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

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