IOS小组件/文案
This commit is contained in:
@@ -25,6 +25,9 @@ import {
|
|||||||
getRecoFeedHistory,
|
getRecoFeedHistory,
|
||||||
recordRecoFeedServed,
|
recordRecoFeedServed,
|
||||||
type ThemeMode,
|
type ThemeMode,
|
||||||
|
getSuixinThemeState,
|
||||||
|
setSuixinThemeState,
|
||||||
|
type SuixinThemeStateV1,
|
||||||
} from '@/src/storage/appStorage';
|
} from '@/src/storage/appStorage';
|
||||||
|
|
||||||
import { fetchRecoFeed } from '@/src/services/recoApi';
|
import { fetchRecoFeed } from '@/src/services/recoApi';
|
||||||
@@ -38,6 +41,9 @@ import MyIcon from '@/assets/images/home/my.svg';
|
|||||||
import LikeFilledIcon from '@/assets/images/home/like_filled.svg';
|
import LikeFilledIcon from '@/assets/images/home/like_filled.svg';
|
||||||
import LikeIcon from '@/assets/images/icon/like_icon.svg';
|
import LikeIcon from '@/assets/images/icon/like_icon.svg';
|
||||||
|
|
||||||
|
import { getBootId } from '@/src/utils/bootSession';
|
||||||
|
import { advanceSuixinState, buildInitialSuixinState, NEUTRAL_THEME_COLORS } from '@/src/features/suixinTheme';
|
||||||
|
|
||||||
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||||
|
|
||||||
// 预定义风景图列表
|
// 预定义风景图列表
|
||||||
@@ -83,6 +89,7 @@ export default function HomeScreen() {
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState(0);
|
||||||
const [themeMode, setThemeModeState] = useState<ThemeMode>('scenery');
|
const [themeMode, setThemeModeState] = useState<ThemeMode>('scenery');
|
||||||
|
const [suixinBgColor, setSuixinBgColor] = useState<string>(NEUTRAL_THEME_COLORS[1]);
|
||||||
const [themeOpen, setThemeOpen] = useState(false);
|
const [themeOpen, setThemeOpen] = useState(false);
|
||||||
const [profileOpen, setProfileOpen] = useState(false);
|
const [profileOpen, setProfileOpen] = useState(false);
|
||||||
const [profileName, setProfileName] = useState<string | undefined>(undefined);
|
const [profileName, setProfileName] = useState<string | undefined>(undefined);
|
||||||
@@ -95,12 +102,55 @@ export default function HomeScreen() {
|
|||||||
// 用 ref 持有最新状态,避免 useCallback 依赖 feedItems/isFetching 造成函数 identity 变化 → effect 重复执行
|
// 用 ref 持有最新状态,避免 useCallback 依赖 feedItems/isFetching 造成函数 identity 变化 → effect 重复执行
|
||||||
const feedItemsRef = useRef<FeedItem[]>([]);
|
const feedItemsRef = useRef<FeedItem[]>([]);
|
||||||
const isFetchingRef = useRef(false);
|
const isFetchingRef = useRef(false);
|
||||||
|
const themeModeRef = useRef<ThemeMode>('scenery');
|
||||||
|
const suixinStateRef = useRef<SuixinThemeStateV1 | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
feedItemsRef.current = feedItems;
|
feedItemsRef.current = feedItems;
|
||||||
}, [feedItems]);
|
}, [feedItems]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isFetchingRef.current = isFetching;
|
isFetchingRef.current = isFetching;
|
||||||
}, [isFetching]);
|
}, [isFetching]);
|
||||||
|
useEffect(() => {
|
||||||
|
themeModeRef.current = themeMode;
|
||||||
|
}, [themeMode]);
|
||||||
|
|
||||||
|
const ensureSuixinReady = useCallback(async () => {
|
||||||
|
const bootId = getBootId();
|
||||||
|
const stored = await getSuixinThemeState();
|
||||||
|
if (stored && stored.boot_id === bootId) {
|
||||||
|
suixinStateRef.current = stored;
|
||||||
|
setSuixinBgColor(stored.last_color || NEUTRAL_THEME_COLORS[1]);
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await getUserProfileScoring();
|
||||||
|
const next = buildInitialSuixinState({ bootId, profile });
|
||||||
|
suixinStateRef.current = next;
|
||||||
|
setSuixinBgColor(next.last_color || NEUTRAL_THEME_COLORS[1]);
|
||||||
|
await setSuixinThemeState(next);
|
||||||
|
return next;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const advanceSuixinOnNextContent = useCallback(async () => {
|
||||||
|
if (themeModeRef.current !== 'suixin') return;
|
||||||
|
|
||||||
|
const bootId = getBootId();
|
||||||
|
let current = suixinStateRef.current;
|
||||||
|
if (!current) {
|
||||||
|
current = await getSuixinThemeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 冷启动后首次触发/或状态丢失:先初始化
|
||||||
|
if (!current || current.boot_id !== bootId) {
|
||||||
|
await ensureSuixinReady();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = advanceSuixinState(current);
|
||||||
|
suixinStateRef.current = next;
|
||||||
|
setSuixinBgColor(next.last_color || NEUTRAL_THEME_COLORS[1]);
|
||||||
|
await setSuixinThemeState(next);
|
||||||
|
}, [ensureSuixinReady]);
|
||||||
|
|
||||||
// 动画相关 Shared Values
|
// 动画相关 Shared Values
|
||||||
const translateY = useSharedValue(0);
|
const translateY = useSharedValue(0);
|
||||||
@@ -180,6 +230,14 @@ export default function HomeScreen() {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setThemeModeState(mode);
|
setThemeModeState(mode);
|
||||||
setProfileName(profile.name);
|
setProfileName(profile.name);
|
||||||
|
|
||||||
|
// 随心:若当前主题为随心,进入 Home 时确保状态就绪(仅冷启动会话重算)
|
||||||
|
if (mode === 'suixin') {
|
||||||
|
ensureSuixinReady().catch(() => {
|
||||||
|
// ignore:失败时回退默认中性底色
|
||||||
|
setSuixinBgColor(NEUTRAL_THEME_COLORS[1]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 语言切换时:旧语言缓存不复用,触发重新拉取
|
// 语言切换时:旧语言缓存不复用,触发重新拉取
|
||||||
if (cache && cache.items.length > 0 && (cache.lang ?? 'en') === recoLang) {
|
if (cache && cache.items.length > 0 && (cache.lang ?? 'en') === recoLang) {
|
||||||
@@ -194,16 +252,19 @@ export default function HomeScreen() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [fetchNewFeed, recoLang])
|
}, [fetchNewFeed, recoLang, ensureSuixinReady])
|
||||||
);
|
);
|
||||||
|
|
||||||
const backgroundColor = useMemo(() => {
|
const backgroundColor = useMemo(() => {
|
||||||
|
if (themeMode === 'suixin') {
|
||||||
|
return suixinBgColor;
|
||||||
|
}
|
||||||
if (themeMode === 'color') {
|
if (themeMode === 'color') {
|
||||||
const colorIndex = Math.floor(index / 10) % THEME_COLORS.length;
|
const colorIndex = Math.floor(index / 10) % THEME_COLORS.length;
|
||||||
return THEME_COLORS[colorIndex];
|
return THEME_COLORS[colorIndex];
|
||||||
}
|
}
|
||||||
return '#F4D6C2'; // 风景模式下的默认底色(图片加载前显示)
|
return '#F4D6C2'; // 风景模式下的默认底色(图片加载前显示)
|
||||||
}, [themeMode, index]);
|
}, [themeMode, suixinBgColor, index]);
|
||||||
|
|
||||||
// 计算当前应该显示的风景图索引(滑动 10 次切换一张)
|
// 计算当前应该显示的风景图索引(滑动 10 次切换一张)
|
||||||
const natureImageIndex = useMemo(() => {
|
const natureImageIndex = useMemo(() => {
|
||||||
@@ -233,6 +294,7 @@ export default function HomeScreen() {
|
|||||||
// 2. 切换数据索引
|
// 2. 切换数据索引
|
||||||
runOnJS(setIndex)(index + 1);
|
runOnJS(setIndex)(index + 1);
|
||||||
runOnJS(setLikeFilled)(false);
|
runOnJS(setLikeFilled)(false);
|
||||||
|
runOnJS(advanceSuixinOnNextContent)();
|
||||||
|
|
||||||
// 检查是否需要拉取新文案(当接近当前列表末尾时,例如还剩 5 条)
|
// 检查是否需要拉取新文案(当接近当前列表末尾时,例如还剩 5 条)
|
||||||
if (index + 5 >= currentFeed.length && !isFetching) {
|
if (index + 5 >= currentFeed.length && !isFetching) {
|
||||||
@@ -333,6 +395,13 @@ export default function HomeScreen() {
|
|||||||
setThemeModeState(next);
|
setThemeModeState(next);
|
||||||
await setThemeMode(next);
|
await setThemeMode(next);
|
||||||
setThemeOpen(false);
|
setThemeOpen(false);
|
||||||
|
|
||||||
|
// 切换到随心:不主动重算(除非冷启动会话变化/状态不存在),仅确保可用
|
||||||
|
if (next === 'suixin') {
|
||||||
|
await ensureSuixinReady().catch(() => {
|
||||||
|
setSuixinBgColor(NEUTRAL_THEME_COLORS[1]);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ import { getOrCreateClientUserId } from '@/src/storage/appStorage';
|
|||||||
Notifications.setNotificationHandler({
|
Notifications.setNotificationHandler({
|
||||||
handleNotification: async () => ({
|
handleNotification: async () => ({
|
||||||
shouldShowAlert: true,
|
shouldShowAlert: true,
|
||||||
|
// 新版 expo-notifications 类型要求显式返回 banner/list 行为
|
||||||
|
shouldShowBanner: true,
|
||||||
|
shouldShowList: true,
|
||||||
shouldPlaySound: false,
|
shouldPlaySound: false,
|
||||||
shouldSetBadge: false,
|
shouldSetBadge: false,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import SheetModal from '@/components/ui/SheetModal';
|
import SheetModal from '@/components/ui/SheetModal';
|
||||||
|
|
||||||
export type ThemeMode = 'scenery' | 'color';
|
import type { ThemeMode } from '@/src/storage/appStorage';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -16,7 +16,7 @@ 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();
|
||||||
return (
|
return (
|
||||||
<SheetModal visible={visible} title={t('theme.title')} onClose={onClose} height={350}>
|
<SheetModal visible={visible} title={t('theme.title')} onClose={onClose} height={360}>
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<ThemeCard
|
<ThemeCard
|
||||||
title={t('theme.scenery')}
|
title={t('theme.scenery')}
|
||||||
@@ -41,6 +41,19 @@ export default function ThemeModal({ visible, mode, onSelect, onClose }: Props)
|
|||||||
style={styles.previewImage}
|
style={styles.previewImage}
|
||||||
/>
|
/>
|
||||||
</ThemeCard>
|
</ThemeCard>
|
||||||
|
|
||||||
|
<ThemeCard
|
||||||
|
title={t('theme.suixin')}
|
||||||
|
selected={mode === 'suixin'}
|
||||||
|
onPress={() => onSelect('suixin')}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
// 占位:一期复用纯色预览图,后续可替换为专用资源
|
||||||
|
source={require('../../assets/images/theme/theme_color.png')}
|
||||||
|
resizeMode="cover"
|
||||||
|
style={styles.previewImage}
|
||||||
|
/>
|
||||||
|
</ThemeCard>
|
||||||
</View>
|
</View>
|
||||||
</SheetModal>
|
</SheetModal>
|
||||||
);
|
);
|
||||||
@@ -81,19 +94,20 @@ function ThemeCard({
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
row: {
|
row: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
gap: 30,
|
flexWrap: 'wrap',
|
||||||
paddingHorizontal: 10,
|
gap: 12,
|
||||||
|
paddingHorizontal: 0,
|
||||||
paddingBottom: 50,
|
paddingBottom: 50,
|
||||||
paddingTop: 20,
|
paddingTop: 20,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
cardContainer: {
|
cardContainer: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
width: 143,
|
width: 112,
|
||||||
},
|
},
|
||||||
previewWrapper: {
|
previewWrapper: {
|
||||||
width: 138,
|
width: 110,
|
||||||
height: 203,
|
height: 178,
|
||||||
borderRadius: 26,
|
borderRadius: 26,
|
||||||
padding: 6.5,
|
padding: 6.5,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { View, StyleSheet, TextInput, Platform, Animated, TouchableOpacity, Dimensions, Text } from 'react-native';
|
import { View, StyleSheet, TextInput, Platform, Animated, TouchableOpacity, Text } from 'react-native';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
||||||
import BtnNotClicked from '@/assets/images/icon/btn_Notclicked.svg';
|
import BtnNotClicked from '@/assets/images/icon/btn_Notclicked.svg';
|
||||||
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
|
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
|
||||||
import EnterLightIcon from '@/assets/images/icon/enter_Light_icon.svg';
|
import EnterLightIcon from '@/assets/images/icon/enter_Light_icon.svg';
|
||||||
|
|
||||||
const { height } = Dimensions.get('window');
|
|
||||||
|
|
||||||
interface NameInputStepProps {
|
interface NameInputStepProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChangeText: (text: string) => void;
|
onChangeText: (text: string) => void;
|
||||||
@@ -16,6 +15,7 @@ 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 [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
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;
|
||||||
@@ -71,7 +71,7 @@ export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProp
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.footer}>
|
<View style={[styles.footer, { bottom: insets.bottom + 16 }]}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={onNext}
|
onPress={onNext}
|
||||||
disabled={!hasInput}
|
disabled={!hasInput}
|
||||||
@@ -132,7 +132,6 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: height * 0.12,
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, StyleSheet, TouchableOpacity, Text, Platform, Dimensions } from 'react-native';
|
import { View, StyleSheet, TouchableOpacity, Text, Platform } from 'react-native';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
||||||
import AddIcon from '@/assets/images/icon/add_icon.svg';
|
import AddIcon from '@/assets/images/icon/add_icon.svg';
|
||||||
import ReduceIcon from '@/assets/images/icon/reduce_icon.svg';
|
import ReduceIcon from '@/assets/images/icon/reduce_icon.svg';
|
||||||
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
|
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
|
||||||
|
|
||||||
const { height } = Dimensions.get('window');
|
|
||||||
|
|
||||||
interface ReminderStepProps {
|
interface ReminderStepProps {
|
||||||
value: number;
|
value: number;
|
||||||
onChange: (value: number) => void;
|
onChange: (value: number) => void;
|
||||||
@@ -17,6 +16,7 @@ interface ReminderStepProps {
|
|||||||
|
|
||||||
export function ReminderStep({ value, onChange, onFinish, onSkip }: ReminderStepProps) {
|
export function ReminderStep({ value, onChange, onFinish, onSkip }: ReminderStepProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const handleReduce = () => {
|
const handleReduce = () => {
|
||||||
// 允许 0~5;0 表示关闭每日提醒
|
// 允许 0~5;0 表示关闭每日提醒
|
||||||
@@ -44,7 +44,7 @@ export function ReminderStep({ value, onChange, onFinish, onSkip }: ReminderStep
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.footer}>
|
<View style={[styles.footer, { bottom: insets.bottom + 16 }]}>
|
||||||
<TouchableOpacity onPress={onFinish} activeOpacity={0.8}>
|
<TouchableOpacity onPress={onFinish} activeOpacity={0.8}>
|
||||||
<BtnClicked width={87} height={57} />
|
<BtnClicked width={87} height={57} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -92,7 +92,6 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: height * 0.12,
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
}
|
}
|
||||||
,
|
,
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, StyleSheet, TouchableOpacity, ScrollView, Dimensions } from 'react-native';
|
import { View, StyleSheet, TouchableOpacity, ScrollView } from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
||||||
import { SerifText } from './SerifText';
|
import { SerifText } from './SerifText';
|
||||||
import SelectedIcon from '@/assets/images/icon/selected_icon.svg';
|
import SelectedIcon from '@/assets/images/icon/selected_icon.svg';
|
||||||
import BtnNotClicked from '@/assets/images/icon/btn_Notclicked.svg';
|
import BtnNotClicked from '@/assets/images/icon/btn_Notclicked.svg';
|
||||||
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
|
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
|
||||||
|
|
||||||
const { height } = Dimensions.get('window');
|
|
||||||
|
|
||||||
interface Option {
|
interface Option {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -23,10 +22,18 @@ interface SelectionStepProps {
|
|||||||
|
|
||||||
export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }: SelectionStepProps) {
|
export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }: SelectionStepProps) {
|
||||||
const hasSelection = selectedIds.length > 0;
|
const hasSelection = selectedIds.length > 0;
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const footerBottom = insets.bottom + 16;
|
||||||
|
const footerButtonHeight = 57;
|
||||||
|
const footerPaddingBottom = footerBottom + footerButtonHeight + 24;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.optionsList}>
|
<ScrollView
|
||||||
|
style={styles.scroll}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={[styles.optionsList, { paddingBottom: footerPaddingBottom }]}
|
||||||
|
>
|
||||||
{options.map((option) => {
|
{options.map((option) => {
|
||||||
const isSelected = selectedIds.includes(option.id);
|
const isSelected = selectedIds.includes(option.id);
|
||||||
return (
|
return (
|
||||||
@@ -48,7 +55,7 @@ export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
{/* 底部按钮:距离底部 12% 高度 */}
|
{/* 底部按钮:距离底部 12% 高度 */}
|
||||||
<View style={styles.footer}>
|
<View style={[styles.footer, { bottom: footerBottom }]}>
|
||||||
<TouchableOpacity onPress={onNext} disabled={!hasSelection} activeOpacity={0.8}>
|
<TouchableOpacity onPress={onNext} disabled={!hasSelection} activeOpacity={0.8}>
|
||||||
{hasSelection ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />}
|
{hasSelection ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -62,8 +69,11 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
paddingTop: 20,
|
paddingTop: 20,
|
||||||
},
|
},
|
||||||
|
scroll: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
optionsList: {
|
optionsList: {
|
||||||
paddingBottom: 150, // 为底部按钮留出空间
|
// paddingBottom 由安全区 + 按钮高度动态计算,避免选项被遮住
|
||||||
},
|
},
|
||||||
optionCard: {
|
optionCard: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -92,7 +102,6 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: height * 0.12,
|
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -1,34 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "2620"
|
LastUpgradeVersion = "2620"
|
||||||
version = "2.2">
|
version = "1.7">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "NO"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES">
|
buildImplicitDependencies = "YES">
|
||||||
<BuildActionEntries>
|
<BuildActionEntries>
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<AutocreatedTestPlanReference>
|
|
||||||
</AutocreatedTestPlanReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "E99E52E7F84CEF53B06494B581AFB6E4"
|
|
||||||
BuildableName = "libPods-client.a"
|
|
||||||
BlueprintName = "Pods-client"
|
|
||||||
ReferencedContainer = "container:Pods/Pods.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
<BuildActionEntry
|
<BuildActionEntry
|
||||||
buildForTesting = "YES"
|
buildForTesting = "YES"
|
||||||
buildForRunning = "YES"
|
buildForRunning = "YES"
|
||||||
@@ -95,6 +72,26 @@
|
|||||||
</AnalyzeAction>
|
</AnalyzeAction>
|
||||||
<ArchiveAction
|
<ArchiveAction
|
||||||
buildConfiguration = "Release"
|
buildConfiguration = "Release"
|
||||||
|
customArchiveName = "Hey Mama"
|
||||||
revealArchiveInOrganizer = "YES">
|
revealArchiveInOrganizer = "YES">
|
||||||
|
<PostActions>
|
||||||
|
<ExecutionAction
|
||||||
|
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||||
|
<ActionContent
|
||||||
|
title = "修复归档头信息(避免 Generic Xcode Archive)"
|
||||||
|
scriptText = "bash "${SRCROOT}/scripts/fix-xcarchive-header.sh" "${ARCHIVE_PATH}" "
|
||||||
|
shellToInvoke = "/bin/sh">
|
||||||
|
<EnvironmentBuildable>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||||
|
BuildableName = "HeyMama.app"
|
||||||
|
BlueprintName = "client"
|
||||||
|
ReferencedContainer = "container:client.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</EnvironmentBuildable>
|
||||||
|
</ActionContent>
|
||||||
|
</ExecutionAction>
|
||||||
|
</PostActions>
|
||||||
</ArchiveAction>
|
</ArchiveAction>
|
||||||
</Scheme>
|
</Scheme>
|
||||||
|
|||||||
@@ -10,14 +10,10 @@ import WidgetKit
|
|||||||
* - 值统一使用字符串(通常是 JSON),由 JS 侧负责序列化与反序列化
|
* - 值统一使用字符串(通常是 JSON),由 JS 侧负责序列化与反序列化
|
||||||
*/
|
*/
|
||||||
@objc(AppGroupStorage)
|
@objc(AppGroupStorage)
|
||||||
final class AppGroupStorage: NSObject, RCTBridgeModule {
|
final class AppGroupStorage: NSObject {
|
||||||
static func moduleName() -> String! {
|
// 通过 AppGroupStorageBridge.m 的 RCT_EXTERN_MODULE 导出到 RN
|
||||||
"AppGroupStorage"
|
// 这里不需要显式实现/遵循 RCTBridgeModule,避免某些 Archive 场景下找不到协议类型
|
||||||
}
|
@objc static func requiresMainQueueSetup() -> Bool { false }
|
||||||
|
|
||||||
static func requiresMainQueueSetup() -> Bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
private let suiteName = "group.com.damer.mindfulness"
|
private let suiteName = "group.com.damer.mindfulness"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { UserProfileScoring } from '@/src/storage/appStorage';
|
||||||
|
|
||||||
|
import { advanceSuixinState, buildInitialSuixinState, computeSuixinSolidColor, pickSuixinBaseThemeId } from '../index';
|
||||||
|
import { computeTFromStep } from '../progress';
|
||||||
|
import { lerpHex } from '../colorMath';
|
||||||
|
|
||||||
|
function buildProfile(partial?: Partial<UserProfileScoring>): UserProfileScoring {
|
||||||
|
const base: UserProfileScoring = {
|
||||||
|
profile_version: 'v1.2',
|
||||||
|
profile_source: 'questionnaire',
|
||||||
|
profile_generated_at: '2026-01-30T00:00:00Z',
|
||||||
|
profile_confidence: 1.0,
|
||||||
|
profile_answered: { stage: true, emotion: true, context: true, need: true },
|
||||||
|
stage: { expecting: 1, parenting: 0, unknown: 0 },
|
||||||
|
emotion_score: 0.6,
|
||||||
|
context: {},
|
||||||
|
need: {},
|
||||||
|
rule_hits: [],
|
||||||
|
hard_rules: { forbidden_risk_flags: [], forbidden_content_predicates: [] },
|
||||||
|
};
|
||||||
|
return { ...base, ...(partial ?? {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('suixinTheme', () => {
|
||||||
|
it('pickSuixinBaseThemeId: stage.unknown=1 → neutral', () => {
|
||||||
|
const p = buildProfile({ stage: { unknown: 1 } as any, need: { rest_balance: 1 } });
|
||||||
|
expect(pickSuixinBaseThemeId(p)).toBe('neutral');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pickSuixinBaseThemeId: need 为空 → neutral', () => {
|
||||||
|
const p = buildProfile({ need: {} });
|
||||||
|
expect(pickSuixinBaseThemeId(p)).toBe('neutral');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pickSuixinBaseThemeId: need 命中 → 对应 base theme', () => {
|
||||||
|
const p = buildProfile({ need: { rest_balance: 1 } });
|
||||||
|
expect(pickSuixinBaseThemeId(p)).toBe('rest_balance');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computeTFromStep: 始终在 [0,1] 且往返不突跳', () => {
|
||||||
|
const ts = Array.from({ length: 80 }).map((_, i) => computeTFromStep({ stepIndex: i, segments: 12, seed: 'boot' }));
|
||||||
|
for (const t of ts) {
|
||||||
|
expect(t).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(t).toBeLessThanOrEqual(1);
|
||||||
|
}
|
||||||
|
// 往返波形:起点与一个周期后的 t 相同
|
||||||
|
expect(computeTFromStep({ stepIndex: 0, segments: 12, seed: 'boot' })).toBe(
|
||||||
|
computeTFromStep({ stepIndex: 22, segments: 12, seed: 'boot' }) // 2*(N-1)=22
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lerpHex: t=0/1 输出边界色', () => {
|
||||||
|
expect(lerpHex('#000000', '#FFFFFF', 0)).toBe('#000000');
|
||||||
|
expect(lerpHex('#000000', '#FFFFFF', 1)).toBe('#FFFFFF');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buildInitialSuixinState: 生成可用状态并可推进', () => {
|
||||||
|
const p = buildProfile({ need: { emotional_support: 1 } });
|
||||||
|
const init = buildInitialSuixinState({ bootId: 'boot-1', profile: p, now: new Date('2026-02-05T00:00:00Z') });
|
||||||
|
expect(init.schema_version).toBe(1);
|
||||||
|
expect(init.base_theme_id).toBe('emotional_support');
|
||||||
|
expect(init.boot_id).toBe('boot-1');
|
||||||
|
expect(init.last_color).toMatch(/^#[0-9A-F]{6}$/);
|
||||||
|
|
||||||
|
const next = advanceSuixinState(init, new Date('2026-02-05T00:00:01Z'));
|
||||||
|
expect(next.step_index).toBe(1);
|
||||||
|
expect(next.base_theme_id).toBe(init.base_theme_id);
|
||||||
|
expect(next.last_color).toMatch(/^#[0-9A-F]{6}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computeSuixinSolidColor: 输出为合法 hex', () => {
|
||||||
|
const c = computeSuixinSolidColor({ baseThemeId: 'neutral', stepIndex: 3, seed: 'boot' });
|
||||||
|
expect(c).toMatch(/^#[0-9A-F]{6}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
53
client/src/features/suixinTheme/colorMath.ts
Normal file
53
client/src/features/suixinTheme/colorMath.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
function clamp01(t: number): number {
|
||||||
|
if (!Number.isFinite(t)) return 0;
|
||||||
|
return Math.min(1, Math.max(0, t));
|
||||||
|
}
|
||||||
|
|
||||||
|
type Rgb = { r: number; g: number; b: number };
|
||||||
|
|
||||||
|
function toByte(v: number): number {
|
||||||
|
if (!Number.isFinite(v)) return 0;
|
||||||
|
return Math.min(255, Math.max(0, Math.round(v)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hexToRgb(hex: string): Rgb | null {
|
||||||
|
const h = String(hex || '').trim();
|
||||||
|
const m = /^#?([0-9a-fA-F]{6})$/.exec(h);
|
||||||
|
if (!m) return null;
|
||||||
|
const raw = m[1];
|
||||||
|
const n = parseInt(raw, 16);
|
||||||
|
// eslint-disable-next-line no-bitwise
|
||||||
|
const r = (n >> 16) & 0xff;
|
||||||
|
// eslint-disable-next-line no-bitwise
|
||||||
|
const g = (n >> 8) & 0xff;
|
||||||
|
// eslint-disable-next-line no-bitwise
|
||||||
|
const b = n & 0xff;
|
||||||
|
return { r, g, b };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rgbToHex(rgb: Rgb): string {
|
||||||
|
const r = toByte(rgb.r).toString(16).padStart(2, '0');
|
||||||
|
const g = toByte(rgb.g).toString(16).padStart(2, '0');
|
||||||
|
const b = toByte(rgb.b).toString(16).padStart(2, '0');
|
||||||
|
return `#${r}${g}${b}`.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅允许线性插值(Hard Rule)
|
||||||
|
*/
|
||||||
|
export function lerpRgb(a: Rgb, b: Rgb, t: number): Rgb {
|
||||||
|
const tt = clamp01(t);
|
||||||
|
return {
|
||||||
|
r: a.r + (b.r - a.r) * tt,
|
||||||
|
g: a.g + (b.g - a.g) * tt,
|
||||||
|
b: a.b + (b.b - a.b) * tt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lerpHex(topHex: string, bottomHex: string, t: number, fallbackHex = '#E8F1EC'): string {
|
||||||
|
const top = hexToRgb(topHex);
|
||||||
|
const bottom = hexToRgb(bottomHex);
|
||||||
|
if (!top || !bottom) return fallbackHex;
|
||||||
|
return rgbToHex(lerpRgb(top, bottom, t));
|
||||||
|
}
|
||||||
|
|
||||||
65
client/src/features/suixinTheme/index.ts
Normal file
65
client/src/features/suixinTheme/index.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import type { SuixinBaseThemeId, SuixinThemeStateV1, UserProfileScoring } from '@/src/storage/appStorage';
|
||||||
|
|
||||||
|
import { getThemeTriplet, NEUTRAL_THEME_COLORS } from './palette';
|
||||||
|
import { lerpHex } from './colorMath';
|
||||||
|
import { computeTFromStep } from './progress';
|
||||||
|
import { pickSuixinBaseThemeId } from './pickTheme';
|
||||||
|
|
||||||
|
export { NEUTRAL_THEME_COLORS } from './palette';
|
||||||
|
export { pickSuixinBaseThemeId } from './pickTheme';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 base theme 与 step 计算当前背景纯色
|
||||||
|
*/
|
||||||
|
export function computeSuixinSolidColor(args: {
|
||||||
|
baseThemeId: SuixinBaseThemeId;
|
||||||
|
stepIndex: number;
|
||||||
|
seed: string;
|
||||||
|
}): string {
|
||||||
|
const [top, _mid, bottom] = getThemeTriplet(args.baseThemeId);
|
||||||
|
const t = computeTFromStep({ stepIndex: args.stepIndex, segments: 12, seed: args.seed });
|
||||||
|
return lerpHex(top, bottom, t, NEUTRAL_THEME_COLORS[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化一份随心状态(在冷启动会话内锁定 base theme)
|
||||||
|
*/
|
||||||
|
export function buildInitialSuixinState(args: {
|
||||||
|
bootId: string;
|
||||||
|
profile: UserProfileScoring | null;
|
||||||
|
now?: Date;
|
||||||
|
}): SuixinThemeStateV1 {
|
||||||
|
const now = args.now ?? new Date();
|
||||||
|
const baseThemeId = pickSuixinBaseThemeId(args.profile);
|
||||||
|
const seed = args.bootId;
|
||||||
|
const step_index = 0;
|
||||||
|
const last_color = computeSuixinSolidColor({ baseThemeId, stepIndex: step_index, seed });
|
||||||
|
return {
|
||||||
|
schema_version: 1,
|
||||||
|
saved_at: now.toISOString(),
|
||||||
|
boot_id: args.bootId,
|
||||||
|
base_theme_id: baseThemeId,
|
||||||
|
seed,
|
||||||
|
step_index,
|
||||||
|
last_color,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换文案时推进一步(不跨主题)
|
||||||
|
*/
|
||||||
|
export function advanceSuixinState(prev: SuixinThemeStateV1, now?: Date): SuixinThemeStateV1 {
|
||||||
|
const nextStep = (Number.isFinite(prev.step_index) ? prev.step_index : 0) + 1;
|
||||||
|
const last_color = computeSuixinSolidColor({
|
||||||
|
baseThemeId: prev.base_theme_id,
|
||||||
|
stepIndex: nextStep,
|
||||||
|
seed: prev.seed,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
saved_at: (now ?? new Date()).toISOString(),
|
||||||
|
step_index: nextStep,
|
||||||
|
last_color,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
20
client/src/features/suixinTheme/palette.ts
Normal file
20
client/src/features/suixinTheme/palette.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { SuixinBaseThemeId } from '@/src/storage/appStorage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 随心主题色盘(与设计说明文档保持一致)
|
||||||
|
*/
|
||||||
|
export const BASE_THEME_COLORS: Record<Exclude<SuixinBaseThemeId, 'neutral'>, [string, string, string]> = {
|
||||||
|
emotional_support: ['#F6DCE4', '#FFEFF4', '#FFF7FA'],
|
||||||
|
parenting_pressure: ['#D6EAF5', '#EEF6FB', '#F8FCFF'],
|
||||||
|
self_worth: ['#FFD8A8', '#FFE8C9', '#FFF6E5'],
|
||||||
|
anxiety_relief: ['#DFF3EA', '#ECFBF6', '#F6FFFB'],
|
||||||
|
rest_balance: ['#F2E6D8', '#FAF3EC', '#FFFDF9'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NEUTRAL_THEME_COLORS: [string, string, string] = ['#F4F7F2', '#E8F1EC', '#EDF4F8'];
|
||||||
|
|
||||||
|
export function getThemeTriplet(themeId: SuixinBaseThemeId): [string, string, string] {
|
||||||
|
if (themeId === 'neutral') return NEUTRAL_THEME_COLORS;
|
||||||
|
return BASE_THEME_COLORS[themeId];
|
||||||
|
}
|
||||||
|
|
||||||
32
client/src/features/suixinTheme/pickTheme.ts
Normal file
32
client/src/features/suixinTheme/pickTheme.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { SuixinBaseThemeId, UserProfileScoring } from '@/src/storage/appStorage';
|
||||||
|
|
||||||
|
const ALL_NEED_THEME_IDS: ReadonlySet<string> = new Set([
|
||||||
|
'emotional_support',
|
||||||
|
'parenting_pressure',
|
||||||
|
'self_worth',
|
||||||
|
'anxiety_relief',
|
||||||
|
'rest_balance',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base Theme 选择(Theme Picking)
|
||||||
|
*
|
||||||
|
* Hard Rules(对齐设计文档):
|
||||||
|
* - mom_stage = unknown → 强制 Neutral
|
||||||
|
* - need 跳过/缺失 → Neutral
|
||||||
|
*/
|
||||||
|
export function pickSuixinBaseThemeId(profile: UserProfileScoring | null | undefined): SuixinBaseThemeId {
|
||||||
|
if (!profile) return 'neutral';
|
||||||
|
|
||||||
|
// Hard Rule:unknown → Neutral
|
||||||
|
if (profile.stage?.unknown === 1) return 'neutral';
|
||||||
|
|
||||||
|
const needObj = profile.need ?? {};
|
||||||
|
const keys = Object.keys(needObj);
|
||||||
|
if (keys.length === 0) return 'neutral';
|
||||||
|
|
||||||
|
const needId = keys[0];
|
||||||
|
if (!ALL_NEED_THEME_IDS.has(needId)) return 'neutral';
|
||||||
|
return needId as Exclude<SuixinBaseThemeId, 'neutral'>;
|
||||||
|
}
|
||||||
|
|
||||||
40
client/src/features/suixinTheme/progress.ts
Normal file
40
client/src/features/suixinTheme/progress.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
function clampInt(n: number, min: number, max: number): number {
|
||||||
|
if (!Number.isFinite(n)) return min;
|
||||||
|
return Math.min(max, Math.max(min, Math.floor(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashStringToInt32(input: string): number {
|
||||||
|
// 简单可复现 hash:用于把 seed 映射为偏移量(不用于安全场景)
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < input.length; i += 1) {
|
||||||
|
// eslint-disable-next-line no-bitwise
|
||||||
|
h = (h * 31 + input.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 t ∈ [0,1],用于 Color(t)=lerp(top,bottom,t)
|
||||||
|
*
|
||||||
|
* 说明:
|
||||||
|
* - Home 没有 scroll,用“切换文案 step_index”模拟连续流动
|
||||||
|
* - 采用往返波形,避免从 1 回到 0 的突跳
|
||||||
|
*/
|
||||||
|
export function computeTFromStep(args: {
|
||||||
|
stepIndex: number;
|
||||||
|
segments?: number; // N,默认 12
|
||||||
|
seed?: string; // 允许用 seed 做初始相位偏移(同一次冷启动内稳定)
|
||||||
|
}): number {
|
||||||
|
const N = clampInt(args.segments ?? 12, 3, 60);
|
||||||
|
const stepIndex = clampInt(args.stepIndex, 0, 1_000_000_000);
|
||||||
|
const period = 2 * (N - 1);
|
||||||
|
|
||||||
|
const seed = String(args.seed ?? '');
|
||||||
|
const offset = seed ? Math.abs(hashStringToInt32(seed)) % period : 0;
|
||||||
|
|
||||||
|
const phase = (stepIndex + offset) % period;
|
||||||
|
const up = phase <= (N - 1);
|
||||||
|
const pos = up ? phase : period - phase;
|
||||||
|
return pos / (N - 1);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -97,7 +97,8 @@
|
|||||||
"theme": {
|
"theme": {
|
||||||
"title": "Theme",
|
"title": "Theme",
|
||||||
"scenery": "Scenery",
|
"scenery": "Scenery",
|
||||||
"color": "Color"
|
"color": "Color",
|
||||||
|
"suixin": "Ease"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"title": "Me",
|
"title": "Me",
|
||||||
@@ -258,7 +259,8 @@
|
|||||||
"theme": {
|
"theme": {
|
||||||
"title": "主題",
|
"title": "主題",
|
||||||
"scenery": "風景",
|
"scenery": "風景",
|
||||||
"color": "顏色"
|
"color": "顏色",
|
||||||
|
"suixin": "隨心"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"title": "我的",
|
"title": "我的",
|
||||||
|
|||||||
@@ -16,17 +16,40 @@ const KEY_USER_PROFILE_SCORING = 'user.profileScoring';
|
|||||||
const KEY_RECO_FEED_CACHE = 'reco.feedCache';
|
const KEY_RECO_FEED_CACHE = 'reco.feedCache';
|
||||||
const KEY_RECO_FEED_HISTORY = 'reco.feedHistory';
|
const KEY_RECO_FEED_HISTORY = 'reco.feedHistory';
|
||||||
const KEY_UI_THEME_MODE = 'ui.theme.mode';
|
const KEY_UI_THEME_MODE = 'ui.theme.mode';
|
||||||
|
const KEY_UI_THEME_SUIXIN_STATE = 'ui.theme.suixin.state';
|
||||||
const KEY_DAILY_REMINDER_SETTINGS = 'dailyReminder.settings';
|
const KEY_DAILY_REMINDER_SETTINGS = 'dailyReminder.settings';
|
||||||
|
|
||||||
export type PushPromptState = 'enabled' | 'skipped' | 'unknown';
|
export type PushPromptState = 'enabled' | 'skipped' | 'unknown';
|
||||||
export type Reaction = 'like' | 'dislike';
|
export type Reaction = 'like' | 'dislike';
|
||||||
export type ReactionsMap = Record<string, Reaction>;
|
export type ReactionsMap = Record<string, Reaction>;
|
||||||
export type ThemeMode = 'scenery' | 'color';
|
export type ThemeMode = 'scenery' | 'color' | 'suixin';
|
||||||
export type UserProfile = {
|
export type UserProfile = {
|
||||||
name?: string;
|
name?: string;
|
||||||
intents?: string[];
|
intents?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SuixinBaseThemeId =
|
||||||
|
| 'neutral'
|
||||||
|
| 'emotional_support'
|
||||||
|
| 'parenting_pressure'
|
||||||
|
| 'self_worth'
|
||||||
|
| 'anxiety_relief'
|
||||||
|
| 'rest_balance';
|
||||||
|
|
||||||
|
export type SuixinThemeStateV1 = {
|
||||||
|
schema_version: 1;
|
||||||
|
saved_at: string; // ISO8601
|
||||||
|
/**
|
||||||
|
* 冷启动会话标记(进程级)。
|
||||||
|
* 用于确保:仅在冷启动时重置 base theme/seed。
|
||||||
|
*/
|
||||||
|
boot_id: string;
|
||||||
|
base_theme_id: SuixinBaseThemeId;
|
||||||
|
seed: string;
|
||||||
|
step_index: number;
|
||||||
|
last_color: string; // "#RRGGBB"
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户画像(问卷打分输出)
|
* 用户画像(问卷打分输出)
|
||||||
* 说明:用于推荐/Push/Widget 统一复用;结构以 `src/features/userProfileScoring` 输出为准。
|
* 说明:用于推荐/Push/Widget 统一复用;结构以 `src/features/userProfileScoring` 输出为准。
|
||||||
@@ -205,7 +228,7 @@ export async function setConsentAccepted(accepted: boolean): Promise<void> {
|
|||||||
|
|
||||||
export async function getThemeMode(): Promise<ThemeMode> {
|
export async function getThemeMode(): Promise<ThemeMode> {
|
||||||
const raw = await AsyncStorage.getItem(KEY_UI_THEME_MODE);
|
const raw = await AsyncStorage.getItem(KEY_UI_THEME_MODE);
|
||||||
if (raw === 'scenery' || raw === 'color') return raw;
|
if (raw === 'scenery' || raw === 'color' || raw === 'suixin') return raw;
|
||||||
return 'scenery';
|
return 'scenery';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,6 +236,28 @@ export async function setThemeMode(mode: ThemeMode): Promise<void> {
|
|||||||
await AsyncStorage.setItem(KEY_UI_THEME_MODE, mode);
|
await AsyncStorage.setItem(KEY_UI_THEME_MODE, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getSuixinThemeState(): Promise<SuixinThemeStateV1 | null> {
|
||||||
|
const raw = await AsyncStorage.getItem(KEY_UI_THEME_SUIXIN_STATE);
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as Partial<SuixinThemeStateV1>;
|
||||||
|
if (parsed.schema_version !== 1) return null;
|
||||||
|
if (typeof parsed.boot_id !== 'string') return null;
|
||||||
|
if (typeof parsed.base_theme_id !== 'string') return null;
|
||||||
|
if (typeof parsed.seed !== 'string') return null;
|
||||||
|
if (typeof parsed.step_index !== 'number') return null;
|
||||||
|
if (typeof parsed.last_color !== 'string') return null;
|
||||||
|
if (typeof parsed.saved_at !== 'string') return null;
|
||||||
|
return parsed as SuixinThemeStateV1;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setSuixinThemeState(state: SuixinThemeStateV1): Promise<void> {
|
||||||
|
await setJson(KEY_UI_THEME_SUIXIN_STATE, state);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getUserProfile(): Promise<UserProfile> {
|
export async function getUserProfile(): Promise<UserProfile> {
|
||||||
return await getJson<UserProfile>(KEY_USER_PROFILE, {});
|
return await getJson<UserProfile>(KEY_USER_PROFILE, {});
|
||||||
}
|
}
|
||||||
|
|||||||
16
client/src/utils/bootSession.ts
Normal file
16
client/src/utils/bootSession.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* 冷启动会话标记(进程级、仅内存)。
|
||||||
|
*
|
||||||
|
* 目的:
|
||||||
|
* - 在“随心”主题中实现:仅在冷启动时重置 base theme/seed
|
||||||
|
* - 不落盘,避免污染 AsyncStorage
|
||||||
|
*/
|
||||||
|
let bootId: string | null = null;
|
||||||
|
|
||||||
|
export function getBootId(): string {
|
||||||
|
if (bootId) return bootId;
|
||||||
|
// 说明:无需加密强随机;只要在一次进程周期内稳定、不同冷启动尽量不同即可
|
||||||
|
bootId = `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||||
|
return bootId;
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user