chore: vendor client into main repo
This commit is contained in:
33
client/src/constants/env.ts
Normal file
33
client/src/constants/env.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 客户端环境变量统一入口(Expo 推荐使用 EXPO_PUBLIC_ 前缀)
|
||||
*
|
||||
* 注意:
|
||||
* - 这里读取的是构建时/运行时注入的环境变量(EXPO_PUBLIC_*)
|
||||
* - 真机联调时不要用 localhost,改为电脑局域网 IP,例如:http://192.168.1.10:8000
|
||||
*/
|
||||
|
||||
export type AppEnv = 'dev' | 'prod';
|
||||
|
||||
function getRequiredEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`缺少环境变量:${name}(请检查 .env 配置)`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function getOptionalEnv(name: string, fallback: string): string {
|
||||
return process.env[name] ?? fallback;
|
||||
}
|
||||
|
||||
export const APP_ENV = (getOptionalEnv('EXPO_PUBLIC_ENV', 'dev') as AppEnv) ?? 'dev';
|
||||
|
||||
export const API_BASE_URL = getRequiredEnv('EXPO_PUBLIC_API_BASE_URL');
|
||||
|
||||
/**
|
||||
* 默认语言策略:
|
||||
* - auto:优先设备语言(支持列表内时),否则回退 zh-CN
|
||||
* - zh-CN/en/es/pt/zh-TW:固定默认语言(仍允许用户在设置中手动切换并持久化)
|
||||
*/
|
||||
export const DEFAULT_LANGUAGE = getOptionalEnv('EXPO_PUBLIC_DEFAULT_LANGUAGE', 'auto');
|
||||
|
||||
16
client/src/constants/mockContent.ts
Normal file
16
client/src/constants/mockContent.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type MockContentItem = {
|
||||
id: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 本地 mock 内容(后续接后端时可替换)
|
||||
*/
|
||||
export const MOCK_CONTENT: MockContentItem[] = [
|
||||
{ id: 'c1', text: '你已经很努力了,今天也值得被温柔对待。' },
|
||||
{ id: 'c2', text: '深呼吸三次,把注意力带回当下。' },
|
||||
{ id: 'c3', text: '允许自己慢一点,情绪会像云一样飘过。' },
|
||||
{ id: 'c4', text: '你不需要完美,你已经足够好。' },
|
||||
{ id: 'c5', text: '把手放在心口,对自己说一句:辛苦了。' }
|
||||
];
|
||||
|
||||
123
client/src/i18n/index.ts
Normal file
123
client/src/i18n/index.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import * as Localization from 'expo-localization';
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
import en from './locales/en.json';
|
||||
import es from './locales/es.json';
|
||||
import pt from './locales/pt.json';
|
||||
import zhCN from './locales/zh-CN.json';
|
||||
import zhTW from './locales/zh-TW.json';
|
||||
|
||||
/**
|
||||
* 语言码约定:
|
||||
* - 简体中文:zh-CN
|
||||
* - 繁体中文:zh-TW
|
||||
* - 英语:en
|
||||
* - 西班牙语:es
|
||||
* - 葡萄牙语:pt
|
||||
*/
|
||||
export type AppLanguage = 'zh-CN' | 'zh-TW' | 'en' | 'es' | 'pt';
|
||||
|
||||
export const SUPPORTED_LANGUAGES: readonly AppLanguage[] = [
|
||||
'zh-CN',
|
||||
'zh-TW',
|
||||
'en',
|
||||
'es',
|
||||
'pt',
|
||||
] as const;
|
||||
|
||||
const DEFAULT_FALLBACK_LANGUAGE: AppLanguage = 'zh-CN';
|
||||
const STORAGE_KEY_LANGUAGE = 'settings.language';
|
||||
|
||||
function isSupportedLanguage(lang: string): lang is AppLanguage {
|
||||
return (SUPPORTED_LANGUAGES as readonly string[]).includes(lang);
|
||||
}
|
||||
|
||||
function normalizeDeviceLanguageTagToAppLanguage(languageTag: string): AppLanguage {
|
||||
const tag = languageTag.toLowerCase();
|
||||
|
||||
// 中文:优先区分繁简
|
||||
if (tag.startsWith('zh')) {
|
||||
// 常见繁体标记:zh-TW / zh-HK / zh-Hant
|
||||
if (tag.includes('tw') || tag.includes('hk') || tag.includes('hant')) {
|
||||
return 'zh-TW';
|
||||
}
|
||||
return 'zh-CN';
|
||||
}
|
||||
|
||||
// 其他语言:按前缀匹配
|
||||
if (tag.startsWith('en')) return 'en';
|
||||
if (tag.startsWith('es')) return 'es';
|
||||
if (tag.startsWith('pt')) return 'pt';
|
||||
|
||||
return DEFAULT_FALLBACK_LANGUAGE;
|
||||
}
|
||||
|
||||
function getDeviceLanguage(): AppLanguage {
|
||||
// expo-localization 返回系统 locale 列表,取第一个作为当前设备偏好
|
||||
const locales = Localization.getLocales();
|
||||
const first = locales?.[0]?.languageTag;
|
||||
|
||||
if (first) {
|
||||
return normalizeDeviceLanguageTagToAppLanguage(first);
|
||||
}
|
||||
|
||||
// 兜底:少数情况下 locales 为空
|
||||
return DEFAULT_FALLBACK_LANGUAGE;
|
||||
}
|
||||
|
||||
export async function getLanguagePreference(): Promise<AppLanguage | null> {
|
||||
const value = await AsyncStorage.getItem(STORAGE_KEY_LANGUAGE);
|
||||
if (!value) return null;
|
||||
return isSupportedLanguage(value) ? value : null;
|
||||
}
|
||||
|
||||
export async function setLanguagePreference(lang: AppLanguage): Promise<void> {
|
||||
await AsyncStorage.setItem(STORAGE_KEY_LANGUAGE, lang);
|
||||
}
|
||||
|
||||
export async function clearLanguagePreference(): Promise<void> {
|
||||
await AsyncStorage.removeItem(STORAGE_KEY_LANGUAGE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 i18n(只会初始化一次)
|
||||
*
|
||||
* 语言选择优先级:
|
||||
* 1) 用户设置(若存在)
|
||||
* 2) 设备语言(在支持列表内时生效;否则会被 normalize 到默认回退)
|
||||
* 3) 默认回退(zh-CN)
|
||||
*/
|
||||
export async function initI18n(): Promise<void> {
|
||||
if (i18n.isInitialized) return;
|
||||
|
||||
const userLang = await getLanguagePreference();
|
||||
const deviceLang = getDeviceLanguage();
|
||||
const initialLang = userLang ?? deviceLang;
|
||||
|
||||
await i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
'zh-CN': { translation: zhCN },
|
||||
'zh-TW': { translation: zhTW },
|
||||
en: { translation: en },
|
||||
es: { translation: es },
|
||||
pt: { translation: pt },
|
||||
},
|
||||
lng: initialLang,
|
||||
fallbackLng: DEFAULT_FALLBACK_LANGUAGE,
|
||||
interpolation: {
|
||||
// React Native 不需要对内容做 HTML 转义
|
||||
escapeValue: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户手动切换语言:立即生效 + 持久化
|
||||
*/
|
||||
export async function changeLanguage(lang: AppLanguage): Promise<void> {
|
||||
await setLanguagePreference(lang);
|
||||
await i18n.changeLanguage(lang);
|
||||
}
|
||||
|
||||
50
client/src/i18n/locales/en.json
Normal file
50
client/src/i18n/locales/en.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"common": {
|
||||
"ok": "OK",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"onboarding": {
|
||||
"title": "Welcome",
|
||||
"progress": "{{current}}/{{total}}",
|
||||
"next": "Next",
|
||||
"skip": "Skip",
|
||||
"skipAll": "Skip onboarding",
|
||||
"q1Title": "How are you feeling lately?",
|
||||
"q1Desc": "No right or wrong. You can skip and adjust later.",
|
||||
"q2Title": "What kind of support do you want?",
|
||||
"q2Desc": "For example: gentle reminders, mindfulness, emotional support.",
|
||||
"q3Title": "When do you need comfort the most?",
|
||||
"q3Desc": "Morning, afternoon, late night, or specific moments.",
|
||||
"q4Title": "A gentle sentence for yourself",
|
||||
"q4Desc": "You can skip. We’ll stay with you along the way."
|
||||
},
|
||||
"push": {
|
||||
"title": "Notifications",
|
||||
"cardTitle": "Turn on gentle reminders",
|
||||
"cardDesc": "We’ll send a short mindful phrase when you may need it. You can change this anytime in Settings.",
|
||||
"enable": "Enable",
|
||||
"later": "Later",
|
||||
"loading": "Working…",
|
||||
"errorTitle": "Notice",
|
||||
"errorDesc": "It’s okay if enabling fails. You can keep using the app."
|
||||
},
|
||||
"home": {
|
||||
"title": "Mindfulness",
|
||||
"like": "Like",
|
||||
"dislike": "Dislike",
|
||||
"favorites": "Favorites",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "Favorites",
|
||||
"empty": "No favorites yet."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"language": "Language",
|
||||
"version": "Version",
|
||||
"widgetTitle": "iOS Widget",
|
||||
"widgetDesc": "Put gentle reminders on your home screen: long-press → tap “+” → search “Mindfulness” → add a size you like."
|
||||
}
|
||||
}
|
||||
|
||||
50
client/src/i18n/locales/es.json
Normal file
50
client/src/i18n/locales/es.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"common": {
|
||||
"ok": "Aceptar",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"onboarding": {
|
||||
"title": "Bienvenida",
|
||||
"progress": "{{current}}/{{total}}",
|
||||
"next": "Siguiente",
|
||||
"skip": "Saltar",
|
||||
"skipAll": "Saltar introducción",
|
||||
"q1Title": "¿Cómo te sientes últimamente?",
|
||||
"q1Desc": "No hay respuestas correctas. Puedes saltar y ajustar después.",
|
||||
"q2Title": "¿Qué tipo de apoyo quieres?",
|
||||
"q2Desc": "Por ejemplo: recordatorios suaves, mindfulness, apoyo emocional.",
|
||||
"q3Title": "¿Cuándo necesitas más consuelo?",
|
||||
"q3Desc": "Mañana, tarde, noche o momentos específicos.",
|
||||
"q4Title": "Una frase amable para ti",
|
||||
"q4Desc": "Puedes saltar. Te acompañaremos en el camino."
|
||||
},
|
||||
"push": {
|
||||
"title": "Notificaciones",
|
||||
"cardTitle": "Activar recordatorios suaves",
|
||||
"cardDesc": "Te enviaremos una frase breve cuando lo necesites. Puedes cambiarlo en Ajustes.",
|
||||
"enable": "Activar",
|
||||
"later": "Más tarde",
|
||||
"loading": "Procesando…",
|
||||
"errorTitle": "Aviso",
|
||||
"errorDesc": "No pasa nada si falla. Puedes seguir usando la app."
|
||||
},
|
||||
"home": {
|
||||
"title": "Mindfulness",
|
||||
"like": "Me gusta",
|
||||
"dislike": "No me gusta",
|
||||
"favorites": "Favoritos",
|
||||
"settings": "Ajustes"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "Favoritos",
|
||||
"empty": "Aún no hay favoritos."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ajustes",
|
||||
"language": "Idioma",
|
||||
"version": "Versión",
|
||||
"widgetTitle": "Widget de iOS",
|
||||
"widgetDesc": "Pon recordatorios en tu pantalla: mantén pulsado → “+” → busca “Mindfulness” → añade el tamaño."
|
||||
}
|
||||
}
|
||||
|
||||
50
client/src/i18n/locales/pt.json
Normal file
50
client/src/i18n/locales/pt.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"common": {
|
||||
"ok": "OK",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"onboarding": {
|
||||
"title": "Bem-vinda",
|
||||
"progress": "{{current}}/{{total}}",
|
||||
"next": "Próximo",
|
||||
"skip": "Pular",
|
||||
"skipAll": "Pular introdução",
|
||||
"q1Title": "Como você tem se sentido ultimamente?",
|
||||
"q1Desc": "Não há certo ou errado. Você pode pular e ajustar depois.",
|
||||
"q2Title": "Que tipo de apoio você quer?",
|
||||
"q2Desc": "Por exemplo: lembretes gentis, mindfulness, apoio emocional.",
|
||||
"q3Title": "Quando você mais precisa de conforto?",
|
||||
"q3Desc": "Manhã, tarde, noite ou momentos específicos.",
|
||||
"q4Title": "Uma frase gentil para você",
|
||||
"q4Desc": "Você pode pular. Vamos seguir com você no caminho."
|
||||
},
|
||||
"push": {
|
||||
"title": "Notificações",
|
||||
"cardTitle": "Ativar lembretes gentis",
|
||||
"cardDesc": "Enviaremos uma frase curta quando você precisar. Você pode mudar isso em Configurações.",
|
||||
"enable": "Ativar",
|
||||
"later": "Depois",
|
||||
"loading": "Processando…",
|
||||
"errorTitle": "Aviso",
|
||||
"errorDesc": "Tudo bem se falhar. Você pode continuar usando o app."
|
||||
},
|
||||
"home": {
|
||||
"title": "Mindfulness",
|
||||
"like": "Curtir",
|
||||
"dislike": "Não curtir",
|
||||
"favorites": "Favoritos",
|
||||
"settings": "Configurações"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "Favoritos",
|
||||
"empty": "Ainda não há favoritos."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Configurações",
|
||||
"language": "Idioma",
|
||||
"version": "Versão",
|
||||
"widgetTitle": "Widget do iOS",
|
||||
"widgetDesc": "Coloque lembretes na tela inicial: pressione e segure → “+” → procure “Mindfulness” → adicione o tamanho."
|
||||
}
|
||||
}
|
||||
|
||||
50
client/src/i18n/locales/zh-CN.json
Normal file
50
client/src/i18n/locales/zh-CN.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"common": {
|
||||
"ok": "确定",
|
||||
"cancel": "取消"
|
||||
},
|
||||
"onboarding": {
|
||||
"title": "欢迎",
|
||||
"progress": "{{current}}/{{total}}",
|
||||
"next": "下一步",
|
||||
"skip": "跳过",
|
||||
"skipAll": "跳过整个引导",
|
||||
"q1Title": "你最近的感受更接近哪一种?",
|
||||
"q1Desc": "没有对错,你可以跳过,之后也可以慢慢调整。",
|
||||
"q2Title": "你更希望获得哪种支持?",
|
||||
"q2Desc": "例如:温柔提醒、正念练习、情绪陪伴。",
|
||||
"q3Title": "你通常在什么时候最需要被安慰?",
|
||||
"q3Desc": "比如:清晨、午后、深夜,或某些特定时刻。",
|
||||
"q4Title": "给自己一句温柔的话",
|
||||
"q4Desc": "你可以直接跳过,我们会在之后继续陪你。"
|
||||
},
|
||||
"push": {
|
||||
"title": "通知",
|
||||
"cardTitle": "开启温柔提醒",
|
||||
"cardDesc": "我们会在你需要的时候,送上一句正念短句或温柔提醒(可随时在设置中调整)。",
|
||||
"enable": "立即开启",
|
||||
"later": "稍后",
|
||||
"loading": "处理中…",
|
||||
"errorTitle": "提示",
|
||||
"errorDesc": "开启失败也没关系,你仍然可以继续使用应用。"
|
||||
},
|
||||
"home": {
|
||||
"title": "正念",
|
||||
"like": "点赞",
|
||||
"dislike": "讨厌",
|
||||
"favorites": "收藏",
|
||||
"settings": "设置"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "收藏夹",
|
||||
"empty": "这里还没有收藏内容。"
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
"language": "语言",
|
||||
"version": "版本",
|
||||
"widgetTitle": "iOS 小组件",
|
||||
"widgetDesc": "把温柔提醒放到桌面上:长按主屏幕 → 点“+” → 搜索“正念” → 添加你喜欢的尺寸。"
|
||||
}
|
||||
}
|
||||
|
||||
50
client/src/i18n/locales/zh-TW.json
Normal file
50
client/src/i18n/locales/zh-TW.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"common": {
|
||||
"ok": "確定",
|
||||
"cancel": "取消"
|
||||
},
|
||||
"onboarding": {
|
||||
"title": "歡迎",
|
||||
"progress": "{{current}}/{{total}}",
|
||||
"next": "下一步",
|
||||
"skip": "跳過",
|
||||
"skipAll": "跳過整個引導",
|
||||
"q1Title": "你最近的感受更接近哪一種?",
|
||||
"q1Desc": "沒有對錯,你可以跳過,之後也能慢慢調整。",
|
||||
"q2Title": "你更希望獲得哪種支持?",
|
||||
"q2Desc": "例如:溫柔提醒、正念練習、情緒陪伴。",
|
||||
"q3Title": "你通常在什麼時候最需要被安慰?",
|
||||
"q3Desc": "例如:清晨、午后、深夜,或某些特定時刻。",
|
||||
"q4Title": "給自己一句溫柔的話",
|
||||
"q4Desc": "你可以直接跳過,我們會在之後繼續陪你。"
|
||||
},
|
||||
"push": {
|
||||
"title": "通知",
|
||||
"cardTitle": "開啟溫柔提醒",
|
||||
"cardDesc": "我們會在你需要的時候,送上一句正念短句或溫柔提醒(可隨時在設定中調整)。",
|
||||
"enable": "立即開啟",
|
||||
"later": "稍後",
|
||||
"loading": "處理中…",
|
||||
"errorTitle": "提示",
|
||||
"errorDesc": "開啟失敗也沒關係,你仍然可以繼續使用應用。"
|
||||
},
|
||||
"home": {
|
||||
"title": "正念",
|
||||
"like": "喜歡",
|
||||
"dislike": "不喜歡",
|
||||
"favorites": "收藏",
|
||||
"settings": "設定"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "收藏夾",
|
||||
"empty": "這裡還沒有收藏內容。"
|
||||
},
|
||||
"settings": {
|
||||
"title": "設定",
|
||||
"language": "語言",
|
||||
"version": "版本",
|
||||
"widgetTitle": "iOS 小工具",
|
||||
"widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「正念」 → 添加你喜歡的尺寸。"
|
||||
}
|
||||
}
|
||||
|
||||
67
client/src/storage/appStorage.ts
Normal file
67
client/src/storage/appStorage.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
/**
|
||||
* 本地存储 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';
|
||||
|
||||
export type PushPromptState = 'enabled' | 'skipped' | 'unknown';
|
||||
export type Reaction = 'like' | 'dislike';
|
||||
export type ReactionsMap = Record<string, Reaction>;
|
||||
|
||||
async function getJson<T>(key: string, fallback: T): Promise<T> {
|
||||
const raw = await AsyncStorage.getItem(key);
|
||||
if (!raw) return fallback;
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async function setJson<T>(key: string, value: T): Promise<void> {
|
||||
await AsyncStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
export async function getOnboardingCompleted(): Promise<boolean> {
|
||||
const raw = await AsyncStorage.getItem(KEY_ONBOARDING_COMPLETED);
|
||||
return raw === 'true';
|
||||
}
|
||||
|
||||
export async function setOnboardingCompleted(completed: boolean): Promise<void> {
|
||||
await AsyncStorage.setItem(KEY_ONBOARDING_COMPLETED, completed ? 'true' : 'false');
|
||||
}
|
||||
|
||||
export async function getPushPromptState(): Promise<PushPromptState> {
|
||||
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<void> {
|
||||
await AsyncStorage.setItem(KEY_PUSH_PROMPT_STATE, state);
|
||||
}
|
||||
|
||||
export async function getReactions(): Promise<ReactionsMap> {
|
||||
return await getJson<ReactionsMap>(KEY_CONTENT_REACTIONS, {});
|
||||
}
|
||||
|
||||
export async function setReaction(contentId: string, reaction: Reaction): Promise<void> {
|
||||
const next = await getReactions();
|
||||
next[contentId] = reaction;
|
||||
await setJson(KEY_CONTENT_REACTIONS, next);
|
||||
}
|
||||
|
||||
export async function getFavorites(): Promise<string[]> {
|
||||
return await getJson<string[]>(KEY_FAVORITES_ITEMS, []);
|
||||
}
|
||||
|
||||
export async function addFavorite(contentId: string): Promise<void> {
|
||||
const list = await getFavorites();
|
||||
if (list.includes(contentId)) return;
|
||||
await setJson(KEY_FAVORITES_ITEMS, [...list, contentId]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user