From 8f84f256161b1070a055b0da63b824309856fc72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E6=96=B0=E9=9B=A8?= Date: Thu, 5 Feb 2026 01:49:29 +0800 Subject: [PATCH] =?UTF-8?q?IOS=E5=B0=8F=E7=BB=84=E4=BB=B6/=E6=96=87?= =?UTF-8?q?=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/app/(app)/home.tsx | 73 ++++++++++++++++- client/app/_layout.tsx | 3 + client/components/home/ThemeModal.tsx | 28 +++++-- .../components/onboarding/NameInputStep.tsx | 9 +-- client/components/onboarding/ReminderStep.tsx | 9 +-- .../components/onboarding/SelectionStep.tsx | 23 ++++-- .../xcshareddata/xcschemes/Hey Mama.xcscheme | 47 ++++++----- client/ios/client/AppGroupStorage.swift | 12 +-- .../suixinTheme/__tests__/suixinTheme.test.ts | 78 +++++++++++++++++++ client/src/features/suixinTheme/colorMath.ts | 53 +++++++++++++ client/src/features/suixinTheme/index.ts | 65 ++++++++++++++++ client/src/features/suixinTheme/palette.ts | 20 +++++ client/src/features/suixinTheme/pickTheme.ts | 32 ++++++++ client/src/features/suixinTheme/progress.ts | 40 ++++++++++ client/src/i18n/locales/all.json | 6 +- client/src/storage/appStorage.ts | 49 +++++++++++- client/src/utils/bootSession.ts | 16 ++++ 17 files changed, 500 insertions(+), 63 deletions(-) create mode 100644 client/src/features/suixinTheme/__tests__/suixinTheme.test.ts create mode 100644 client/src/features/suixinTheme/colorMath.ts create mode 100644 client/src/features/suixinTheme/index.ts create mode 100644 client/src/features/suixinTheme/palette.ts create mode 100644 client/src/features/suixinTheme/pickTheme.ts create mode 100644 client/src/features/suixinTheme/progress.ts create mode 100644 client/src/utils/bootSession.ts diff --git a/client/app/(app)/home.tsx b/client/app/(app)/home.tsx index d403fd5..23f1046 100644 --- a/client/app/(app)/home.tsx +++ b/client/app/(app)/home.tsx @@ -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('scenery'); + const [suixinBgColor, setSuixinBgColor] = useState(NEUTRAL_THEME_COLORS[1]); const [themeOpen, setThemeOpen] = useState(false); const [profileOpen, setProfileOpen] = useState(false); const [profileName, setProfileName] = useState(undefined); @@ -95,12 +102,55 @@ export default function HomeScreen() { // 用 ref 持有最新状态,避免 useCallback 依赖 feedItems/isFetching 造成函数 identity 变化 → effect 重复执行 const feedItemsRef = useRef([]); const isFetchingRef = useRef(false); + const themeModeRef = useRef('scenery'); + const suixinStateRef = useRef(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 ( diff --git a/client/app/_layout.tsx b/client/app/_layout.tsx index a73c1ae..1de466c 100644 --- a/client/app/_layout.tsx +++ b/client/app/_layout.tsx @@ -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, }), diff --git a/client/components/home/ThemeModal.tsx b/client/components/home/ThemeModal.tsx index 54e8207..6e8202f 100644 --- a/client/components/home/ThemeModal.tsx +++ b/client/components/home/ThemeModal.tsx @@ -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 ( - + + + onSelect('suixin')} + > + + ); @@ -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', diff --git a/client/components/onboarding/NameInputStep.tsx b/client/components/onboarding/NameInputStep.tsx index b02c289..7f36da2 100644 --- a/client/components/onboarding/NameInputStep.tsx +++ b/client/components/onboarding/NameInputStep.tsx @@ -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 - + void; @@ -17,6 +16,7 @@ interface ReminderStepProps { export function ReminderStep({ value, onChange, onFinish, onSkip }: ReminderStepProps) { const { t } = useTranslation(); + const insets = useSafeAreaInsets(); const handleReduce = () => { // 允许 0~5;0 表示关闭每日提醒 @@ -44,7 +44,7 @@ export function ReminderStep({ value, onChange, onFinish, onSkip }: ReminderStep - + @@ -92,7 +92,6 @@ const styles = StyleSheet.create({ }, footer: { position: 'absolute', - bottom: height * 0.12, alignItems: 'center', } , diff --git a/client/components/onboarding/SelectionStep.tsx b/client/components/onboarding/SelectionStep.tsx index 5cd265a..2bb9e25 100644 --- a/client/components/onboarding/SelectionStep.tsx +++ b/client/components/onboarding/SelectionStep.tsx @@ -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 ( - + {options.map((option) => { const isSelected = selectedIds.includes(option.id); return ( @@ -48,7 +55,7 @@ export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip } {/* 底部按钮:距离底部 12% 高度 */} - + {hasSelection ? : } @@ -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', diff --git a/client/ios/client.xcodeproj/xcshareddata/xcschemes/Hey Mama.xcscheme b/client/ios/client.xcodeproj/xcshareddata/xcschemes/Hey Mama.xcscheme index 158949e..758ac77 100644 --- a/client/ios/client.xcodeproj/xcshareddata/xcschemes/Hey Mama.xcscheme +++ b/client/ios/client.xcodeproj/xcshareddata/xcschemes/Hey Mama.xcscheme @@ -1,34 +1,11 @@ + version = "1.7"> - - - - - - - - + + + + + + + + + + diff --git a/client/ios/client/AppGroupStorage.swift b/client/ios/client/AppGroupStorage.swift index de7e220..81757db 100644 --- a/client/ios/client/AppGroupStorage.swift +++ b/client/ios/client/AppGroupStorage.swift @@ -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" diff --git a/client/src/features/suixinTheme/__tests__/suixinTheme.test.ts b/client/src/features/suixinTheme/__tests__/suixinTheme.test.ts new file mode 100644 index 0000000..d6bb441 --- /dev/null +++ b/client/src/features/suixinTheme/__tests__/suixinTheme.test.ts @@ -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 { + 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}$/); + }); +}); + diff --git a/client/src/features/suixinTheme/colorMath.ts b/client/src/features/suixinTheme/colorMath.ts new file mode 100644 index 0000000..6056bc0 --- /dev/null +++ b/client/src/features/suixinTheme/colorMath.ts @@ -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)); +} + diff --git a/client/src/features/suixinTheme/index.ts b/client/src/features/suixinTheme/index.ts new file mode 100644 index 0000000..4469bad --- /dev/null +++ b/client/src/features/suixinTheme/index.ts @@ -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, + }; +} + diff --git a/client/src/features/suixinTheme/palette.ts b/client/src/features/suixinTheme/palette.ts new file mode 100644 index 0000000..5d1cc2a --- /dev/null +++ b/client/src/features/suixinTheme/palette.ts @@ -0,0 +1,20 @@ +import type { SuixinBaseThemeId } from '@/src/storage/appStorage'; + +/** + * 随心主题色盘(与设计说明文档保持一致) + */ +export const BASE_THEME_COLORS: Record, [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]; +} + diff --git a/client/src/features/suixinTheme/pickTheme.ts b/client/src/features/suixinTheme/pickTheme.ts new file mode 100644 index 0000000..9de195a --- /dev/null +++ b/client/src/features/suixinTheme/pickTheme.ts @@ -0,0 +1,32 @@ +import type { SuixinBaseThemeId, UserProfileScoring } from '@/src/storage/appStorage'; + +const ALL_NEED_THEME_IDS: ReadonlySet = 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; +} + diff --git a/client/src/features/suixinTheme/progress.ts b/client/src/features/suixinTheme/progress.ts new file mode 100644 index 0000000..17cfaca --- /dev/null +++ b/client/src/features/suixinTheme/progress.ts @@ -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); +} + diff --git a/client/src/i18n/locales/all.json b/client/src/i18n/locales/all.json index 8b69153..a73c368 100644 --- a/client/src/i18n/locales/all.json +++ b/client/src/i18n/locales/all.json @@ -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": "我的", diff --git a/client/src/storage/appStorage.ts b/client/src/storage/appStorage.ts index 0ed6b11..db6bea1 100644 --- a/client/src/storage/appStorage.ts +++ b/client/src/storage/appStorage.ts @@ -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; -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 { export async function getThemeMode(): Promise { 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 { await AsyncStorage.setItem(KEY_UI_THEME_MODE, mode); } +export async function getSuixinThemeState(): Promise { + const raw = await AsyncStorage.getItem(KEY_UI_THEME_SUIXIN_STATE); + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as Partial; + 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 { + await setJson(KEY_UI_THEME_SUIXIN_STATE, state); +} + export async function getUserProfile(): Promise { return await getJson(KEY_USER_PROFILE, {}); } diff --git a/client/src/utils/bootSession.ts b/client/src/utils/bootSession.ts new file mode 100644 index 0000000..6e391f6 --- /dev/null +++ b/client/src/utils/bootSession.ts @@ -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; +} +