IOS小组件/文案

This commit is contained in:
吕新雨
2026-02-05 01:49:29 +08:00
parent 4c03fce720
commit 8f84f25616
17 changed files with 500 additions and 63 deletions

View File

@@ -25,6 +25,9 @@ import {
getRecoFeedHistory,
recordRecoFeedServed,
type ThemeMode,
getSuixinThemeState,
setSuixinThemeState,
type SuixinThemeStateV1,
} from '@/src/storage/appStorage';
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 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');
// 预定义风景图列表
@@ -83,6 +89,7 @@ export default function HomeScreen() {
const insets = useSafeAreaInsets();
const [index, setIndex] = useState(0);
const [themeMode, setThemeModeState] = useState<ThemeMode>('scenery');
const [suixinBgColor, setSuixinBgColor] = useState<string>(NEUTRAL_THEME_COLORS[1]);
const [themeOpen, setThemeOpen] = useState(false);
const [profileOpen, setProfileOpen] = useState(false);
const [profileName, setProfileName] = useState<string | undefined>(undefined);
@@ -95,12 +102,55 @@ export default function HomeScreen() {
// 用 ref 持有最新状态,避免 useCallback 依赖 feedItems/isFetching 造成函数 identity 变化 → effect 重复执行
const feedItemsRef = useRef<FeedItem[]>([]);
const isFetchingRef = useRef(false);
const themeModeRef = useRef<ThemeMode>('scenery');
const suixinStateRef = useRef<SuixinThemeStateV1 | null>(null);
useEffect(() => {
feedItemsRef.current = feedItems;
}, [feedItems]);
useEffect(() => {
isFetchingRef.current = 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
const translateY = useSharedValue(0);
@@ -180,6 +230,14 @@ export default function HomeScreen() {
if (cancelled) return;
setThemeModeState(mode);
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) {
@@ -194,16 +252,19 @@ export default function HomeScreen() {
return () => {
cancelled = true;
};
}, [fetchNewFeed, recoLang])
}, [fetchNewFeed, recoLang, ensureSuixinReady])
);
const backgroundColor = useMemo(() => {
if (themeMode === 'suixin') {
return suixinBgColor;
}
if (themeMode === 'color') {
const colorIndex = Math.floor(index / 10) % THEME_COLORS.length;
return THEME_COLORS[colorIndex];
}
return '#F4D6C2'; // 风景模式下的默认底色(图片加载前显示)
}, [themeMode, index]);
}, [themeMode, suixinBgColor, index]);
// 计算当前应该显示的风景图索引(滑动 10 次切换一张)
const natureImageIndex = useMemo(() => {
@@ -233,6 +294,7 @@ export default function HomeScreen() {
// 2. 切换数据索引
runOnJS(setIndex)(index + 1);
runOnJS(setLikeFilled)(false);
runOnJS(advanceSuixinOnNextContent)();
// 检查是否需要拉取新文案(当接近当前列表末尾时,例如还剩 5 条)
if (index + 5 >= currentFeed.length && !isFetching) {
@@ -333,6 +395,13 @@ export default function HomeScreen() {
setThemeModeState(next);
await setThemeMode(next);
setThemeOpen(false);
// 切换到随心:不主动重算(除非冷启动会话变化/状态不存在),仅确保可用
if (next === 'suixin') {
await ensureSuixinReady().catch(() => {
setSuixinBgColor(NEUTRAL_THEME_COLORS[1]);
});
}
}
return (

View File

@@ -17,6 +17,9 @@ import { getOrCreateClientUserId } from '@/src/storage/appStorage';
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
// 新版 expo-notifications 类型要求显式返回 banner/list 行为
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: false,
shouldSetBadge: false,
}),

View File

@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import SheetModal from '@/components/ui/SheetModal';
export type ThemeMode = 'scenery' | 'color';
import type { ThemeMode } from '@/src/storage/appStorage';
type Props = {
visible: boolean;
@@ -16,7 +16,7 @@ type Props = {
export default function ThemeModal({ visible, mode, onSelect, onClose }: Props) {
const { t } = useTranslation();
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}>
<ThemeCard
title={t('theme.scenery')}
@@ -41,6 +41,19 @@ export default function ThemeModal({ visible, mode, onSelect, onClose }: Props)
style={styles.previewImage}
/>
</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>
</SheetModal>
);
@@ -81,19 +94,20 @@ function ThemeCard({
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
gap: 30,
paddingHorizontal: 10,
flexWrap: 'wrap',
gap: 12,
paddingHorizontal: 0,
paddingBottom: 50,
paddingTop: 20,
justifyContent: 'center',
},
cardContainer: {
alignItems: 'center',
width: 143,
width: 112,
},
previewWrapper: {
width: 138,
height: 203,
width: 110,
height: 178,
borderRadius: 26,
padding: 6.5,
justifyContent: 'center',

View File

@@ -1,13 +1,12 @@
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 { useSafeAreaInsets } from 'react-native-safe-area-context';
import { OnboardingColors } from '@/constants/OnboardingTheme';
import BtnNotClicked from '@/assets/images/icon/btn_Notclicked.svg';
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
import EnterLightIcon from '@/assets/images/icon/enter_Light_icon.svg';
const { height } = Dimensions.get('window');
interface NameInputStepProps {
value: string;
onChangeText: (text: string) => void;
@@ -16,6 +15,7 @@ interface NameInputStepProps {
export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProps) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [isFocused, setIsFocused] = useState(false);
const blinkAnim = useRef(new Animated.Value(1)).current;
const hasInput = value.trim().length > 0;
@@ -71,7 +71,7 @@ export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProp
</View>
</View>
<View style={styles.footer}>
<View style={[styles.footer, { bottom: insets.bottom + 16 }]}>
<TouchableOpacity
onPress={onNext}
disabled={!hasInput}
@@ -132,7 +132,6 @@ const styles = StyleSheet.create({
},
footer: {
position: 'absolute',
bottom: height * 0.12,
alignItems: 'center',
}
});

View File

@@ -1,13 +1,12 @@
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 { useSafeAreaInsets } from 'react-native-safe-area-context';
import { OnboardingColors } from '@/constants/OnboardingTheme';
import AddIcon from '@/assets/images/icon/add_icon.svg';
import ReduceIcon from '@/assets/images/icon/reduce_icon.svg';
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
const { height } = Dimensions.get('window');
interface ReminderStepProps {
value: number;
onChange: (value: number) => void;
@@ -17,6 +16,7 @@ interface ReminderStepProps {
export function ReminderStep({ value, onChange, onFinish, onSkip }: ReminderStepProps) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const handleReduce = () => {
// 允许 050 表示关闭每日提醒
@@ -44,7 +44,7 @@ export function ReminderStep({ value, onChange, onFinish, onSkip }: ReminderStep
</TouchableOpacity>
</View>
<View style={styles.footer}>
<View style={[styles.footer, { bottom: insets.bottom + 16 }]}>
<TouchableOpacity onPress={onFinish} activeOpacity={0.8}>
<BtnClicked width={87} height={57} />
</TouchableOpacity>
@@ -92,7 +92,6 @@ const styles = StyleSheet.create({
},
footer: {
position: 'absolute',
bottom: height * 0.12,
alignItems: 'center',
}
,

View File

@@ -1,13 +1,12 @@
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 { SerifText } from './SerifText';
import SelectedIcon from '@/assets/images/icon/selected_icon.svg';
import BtnNotClicked from '@/assets/images/icon/btn_Notclicked.svg';
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
const { height } = Dimensions.get('window');
interface Option {
id: string;
label: string;
@@ -23,10 +22,18 @@ interface SelectionStepProps {
export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }: SelectionStepProps) {
const hasSelection = selectedIds.length > 0;
const insets = useSafeAreaInsets();
const footerBottom = insets.bottom + 16;
const footerButtonHeight = 57;
const footerPaddingBottom = footerBottom + footerButtonHeight + 24;
return (
<View style={styles.container}>
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.optionsList}>
<ScrollView
style={styles.scroll}
showsVerticalScrollIndicator={false}
contentContainerStyle={[styles.optionsList, { paddingBottom: footerPaddingBottom }]}
>
{options.map((option) => {
const isSelected = selectedIds.includes(option.id);
return (
@@ -48,7 +55,7 @@ export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }
</ScrollView>
{/* 底部按钮:距离底部 12% 高度 */}
<View style={styles.footer}>
<View style={[styles.footer, { bottom: footerBottom }]}>
<TouchableOpacity onPress={onNext} disabled={!hasSelection} activeOpacity={0.8}>
{hasSelection ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />}
</TouchableOpacity>
@@ -62,8 +69,11 @@ const styles = StyleSheet.create({
flex: 1,
paddingTop: 20,
},
scroll: {
flex: 1,
},
optionsList: {
paddingBottom: 150, // 为底部按钮留出空间
// paddingBottom 由安全区 + 按钮高度动态计算,避免选项被遮住
},
optionCard: {
width: '100%',
@@ -92,7 +102,6 @@ const styles = StyleSheet.create({
},
footer: {
position: 'absolute',
bottom: height * 0.12,
left: 0,
right: 0,
alignItems: 'center',

View File

@@ -1,34 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
version = "2.2">
version = "1.7">
<BuildAction
parallelizeBuildables = "NO"
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<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
buildForTesting = "YES"
buildForRunning = "YES"
@@ -95,6 +72,26 @@
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
customArchiveName = "Hey Mama"
revealArchiveInOrganizer = "YES">
<PostActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "&#x4fee;&#x590d;&#x5f52;&#x6863;&#x5934;&#x4fe1;&#x606f;&#xff08;&#x907f;&#x514d; Generic Xcode Archive&#xff09;"
scriptText = "bash &quot;${SRCROOT}/scripts/fix-xcarchive-header.sh&quot; &quot;${ARCHIVE_PATH}&quot;&#10;"
shellToInvoke = "/bin/sh">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "HeyMama.app"
BlueprintName = "client"
ReferencedContainer = "container:client.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PostActions>
</ArchiveAction>
</Scheme>

View File

@@ -10,14 +10,10 @@ import WidgetKit
* - 使 JSON JS
*/
@objc(AppGroupStorage)
final class AppGroupStorage: NSObject, RCTBridgeModule {
static func moduleName() -> String! {
"AppGroupStorage"
}
static func requiresMainQueueSetup() -> Bool {
false
}
final class AppGroupStorage: NSObject {
// AppGroupStorageBridge.m RCT_EXTERN_MODULE RN
// / RCTBridgeModule Archive
@objc static func requiresMainQueueSetup() -> Bool { false }
private let suiteName = "group.com.damer.mindfulness"

View File

@@ -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}$/);
});
});

View 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));
}

View 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,
};
}

View 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];
}

View 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 Ruleunknown → 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'>;
}

View 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);
}

View File

@@ -97,7 +97,8 @@
"theme": {
"title": "Theme",
"scenery": "Scenery",
"color": "Color"
"color": "Color",
"suixin": "Ease"
},
"profile": {
"title": "Me",
@@ -258,7 +259,8 @@
"theme": {
"title": "主題",
"scenery": "風景",
"color": "顏色"
"color": "顏色",
"suixin": "隨心"
},
"profile": {
"title": "我的",

View File

@@ -16,17 +16,40 @@ const KEY_USER_PROFILE_SCORING = 'user.profileScoring';
const KEY_RECO_FEED_CACHE = 'reco.feedCache';
const KEY_RECO_FEED_HISTORY = 'reco.feedHistory';
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';
export type PushPromptState = 'enabled' | 'skipped' | 'unknown';
export type Reaction = 'like' | 'dislike';
export type ReactionsMap = Record<string, Reaction>;
export type ThemeMode = 'scenery' | 'color';
export type ThemeMode = 'scenery' | 'color' | 'suixin';
export type UserProfile = {
name?: 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` 输出为准。
@@ -205,7 +228,7 @@ export async function setConsentAccepted(accepted: boolean): Promise<void> {
export async function getThemeMode(): Promise<ThemeMode> {
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';
}
@@ -213,6 +236,28 @@ export async function setThemeMode(mode: ThemeMode): Promise<void> {
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> {
return await getJson<UserProfile>(KEY_USER_PROFILE, {});
}

View 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;
}