import AsyncStorage from '@react-native-async-storage/async-storage'; import type { UserProfileV1_2_Extended } from '@/src/features/userProfileScoring'; /** * 本地存储 key 统一管理,避免 UI 里散落硬编码 */ const KEY_ONBOARDING_COMPLETED = 'onboarding.completed'; const KEY_PUSH_PROMPT_STATE = 'push.promptState'; const KEY_CONTENT_REACTIONS = 'content.reactions'; const KEY_FAVORITES_ITEMS = 'favorites.items'; const KEY_CONSENT_ACCEPTED = 'consent.accepted'; const KEY_USER_PROFILE = 'user.profile'; 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_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 UserProfile = { name?: string; intents?: string[]; }; /** * 用户画像(问卷打分输出) * 说明:用于推荐/Push/Widget 统一复用;结构以 `src/features/userProfileScoring` 输出为准。 */ export type UserProfileScoring = UserProfileV1_2_Extended; export type DailyReminderSettings = { timesPerDay: number; pushEnabled: boolean; }; export type RecoFeedCacheItem = { content_id: number; text: string; }; export type RecoFeedCache = { saved_at: string; // ISO8601 items: RecoFeedCacheItem[]; meta?: Record; }; /** * Feed 链路可观测输入(用于下一次请求携带给后端) * * - already_recommended_ids:本设备已下发过的内容(避免重复下发) * - touched_or_viewed_ids:本设备用户已看过/划过的内容(用于频控/去重/降重复) * * 说明:后端不需要“实时知道”,只要在下一次拉取时带上即可。 */ export type RecoFeedHistory = { updated_at: string; // ISO8601 already_recommended_ids: number[]; touched_or_viewed_ids: number[]; }; async function getJson(key: string, fallback: T): Promise { const raw = await AsyncStorage.getItem(key); if (!raw) return fallback; try { return JSON.parse(raw) as T; } catch { return fallback; } } async function setJson(key: string, value: T): Promise { await AsyncStorage.setItem(key, JSON.stringify(value)); } export async function getOnboardingCompleted(): Promise { const raw = await AsyncStorage.getItem(KEY_ONBOARDING_COMPLETED); return raw === 'true'; } export async function setOnboardingCompleted(completed: boolean): Promise { await AsyncStorage.setItem(KEY_ONBOARDING_COMPLETED, completed ? 'true' : 'false'); } export async function getPushPromptState(): Promise { const raw = await AsyncStorage.getItem(KEY_PUSH_PROMPT_STATE); if (raw === 'enabled' || raw === 'skipped' || raw === 'unknown') return raw; return 'unknown'; } export async function setPushPromptState(state: PushPromptState): Promise { await AsyncStorage.setItem(KEY_PUSH_PROMPT_STATE, state); } export async function getReactions(): Promise { return await getJson(KEY_CONTENT_REACTIONS, {}); } export async function setReaction(contentId: string, reaction: Reaction): Promise { const next = await getReactions(); next[contentId] = reaction; await setJson(KEY_CONTENT_REACTIONS, next); } export type FavoriteItem = { favId: string; // 唯一标识,支持重复点赞同一文案 id: string; date: string; themeMode: ThemeMode; background: string; // 颜色值或图片路径 }; export async function getFavorites(): Promise { return await getJson(KEY_FAVORITES_ITEMS, []); } export async function addFavorite(item: FavoriteItem): Promise { const list = await getFavorites(); // 允许重复点赞,不再根据 id 去重 const newList = [item, ...list]; console.log('Adding to favorites, new list size:', newList.length); await setJson(KEY_FAVORITES_ITEMS, newList); } export async function removeFavorite(favId: string): Promise { const list = await getFavorites(); const next = list.filter(item => item.favId !== favId); await setJson(KEY_FAVORITES_ITEMS, next); } export async function getConsentAccepted(): Promise { const raw = await AsyncStorage.getItem(KEY_CONSENT_ACCEPTED); return raw === 'true'; } export async function setConsentAccepted(accepted: boolean): Promise { await AsyncStorage.setItem(KEY_CONSENT_ACCEPTED, accepted ? 'true' : 'false'); } export async function getThemeMode(): Promise { const raw = await AsyncStorage.getItem(KEY_UI_THEME_MODE); if (raw === 'scenery' || raw === 'color') return raw; return 'scenery'; } export async function setThemeMode(mode: ThemeMode): Promise { await AsyncStorage.setItem(KEY_UI_THEME_MODE, mode); } export async function getUserProfile(): Promise { return await getJson(KEY_USER_PROFILE, {}); } export async function setUserProfile(profile: UserProfile): Promise { const current = await getUserProfile(); await setJson(KEY_USER_PROFILE, { ...current, ...profile }); } export async function getUserProfileScoring(): Promise { const raw = await AsyncStorage.getItem(KEY_USER_PROFILE_SCORING); if (!raw) return null; try { return JSON.parse(raw) as UserProfileScoring; } catch { return null; } } export async function setUserProfileScoring(profile: UserProfileScoring): Promise { await setJson(KEY_USER_PROFILE_SCORING, profile); } export async function getRecoFeedCache(): Promise { const raw = await AsyncStorage.getItem(KEY_RECO_FEED_CACHE); if (!raw) return null; try { return JSON.parse(raw) as RecoFeedCache; } catch { return null; } } export async function setRecoFeedCache(cache: RecoFeedCache): Promise { await setJson(KEY_RECO_FEED_CACHE, cache); } export async function getRecoFeedHistory(): Promise { const raw = await AsyncStorage.getItem(KEY_RECO_FEED_HISTORY); if (!raw) { return { updated_at: new Date().toISOString(), already_recommended_ids: [], touched_or_viewed_ids: [], }; } try { const parsed = JSON.parse(raw) as Partial; return { updated_at: typeof parsed.updated_at === 'string' ? parsed.updated_at : new Date().toISOString(), already_recommended_ids: Array.isArray(parsed.already_recommended_ids) ? parsed.already_recommended_ids.filter((x) => Number.isFinite(x)).map((x) => Number(x)) : [], touched_or_viewed_ids: Array.isArray(parsed.touched_or_viewed_ids) ? parsed.touched_or_viewed_ids.filter((x) => Number.isFinite(x)).map((x) => Number(x)) : [], }; } catch { return { updated_at: new Date().toISOString(), already_recommended_ids: [], touched_or_viewed_ids: [], }; } } export async function setRecoFeedHistory(history: RecoFeedHistory): Promise { await setJson(KEY_RECO_FEED_HISTORY, history); } function uniqKeepLatest(list: number[], max: number): number[] { const seen = new Set(); const out: number[] = []; for (let i = list.length - 1; i >= 0; i -= 1) { const v = list[i]; if (!Number.isFinite(v)) continue; if (seen.has(v)) continue; seen.add(v); out.push(v); if (out.length >= max) break; } return out.reverse(); } export async function recordRecoFeedServed(contentIds: number[]): Promise { if (!contentIds?.length) return; const h = await getRecoFeedHistory(); const next = { ...h, updated_at: new Date().toISOString(), already_recommended_ids: uniqKeepLatest([...h.already_recommended_ids, ...contentIds], 500), }; await setRecoFeedHistory(next); } export async function recordRecoFeedTouched(contentId: number): Promise { if (!Number.isFinite(contentId)) return; const h = await getRecoFeedHistory(); const next = { ...h, updated_at: new Date().toISOString(), touched_or_viewed_ids: uniqKeepLatest([...h.touched_or_viewed_ids, contentId], 500), }; await setRecoFeedHistory(next); } export async function getDailyReminderSettings(): Promise { const s = await getJson(KEY_DAILY_REMINDER_SETTINGS, { timesPerDay: 3, pushEnabled: false, }); const timesPerDay = Number.isFinite(s.timesPerDay) ? s.timesPerDay : 3; return { timesPerDay: Math.min(10, Math.max(1, Math.round(timesPerDay))), pushEnabled: Boolean(s.pushEnabled), }; } export async function setDailyReminderSettings(settings: DailyReminderSettings): Promise { const timesPerDay = Math.min(10, Math.max(1, Math.round(settings.timesPerDay))); await setJson(KEY_DAILY_REMINDER_SETTINGS, { timesPerDay, pushEnabled: Boolean(settings.pushEnabled), }); }