From 7ad52fce89bb02bf36e87f299659cc32dcdaa858 Mon Sep 17 00:00:00 2001 From: Kevin Date: Wed, 6 May 2026 13:51:43 +0800 Subject: [PATCH] feat(profile): avatar presets, upload, nickname editing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FastAPI: preset assets 01–08, GET list/static, PUT /me/avatar/preset, safer uploaded-avatar path validation, preset_avatars + HTTP tests. - Expo: personal-info (library + presets), profile tab avatar, resolveApiMediaUrl, auth hooks cache sync, Web multipart helper, partial-save messaging + profile i18n. - Includes existing edits to conversation screen and voice use-player. Co-authored-by: Cursor --- api/app/features/auth/avatar_presets/01.png | Bin 0 -> 135 bytes api/app/features/auth/avatar_presets/02.png | Bin 0 -> 136 bytes api/app/features/auth/avatar_presets/03.png | Bin 0 -> 136 bytes api/app/features/auth/avatar_presets/04.png | Bin 0 -> 136 bytes api/app/features/auth/avatar_presets/05.png | Bin 0 -> 136 bytes api/app/features/auth/avatar_presets/06.png | Bin 0 -> 136 bytes api/app/features/auth/avatar_presets/07.png | Bin 0 -> 136 bytes api/app/features/auth/avatar_presets/08.png | Bin 0 -> 135 bytes api/app/features/auth/preset_avatars.py | 68 +++ api/app/features/auth/router.py | 71 ++- api/app/features/auth/schemas.py | 15 + api/tests/test_avatar_preset_http.py | 170 +++++++ app-expo/src/app/(main)/conversation/[id].tsx | 115 +++-- app-expo/src/app/(main)/personal-info.tsx | 307 +++++++++++- app-expo/src/app/(tabs)/profile.tsx | 17 +- app-expo/src/core/api/media-url.ts | 14 + app-expo/src/features/auth/api.ts | 12 + .../features/auth/avatar-upload-form-data.ts | 80 ++++ app-expo/src/features/auth/hooks.ts | 50 ++ app-expo/src/features/auth/types.ts | 9 + .../src/features/voice/hooks/use-player.ts | 38 +- app-expo/src/i18n/generated/resources.ts | 443 ++++++++++-------- .../src/i18n/locales/en/conversation.json | 2 + app-expo/src/i18n/locales/en/profile.json | 23 + .../src/i18n/locales/zh/conversation.json | 2 + app-expo/src/i18n/locales/zh/profile.json | 23 + .../tests/features/voice/use-player.test.tsx | 82 +++- 27 files changed, 1271 insertions(+), 270 deletions(-) create mode 100644 api/app/features/auth/avatar_presets/01.png create mode 100644 api/app/features/auth/avatar_presets/02.png create mode 100644 api/app/features/auth/avatar_presets/03.png create mode 100644 api/app/features/auth/avatar_presets/04.png create mode 100644 api/app/features/auth/avatar_presets/05.png create mode 100644 api/app/features/auth/avatar_presets/06.png create mode 100644 api/app/features/auth/avatar_presets/07.png create mode 100644 api/app/features/auth/avatar_presets/08.png create mode 100644 api/app/features/auth/preset_avatars.py create mode 100644 api/tests/test_avatar_preset_http.py create mode 100644 app-expo/src/core/api/media-url.ts create mode 100644 app-expo/src/features/auth/avatar-upload-form-data.ts diff --git a/api/app/features/auth/avatar_presets/01.png b/api/app/features/auth/avatar_presets/01.png new file mode 100644 index 0000000000000000000000000000000000000000..08742d168df8156dbd0558a64516bd03116b0bd9 GIT binary patch literal 135 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLGH$KTj9OkcwMx&pUE5FmSMJSe-3y ooy~IpPy*|;msPqa|8A1-5g+X^(A!PC{xWt~$(695sH8kztA literal 0 HcmV?d00001 diff --git a/api/app/features/auth/avatar_presets/03.png b/api/app/features/auth/avatar_presets/03.png new file mode 100644 index 0000000000000000000000000000000000000000..192122f967149c8545dc53085cb6f1b25327f871 GIT binary patch literal 136 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLGH$e@_?3kcwMx&pUE5FmSMJIGrc- pSmHFtzSe_WI_{b7;FFl literal 0 HcmV?d00001 diff --git a/api/app/features/auth/avatar_presets/05.png b/api/app/features/auth/avatar_presets/05.png new file mode 100644 index 0000000000000000000000000000000000000000..5920a731e45e47595208b4022a7f484d55b4b774 GIT binary patch literal 136 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLGH$e@_?3kcwMx&pUE5FmSMJIGq{s pc*bFleXR$%bS{65Vj&IaaWm;Xyu@-dY#Y#M22WQ%mvv4FO#nn*8f^do literal 0 HcmV?d00001 diff --git a/api/app/features/auth/avatar_presets/06.png b/api/app/features/auth/avatar_presets/06.png new file mode 100644 index 0000000000000000000000000000000000000000..72e48ad724e950cef274f45f1811f0850da46ba9 GIT binary patch literal 136 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLGH$e@_?3kcwMx&pUE5FmSMJ=*|^t oo_T^}U+Y0Go#4MwETjQF?r0;%!(IokgN$bIboFyt=akR{0NNlK2><{9 literal 0 HcmV?d00001 diff --git a/api/app/features/auth/avatar_presets/07.png b/api/app/features/auth/avatar_presets/07.png new file mode 100644 index 0000000000000000000000000000000000000000..26d2f84388662a6d0520ff0981dbc46210606c49 GIT binary patch literal 136 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLGH$e@_?3kcwMx&pUE5FmSMJ=+5PE pmbl8Xuk|38j_ubd7Sezockd?0_LCodHUo`j@O1TaS?83{1OTZk88H9= literal 0 HcmV?d00001 diff --git a/api/app/features/auth/avatar_presets/08.png b/api/app/features/auth/avatar_presets/08.png new file mode 100644 index 0000000000000000000000000000000000000000..23e5f4a77190040b7e477518ef7a45f0757b50e3 GIT binary patch literal 135 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLGH$KTj9OkcwMx&l~bGFmNz$_$j_s pI@05TT4oTlul0IY2a>>pO&8UiWXtSVeFB=y;OXk;vd$@?2>^TFBkBME literal 0 HcmV?d00001 diff --git a/api/app/features/auth/preset_avatars.py b/api/app/features/auth/preset_avatars.py new file mode 100644 index 0000000..e4a5c14 --- /dev/null +++ b/api/app/features/auth/preset_avatars.py @@ -0,0 +1,68 @@ +"""服务端托管的预设头像(白名单文件名)。""" + +from __future__ import annotations + +from pathlib import Path + +_PRESETS_DIR = Path(__file__).resolve().parent / "avatar_presets" + +# 与仓库内静态文件一致:01.png … 08.png +ALLOWED_PRESET_FILENAMES: frozenset[str] = frozenset(f"{i:02d}.png" for i in range(1, 9)) + +PRESET_IDS: tuple[str, ...] = tuple(f"{i:02d}" for i in range(1, 9)) + + +def preset_filename_for_id(preset_id: str) -> str | None: + """preset_id 形如 \"01\",返回 \"01.png\";非法则 None。""" + stripped = preset_id.strip() + name = f"{stripped}.png" + if name in ALLOWED_PRESET_FILENAMES: + return name + return None + + +def avatar_url_for_preset_filename(filename: str) -> str: + return f"/api/auth/avatar-presets/{filename}" + + +def list_preset_items() -> list[tuple[str, str]]: + """(preset_id, avatar_url) 列表,供 GET /avatar-presets。""" + return [ + (pid, avatar_url_for_preset_filename(f"{pid}.png")) for pid in PRESET_IDS + ] + + +def preset_file_path(filename: str) -> Path | None: + if filename not in ALLOWED_PRESET_FILENAMES: + return None + path = (_PRESETS_DIR / filename).resolve() + try: + path.relative_to(_PRESETS_DIR.resolve()) + except ValueError: + return None + return path + + +def _avatar_upload_stem_allowed(stem: str) -> bool: + """允许 UUID、数字 ID、含连字符/下划线的安全文件名主干。""" + if not stem or len(stem) > 128: + return False + return all(c.isalnum() or c in "-_" for c in stem) + + +def safe_avatar_upload_path(filename: str, avatar_dir: Path) -> Path | None: + """用户上传头像文件名形如 {user_id}.jpg,防路径穿越。""" + if "/" in filename or "\\" in filename or ".." in filename: + return None + if not filename.endswith(".jpg"): + return None + stem = filename[:-4] + if not _avatar_upload_stem_allowed(stem): + return None + base = avatar_dir.resolve() + path = (avatar_dir / filename).resolve() + try: + path.relative_to(base) + except ValueError: + return None + return path diff --git a/api/app/features/auth/router.py b/api/app/features/auth/router.py index 68e230b..c6cd9fd 100644 --- a/api/app/features/auth/router.py +++ b/api/app/features/auth/router.py @@ -9,7 +9,15 @@ from app.core.config import settings from app.core.dependencies import get_current_user from app.core.logging import get_logger from app.features.auth.deps import get_auth_service +from app.features.auth.preset_avatars import ( + avatar_url_for_preset_filename, + list_preset_items, + preset_filename_for_id, + preset_file_path, + safe_avatar_upload_path, +) from app.features.auth.schemas import ( + AvatarPresetItem, ChangePasswordRequest, ChangePhoneRequest, LoginRequest, @@ -18,6 +26,7 @@ from app.features.auth.schemas import ( RegisterRequest, ResetPasswordRequest, SendSmsRequest, + SetAvatarPresetRequest, SmsLoginRequest, SmsRegisterRequest, TokenResponse, @@ -329,14 +338,72 @@ async def upload_avatar( ) from e +@router.get( + "/avatar-presets", + response_model=list[AvatarPresetItem], + summary="预设头像列表", +) +async def list_avatar_presets(): + return [ + AvatarPresetItem(id=item_id, url=item_url) + for item_id, item_url in list_preset_items() + ] + + +@router.put( + "/me/avatar/preset", + response_model=UserResponse, + summary="使用预设头像", + responses={400: {"description": "无效的预设编号"}}, +) +async def set_avatar_preset( + request: SetAvatarPresetRequest, + current_user: User = Depends(get_current_user), + service: AuthService = Depends(get_auth_service), +): + filename = preset_filename_for_id(request.preset_id) + if filename is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="无效的预设头像编号", + ) + path = preset_file_path(filename) + if path is None or not path.exists(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="预设头像不可用", + ) + avatar_url = avatar_url_for_preset_filename(filename) + try: + user = await service.update_avatar_url(current_user.id, avatar_url) + except AuthError as e: + raise _map_auth_error(e) + return _user_response(user) + + +@router.get( + "/avatar-presets/{filename}", + summary="获取预设头像图片", + responses={404: {"description": "预设不存在"}}, +) +async def get_avatar_preset(filename: str): + path = preset_file_path(filename) + if path is None or not path.exists(): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="预设头像不存在", + ) + return FileResponse(path, media_type="image/png") + + @router.get( "/avatars/{filename}", summary="获取头像图片", responses={404: {"description": "头像不存在"}}, ) async def get_avatar(filename: str): - file_path = AVATAR_DIR / filename - if not file_path.exists(): + file_path = safe_avatar_upload_path(filename, AVATAR_DIR) + if file_path is None or not file_path.exists(): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="头像不存在", diff --git a/api/app/features/auth/schemas.py b/api/app/features/auth/schemas.py index b3c5dba..15bac34 100644 --- a/api/app/features/auth/schemas.py +++ b/api/app/features/auth/schemas.py @@ -104,3 +104,18 @@ class UpdateNicknameRequest(BaseModel): class AvatarUploadResponse(BaseModel): avatar_url: str + + +class SetAvatarPresetRequest(BaseModel): + preset_id: str = Field( + ..., + min_length=2, + max_length=2, + pattern=r"^\d{2}$", + description="预设编号,如 01–08", + ) + + +class AvatarPresetItem(BaseModel): + id: str + url: str diff --git a/api/tests/test_avatar_preset_http.py b/api/tests/test_avatar_preset_http.py new file mode 100644 index 0000000..e9d2f67 --- /dev/null +++ b/api/tests/test_avatar_preset_http.py @@ -0,0 +1,170 @@ +"""预设头像 HTTP 契约。""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient + +from app.core.dependencies import get_current_user +from app.features.auth.deps import get_auth_service +from app.features.auth.router import router as auth_router +from app.features.auth.service import AuthService +from app.features.user.models import User + + +def _mock_current_user() -> User: + u = MagicMock(spec=User) + u.id = str(uuid.uuid4()) + u.phone = "13800000000" + u.email = None + u.nickname = "测试用户" + u.avatar_url = None + u.subscription_type = "free" + u.created_at = datetime.now(timezone.utc) + return u + + +@pytest.fixture +def preset_auth_app() -> FastAPI: + app = FastAPI() + app.include_router(auth_router) + + fixed_user = _mock_current_user() + + async def _fake_update_avatar(uid: str, url: str): + fixed_user.avatar_url = url + return fixed_user + + mock_service = MagicMock(spec=AuthService) + mock_service.update_avatar_url = AsyncMock(side_effect=_fake_update_avatar) + + app.dependency_overrides[get_auth_service] = lambda: mock_service + app.dependency_overrides[get_current_user] = lambda: fixed_user + + app.state._mock_auth_service = mock_service + app.state._fixed_user = fixed_user + return app + + +@pytest.mark.asyncio +async def test_list_avatar_presets(preset_auth_app: FastAPI) -> None: + transport = ASGITransport(app=preset_auth_app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + r = await ac.get("/api/auth/avatar-presets") + assert r.status_code == 200 + items = r.json() + assert len(items) == 8 + assert items[0]["id"] == "01" + assert items[0]["url"] == "/api/auth/avatar-presets/01.png" + + +@pytest.mark.asyncio +async def test_get_avatar_preset_ok(preset_auth_app: FastAPI) -> None: + transport = ASGITransport(app=preset_auth_app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + r = await ac.get("/api/auth/avatar-presets/01.png") + assert r.status_code == 200 + assert r.headers.get("content-type", "").startswith("image/png") + + +@pytest.mark.asyncio +async def test_get_avatar_preset_unknown(preset_auth_app: FastAPI) -> None: + transport = ASGITransport(app=preset_auth_app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + r = await ac.get("/api/auth/avatar-presets/99.png") + assert r.status_code == 404 + + +@pytest.mark.asyncio +async def test_get_avatar_preset_path_traversal(preset_auth_app: FastAPI) -> None: + transport = ASGITransport(app=preset_auth_app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + r = await ac.get("/api/auth/avatar-presets/../secrets.env") + assert r.status_code == 404 + + +@pytest.mark.asyncio +async def test_set_avatar_preset_ok(preset_auth_app: FastAPI) -> None: + uid = preset_auth_app.state._fixed_user.id + transport = ASGITransport(app=preset_auth_app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + r = await ac.put( + "/api/auth/me/avatar/preset", + json={"preset_id": "02"}, + headers={"Authorization": "Bearer x"}, + ) + assert r.status_code == 200 + body = r.json() + assert body["avatar_url"] == "/api/auth/avatar-presets/02.png" + svc: MagicMock = preset_auth_app.state._mock_auth_service + svc.update_avatar_url.assert_awaited_once_with( + uid, "/api/auth/avatar-presets/02.png" + ) + + +@pytest.mark.asyncio +async def test_set_avatar_preset_invalid(preset_auth_app: FastAPI) -> None: + transport = ASGITransport(app=preset_auth_app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + r = await ac.put( + "/api/auth/me/avatar/preset", + json={"preset_id": "99"}, + headers={"Authorization": "Bearer x"}, + ) + assert r.status_code == 400 + + +@pytest.mark.asyncio +async def test_get_uploaded_avatar_rejects_traversal() -> None: + app = FastAPI() + app.include_router(auth_router) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + r = await ac.get("/api/auth/avatars/../../../etc/passwd") + assert r.status_code == 404 + + +@pytest.mark.asyncio +async def test_get_uploaded_avatar_ok_with_safe_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import app.features.auth.router as auth_router_mod + + avatar_dir = tmp_path / "avatars" + avatar_dir.mkdir() + (avatar_dir / "abc-def-123.jpg").write_bytes(b"x") + + monkeypatch.setattr(auth_router_mod, "AVATAR_DIR", avatar_dir) + + app = FastAPI() + app.include_router(auth_router) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + r = await ac.get("/api/auth/avatars/abc-def-123.jpg") + assert r.status_code == 200 + + +@pytest.mark.asyncio +async def test_get_uploaded_avatar_ok_when_stem_has_underscore( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import app.features.auth.router as auth_router_mod + + avatar_dir = tmp_path / "avatars" + avatar_dir.mkdir() + (avatar_dir / "user_abc_01.jpg").write_bytes(b"x") + + monkeypatch.setattr(auth_router_mod, "AVATAR_DIR", avatar_dir) + + app = FastAPI() + app.include_router(auth_router) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + r = await ac.get("/api/auth/avatars/user_abc_01.jpg") + assert r.status_code == 200 diff --git a/app-expo/src/app/(main)/conversation/[id].tsx b/app-expo/src/app/(main)/conversation/[id].tsx index 0faea35..2264efa 100644 --- a/app-expo/src/app/(main)/conversation/[id].tsx +++ b/app-expo/src/app/(main)/conversation/[id].tsx @@ -5,7 +5,6 @@ import { Pause, Play, PlusCircle, - Square, Type, Volume2, X, @@ -95,6 +94,16 @@ function isFirstAssistantTextPart(listKey: string, messageId: string): boolean { return listKey === messageId || listKey === `${messageId}_part_0`; } +/** PlaybackItem.messageRef.listKey 可与 `item.id` 或 `${id}_seg_/part_` 后缀对齐 */ +function playbackMessageRefMatchesMessage( + playbackListKey: string | undefined, + messageItemId: string, +): boolean { + if (!playbackListKey?.length) return false; + if (playbackListKey === messageItemId) return true; + return playbackListKey.startsWith(`${messageItemId}_`); +} + /** 展平消息列表:assistant 消息按 [SPLIT] 边界拆成多条,每条一个 listKey */ function flattenMessagesForList( messages: MessageItem[], @@ -139,8 +148,10 @@ function MessageBubble({ currentPlaybackUri, currentPlaybackItem, playbackIsPlaying, + playbackIsPaused, onPlayVoiceExclusive, - onPausePlayback, + onPauseAssistantTts, + onResumeAssistantTts, onInterruptAssistantTts, onReplayAssistantTts, bubbleTextStyle, @@ -155,8 +166,10 @@ function MessageBubble({ currentPlaybackUri: string | null; currentPlaybackItem: PlaybackItem | null; playbackIsPlaying: boolean; + playbackIsPaused: boolean; onPlayVoiceExclusive: (uri: string) => void; - onPausePlayback: () => void; + onPauseAssistantTts: () => void; + onResumeAssistantTts: () => void; onInterruptAssistantTts: () => void; onReplayAssistantTts: (messageId: string, urls: string[]) => void; bubbleTextStyle?: TextStyle; @@ -177,14 +190,40 @@ function MessageBubble({ const isAssistantTextFirstPart = !isUser && !isVoice && isFirstAssistantTextPart(listKey, item.id); - const isThisBubbleTtsTarget = + const playbackKind = currentPlaybackItem?.kind; + const playbackRefListKey = currentPlaybackItem?.messageRef?.listKey; + const matchesThisMessageForTts = !isUser && !isVoice && - playbackIsPlaying && - currentPlaybackItem?.kind !== 'voice' && - currentPlaybackItem?.messageRef?.listKey === item.id; + playbackKind !== 'voice' && + playbackMessageRefMatchesMessage(playbackRefListKey, item.id); - const isAssistantTtsHighlight = isThisBubbleTtsTarget; + const playbackEngaged = playbackIsPlaying || playbackIsPaused; + const isThisBubbleActiveTts = + matchesThisMessageForTts && playbackEngaged; + + const isThisBubbleTtsPlaying = + isThisBubbleActiveTts && playbackIsPlaying; + const isThisBubbleTtsPaused = isThisBubbleActiveTts && playbackIsPaused; + + const isAssistantTtsHighlight = isThisBubbleActiveTts; + + const isThisVoiceTrack = + !!item.audioUri && + currentPlaybackUri === item.audioUri && + currentPlaybackItem?.kind === 'voice'; + + const readAloudAccessibilityLabel = isThisBubbleTtsPlaying + ? t('readAloudPause') + : isThisBubbleTtsPaused + ? t('readAloudResume') + : t('readAloudAgain'); + + const ReadAloudIconComponent = isThisBubbleTtsPlaying + ? Pause + : isThisBubbleTtsPaused + ? Play + : Volume2; const assistantTextBubbleBody = ( {isAssistantTextFirstPart && - (ttsUrls.length > 0 || isThisBubbleTtsTarget) ? ( + (ttsUrls.length > 0 || isThisBubbleActiveTts) ? ( { - onReplayAssistantTts(item.id, ttsUrls); + if (isThisBubbleTtsPlaying) { + onPauseAssistantTts(); + } else if (isThisBubbleTtsPaused) { + onResumeAssistantTts(); + } else { + onReplayAssistantTts(item.id, ttsUrls); + } }} style={({ pressed }) => [ styles.readAloudButton, { width: readAloudButtonSize, height: readAloudButtonSize }, - !isThisBubbleTtsTarget && pressed ? { opacity: 0.85 } : null, + pressed ? { opacity: 0.85 } : null, ]} - accessibilityElementsHidden={isThisBubbleTtsTarget} - importantForAccessibility={ - isThisBubbleTtsTarget ? 'no-hide-descendants' : 'auto' - } accessibilityRole="button" - accessibilityLabel={t('readAloudAgain')} - accessibilityState={{ disabled: isThisBubbleTtsTarget }} + accessibilityLabel={readAloudAccessibilityLabel} > @@ -259,19 +298,19 @@ function MessageBubble({ durationSeconds={item.durationSeconds ?? 0} audioUri={item.audioUri} isUser={isUser} - isPlaying={ - !!item.audioUri && - playbackIsPlaying && - currentPlaybackUri === item.audioUri - } + isPlaying={playbackIsPlaying && isThisVoiceTrack} durationTextStyle={voiceDurationTextStyle} onPlayPress={() => { if (!item.audioUri) return; - if (playbackIsPlaying && currentPlaybackUri === item.audioUri) { - onPausePlayback(); - } else { - onPlayVoiceExclusive(item.audioUri); + if (playbackIsPlaying && isThisVoiceTrack) { + onPauseAssistantTts(); + return; } + if (playbackIsPaused && isThisVoiceTrack) { + onResumeAssistantTts(); + return; + } + onPlayVoiceExclusive(item.audioUri); }} /> @@ -286,7 +325,7 @@ function MessageBubble({ ) : ( {assistantTextBubbleBody} - {isThisBubbleTtsTarget ? ( + {isThisBubbleActiveTts ? ( [ @@ -1053,6 +1092,8 @@ export default function ConversationScreen() { status: playerStatus, currentSource, currentPlaybackItem, + pausePlayback, + resumePlayback, } = usePlayer(); const handleTtsPlaybackResume = useCallback(() => { @@ -1145,9 +1186,12 @@ export default function ConversationScreen() { [enqueueExclusive], ); - const handlePausePlayback = useCallback(() => { - void stop(); - }, [stop]); + pausePlayback(); + }, [pausePlayback]); + + const handleResumeAssistantPlayback = useCallback(() => { + void resumePlayback(); + }, [resumePlayback]); const handleReplayAssistantTts = useCallback( (messageId: string, urls: string[]) => { @@ -1468,8 +1512,10 @@ export default function ConversationScreen() { currentPlaybackUri={currentSource} currentPlaybackItem={currentPlaybackItem} playbackIsPlaying={playerStatus === 'playing'} + playbackIsPaused={playerStatus === 'paused'} + onPauseAssistantTts={handlePauseAssistantPlayback} + onResumeAssistantTts={handleResumeAssistantPlayback} onPlayVoiceExclusive={handlePlayVoiceExclusive} - onPausePlayback={handlePausePlayback} onInterruptAssistantTts={handleInterruptAssistantTts} onReplayAssistantTts={handleReplayAssistantTts} bubbleTextStyle={chatBubbleTextStyle} @@ -1498,7 +1544,8 @@ export default function ConversationScreen() { agentName={t('agentName')} streamingTtsActive={ !!streamingMessage && - playerStatus === 'playing' && + (playerStatus === 'playing' || + playerStatus === 'paused') && currentPlaybackItem?.kind === 'tts_auto' } onStreamingPress={handleInterruptAssistantTts} diff --git a/app-expo/src/app/(main)/personal-info.tsx b/app-expo/src/app/(main)/personal-info.tsx index 2aa2866..5dedfab 100644 --- a/app-expo/src/app/(main)/personal-info.tsx +++ b/app-expo/src/app/(main)/personal-info.tsx @@ -1,24 +1,65 @@ +import { Image } from 'expo-image'; +import * as ImagePicker from 'expo-image-picker'; import React, { useEffect, useState } from 'react'; -import { View } from 'react-native'; +import { useTranslation } from 'react-i18next'; +import { + ActivityIndicator, + Alert, + Dimensions, + Modal, + Pressable, + ScrollView, + View, +} from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Text } from '@/components/ui/text'; import { ScreenHeader } from '@/components/screen-header'; +import { resolveApiMediaUrl } from '@/core/api/media-url'; +import { ApiError } from '@/core/api/types'; +import { buildAvatarUploadFormData } from '@/features/auth/avatar-upload-form-data'; +import { + useAvatarPresets, + useSetAvatarPreset, + useUpdateNickname, + useUploadAvatar, +} from '@/features/auth/hooks'; import { useProfile, useUpdateProfile } from '@/features/profile/hooks'; -export default function PersonalInfoScreen() { - const { data: profile } = useProfile(); - const update = useUpdateProfile(); +const PRESET_GRID_H_PADDING = 16 * 2; +const TILE_GAP = 12; +const COLS = 4; +function computePresetTileSize(): number { + const w = Dimensions.get('window').width; + return (w - PRESET_GRID_H_PADDING - TILE_GAP * (COLS - 1)) / COLS; +} + +type AvatarModalStep = 'menu' | 'presets'; + +export default function PersonalInfoScreen() { + const { t } = useTranslation('profile'); + const { data: profile, isLoading: profileLoading } = useProfile(); + const update = useUpdateProfile(); + const updateNicknameMut = useUpdateNickname(); + const uploadAvatar = useUploadAvatar(); + const setPreset = useSetAvatarPreset(); + const { data: presets, isLoading: presetsLoading } = useAvatarPresets(); + + const [nickname, setNickname] = useState(''); const [birthYear, setBirthYear] = useState(''); const [birthPlace, setBirthPlace] = useState(''); const [grewUpPlace, setGrewUpPlace] = useState(''); const [occupation, setOccupation] = useState(''); + const [avatarModalOpen, setAvatarModalOpen] = useState(false); + const [avatarStep, setAvatarStep] = useState('menu'); + useEffect(() => { if (profile) { + setNickname(profile.nickname ?? ''); setBirthYear(profile.birth_year?.toString() ?? ''); setBirthPlace(profile.birth_place ?? ''); setGrewUpPlace(profile.grew_up_place ?? ''); @@ -26,53 +67,275 @@ export default function PersonalInfoScreen() { } }, [profile]); - const handleSave = () => { - update.mutate({ - birth_year: birthYear ? Number(birthYear) : null, - birth_place: birthPlace || null, - grew_up_place: grewUpPlace || null, - occupation: occupation || null, - }); + const closeAvatarModal = () => { + setAvatarModalOpen(false); + setAvatarStep('menu'); }; + const pickFromLibrary = async () => { + const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!perm.granted) { + Alert.alert('', t('personalInfo.libraryPermissionDenied')); + return; + } + + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ['images'], + allowsEditing: true, + aspect: [1, 1], + quality: 0.9, + }); + + if (result.canceled) return; + + const asset = result.assets[0]; + + try { + const form = await buildAvatarUploadFormData(asset); + await uploadAvatar.mutateAsync(form); + closeAvatarModal(); + } catch (err) { + const msg = + err instanceof ApiError + ? err.message + : err instanceof Error + ? err.message + : String(err); + Alert.alert(t('personalInfo.avatarUploadFailed'), msg); + } + }; + + const applyPreset = async (presetId: string) => { + try { + await setPreset.mutateAsync(presetId); + closeAvatarModal(); + } catch (err) { + const msg = + err instanceof ApiError + ? err.message + : err instanceof Error + ? err.message + : String(err); + Alert.alert(t('personalInfo.avatarPresetFailed'), msg); + } + }; + + const avatarBusy = uploadAvatar.isPending || setPreset.isPending; + const avatarUri = resolveApiMediaUrl(profile?.avatar_url ?? null); + const tileSize = computePresetTileSize(); + + const handleSave = async () => { + const trimmed = nickname.trim(); + if (!trimmed) { + Alert.alert('', t('personalInfo.nicknameRequired')); + return; + } + + let nicknameCommitted = false; + try { + if (profile && trimmed !== profile.nickname) { + await updateNicknameMut.mutateAsync({ nickname: trimmed }); + nicknameCommitted = true; + } + await update.mutateAsync({ + birth_year: birthYear ? Number(birthYear) : null, + birth_place: birthPlace || null, + grew_up_place: grewUpPlace || null, + occupation: occupation || null, + }); + } catch (err) { + const msg = + err instanceof ApiError + ? err.message + : err instanceof Error + ? err.message + : String(err); + if (nicknameCommitted) { + Alert.alert(t('personalInfo.savePartialTitle'), `${t('personalInfo.savePartialBody')}\n\n${msg}`); + } else { + Alert.alert(t('personalInfo.saveFailed'), msg); + } + } + }; + + const saving = update.isPending || updateNicknameMut.isPending; + return ( - - + + + + { + setAvatarStep('menu'); + setAvatarModalOpen(true); + }} + > + + {avatarUri ? ( + + ) : ( + + + {nickname.trim().slice(0, 1).toUpperCase() || '?'} + + + )} + {avatarBusy ? ( + + + + ) : null} + + + + {t('personalInfo.changeAvatar')} + + + + + {t('personalInfo.nickname')} + + - {update.error && ( + {(update.error ?? updateNicknameMut.error) != null ? ( - {update.error.message} + {(updateNicknameMut.error ?? update.error)?.message} - )} + ) : null} - + + + + + {avatarStep === 'presets' ? ( + setAvatarStep('menu')} + > + {t('personalInfo.back')} + + ) : ( + + )} + + {avatarStep === 'presets' + ? t('personalInfo.presetPickTitle') + : t('personalInfo.changeAvatar')} + + + {t('personalInfo.cancel')} + + + + {avatarStep === 'menu' ? ( + + + + + ) : ( + + {presetsLoading ? ( + + ) : ( + + {(presets ?? []).map((item) => { + const uri = resolveApiMediaUrl(item.url); + return ( + void applyPreset(item.id)} + style={{ + width: tileSize, + height: tileSize, + }} + > + {uri ? ( + + ) : null} + + ); + })} + + )} + + )} + + ); } diff --git a/app-expo/src/app/(tabs)/profile.tsx b/app-expo/src/app/(tabs)/profile.tsx index 9922d82..cef7346 100644 --- a/app-expo/src/app/(tabs)/profile.tsx +++ b/app-expo/src/app/(tabs)/profile.tsx @@ -1,4 +1,5 @@ import { router } from 'expo-router'; +import { Image } from 'expo-image'; import React from 'react'; import { Pressable, ScrollView, View } from 'react-native'; import { useTranslation } from 'react-i18next'; @@ -21,6 +22,7 @@ import { import { Icon } from '@/components/ui/icon'; import { Switch } from '@/components/ui/switch'; import { Text } from '@/components/ui/text'; +import { resolveApiMediaUrl } from '@/core/api/media-url'; import { useAppSettings } from '@/hooks/use-app-settings'; import { useSession, useLogout } from '@/features/auth/hooks'; import { useCurrentPlan } from '@/features/profile/hooks'; @@ -181,6 +183,8 @@ export default function ProfileScreen() { themeOptions.find((o) => o.value === themeName)?.label ?? tApp('theme.default'); + const avatarUri = resolveApiMediaUrl(user?.avatar_url ?? null); + return ( - + {avatarUri ? ( + + ) : ( + + )} (`${AUTH}/me/avatar`, { body: file }); }, + + fetchAvatarPresets() { + return api.get(`${AUTH}/avatar-presets`, { + skipAuth: true, + }); + }, + + setAvatarPreset(body: SetAvatarPresetRequest) { + return api.put(`${AUTH}/me/avatar/preset`, { body }); + }, } as const; diff --git a/app-expo/src/features/auth/avatar-upload-form-data.ts b/app-expo/src/features/auth/avatar-upload-form-data.ts new file mode 100644 index 0000000..4c1018c --- /dev/null +++ b/app-expo/src/features/auth/avatar-upload-form-data.ts @@ -0,0 +1,80 @@ +import type * as ImagePicker from 'expo-image-picker'; +import { Platform } from 'react-native'; + +type AvatarMime = 'image/jpeg' | 'image/png' | 'image/webp'; + +function inferMimeFromUri(uri: string): AvatarMime { + const u = uri.toLowerCase(); + if (u.endsWith('.png')) return 'image/png'; + if (u.endsWith('.webp')) return 'image/webp'; + return 'image/jpeg'; +} + +function coerceMime(value: string | null | undefined, uri: string): AvatarMime { + if ( + value === 'image/jpeg' || + value === 'image/png' || + value === 'image/webp' + ) { + return value; + } + return inferMimeFromUri(uri); +} + +function mimeToFilename(mime: AvatarMime): string { + switch (mime) { + case 'image/png': + return 'avatar.png'; + case 'image/webp': + return 'avatar.webp'; + default: + return 'avatar.jpg'; + } +} + +/** + * 构建与后端 `POST /api/auth/me/avatar` 约定的 multipart(字段名 `file`)。 + * Native:`{ uri, name, type }`;Web:`File`,避免 RN FormData 在 Web 上不识别 `uri`。 + */ +export async function buildAvatarUploadFormData( + asset: ImagePicker.ImagePickerAsset, +): Promise { + const uri = asset.uri; + const mime = coerceMime(asset.mimeType, uri); + const filename = mimeToFilename(mime); + const form = new FormData(); + + if (Platform.OS === 'web') { + const webFile = asset.file; + if ( + webFile instanceof File && + (webFile.type === 'image/jpeg' || + webFile.type === 'image/png' || + webFile.type === 'image/webp') + ) { + form.append( + 'file', + webFile, + webFile.name || mimeToFilename(coerceMime(webFile.type, uri)), + ); + return form; + } + + const res = await fetch(uri); + const blob = await res.blob(); + const type = coerceMime(blob.type, uri); + form.append('file', new File([blob], mimeToFilename(type), { type })); + return form; + } + + form.append( + 'file', + { + uri, + name: filename, + type: mime, + } as unknown as Blob, + ); + + return form; +} diff --git a/app-expo/src/features/auth/hooks.ts b/app-expo/src/features/auth/hooks.ts index d4e3ee3..68e3803 100644 --- a/app-expo/src/features/auth/hooks.ts +++ b/app-expo/src/features/auth/hooks.ts @@ -14,6 +14,7 @@ import type { SmsRegisterRequest, SmsRequest, TokenResponse, + UpdateNicknameRequest, UserInfo, } from './types'; @@ -24,6 +25,16 @@ export const authKeys = { tokenCheck: ['auth', 'token-check'] as const, }; +const PROFILE_QUERY_PREFIX = ['profile'] as const; + +function syncSessionAndProfileQueries( + queryClient: ReturnType, + user: UserInfo, +) { + queryClient.setQueryData(authKeys.session, user); + queryClient.invalidateQueries({ queryKey: PROFILE_QUERY_PREFIX }); +} + // ─── useSession ─── /** @@ -162,6 +173,45 @@ export function useSmsCode() { }); } +// ─── Avatar / nickname ─── + +export function useAvatarPresets() { + return useQuery({ + queryKey: ['avatar-presets'], + queryFn: () => authApi.fetchAvatarPresets(), + staleTime: 60 * 60 * 1000, + }); +} + +export function useUpdateNickname() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (body: UpdateNicknameRequest) => + authApi.updateNickname(body), + onSuccess: (user) => syncSessionAndProfileQueries(queryClient, user), + }); +} + +export function useUploadAvatar() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (form: FormData) => authApi.uploadAvatar(form), + onSuccess: (user) => syncSessionAndProfileQueries(queryClient, user), + }); +} + +export function useSetAvatarPreset() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (presetId: string) => + authApi.setAvatarPreset({ preset_id: presetId }), + onSuccess: (user) => syncSessionAndProfileQueries(queryClient, user), + }); +} + // ─── useLogout ─── /** diff --git a/app-expo/src/features/auth/types.ts b/app-expo/src/features/auth/types.ts index 1f10285..c1db232 100644 --- a/app-expo/src/features/auth/types.ts +++ b/app-expo/src/features/auth/types.ts @@ -79,6 +79,15 @@ export interface UpdateNicknameRequest { nickname: string; } +export interface AvatarPresetItem { + id: string; + url: string; +} + +export interface SetAvatarPresetRequest { + preset_id: string; +} + // ─── Session state ─── export type SessionStatus = diff --git a/app-expo/src/features/voice/hooks/use-player.ts b/app-expo/src/features/voice/hooks/use-player.ts index d418ddc..8bb257f 100644 --- a/app-expo/src/features/voice/hooks/use-player.ts +++ b/app-expo/src/features/voice/hooks/use-player.ts @@ -15,6 +15,10 @@ interface UsePlayerResult { enqueue: (item: PlaybackItem) => void; /** Replace queue and play this item (e.g. user voice bubble vs other sources). */ enqueueExclusive: (item: PlaybackItem) => Promise; + /** Pause native playback without draining queue(与 stop 清空队列不同)。 */ + pausePlayback: () => void; + /** Continue after pausePlayback(需 status === 'paused') */ + resumePlayback: () => void; stop: () => void; } @@ -68,9 +72,11 @@ export function usePlayer(): UsePlayerResult { useEffect(() => { if (!currentSource || !player) return; if (!playerStatus.isLoaded) return; + /** 先于 isLoaded「抢暂停」时需保留暂停,避免本条自动 play 覆盖 pause */ + if (status === 'paused') return; player.play(); isPlayingRef.current = true; - }, [currentSource, player, playerStatus.isLoaded]); + }, [currentSource, player, playerStatus.isLoaded, status]); const playNext = useCallback(async () => { if (isPlayNextInProgressRef.current) return; @@ -114,6 +120,7 @@ export function usePlayer(): UsePlayerResult { // Detect playback completion → advance queue(必须曾 playing,避免换源瞬间沿用上一条的 duration/currentTime) useEffect(() => { + if (status === 'paused') return; if (!currentSource || !isPlayingRef.current) return; const { playing, currentTime, duration } = playerStatus; @@ -128,7 +135,32 @@ export function usePlayer(): UsePlayerResult { isPlayingRef.current = false; playNext(); } - }, [playerStatus, currentSource, playNext]); + }, [playerStatus, currentSource, playNext, status]); + + const pausePlayback = useCallback(() => { + setStatus((s) => { + if (s !== 'playing') return s; + if (player) { + player.pause(); + } + isPlayingRef.current = false; + return 'paused'; + }); + }, [player]); + + const resumePlayback = useCallback(async () => { + if (status !== 'paused') return; + const acquired = await audioFocus.acquireForPlayback(); + if (!acquired) { + setStatus('idle'); + return; + } + if (!player) return; + if (!playerStatus.isLoaded) return; + player.play(); + setStatus('playing'); + isPlayingRef.current = true; + }, [status, player, playerStatus.isLoaded]); // Subscribe to audioFocus owner changes for recorder → idle recovery useEffect(() => { @@ -205,6 +237,8 @@ export function usePlayer(): UsePlayerResult { currentPlaybackItem, enqueue, enqueueExclusive, + pausePlayback, + resumePlayback, stop, }; } diff --git a/app-expo/src/i18n/generated/resources.ts b/app-expo/src/i18n/generated/resources.ts index dedbc9e..2b35dd8 100644 --- a/app-expo/src/i18n/generated/resources.ts +++ b/app-expo/src/i18n/generated/resources.ts @@ -1,212 +1,241 @@ // This file is automatically generated by i18next-cli. Do not edit manually. interface Resources { - app: { - languages: { - en: 'English'; - system: 'System'; - zh: 'Chinese'; - }; - name: 'Life Echo'; - tabs: { - conversations: 'Chats'; - explore: 'Explore'; - home: 'Home'; - memoir: 'Memoir'; - profile: 'Profile'; - }; - theme: { - default: 'Default'; - }; - }; - auth: { - login: { - codeLabel: 'Verification Code'; - getCode: 'Get Code'; - getCodeCountdown: 'Retry in {{seconds}}s'; - networkError: 'Network error. Please try again later.'; - phoneLabel: 'Phone Number'; - phonePlaceholder: 'Enter your phone number'; - privacyPolicy: 'Privacy Policy'; - submit: 'Login'; - termsAnd: 'and'; - termsIntro: 'I agree to the'; - termsRequired: 'Please agree to the User Agreement and Privacy Policy first'; - termsRequiredConfirm: 'OK'; - termsRequiredTitle: 'Agreement Required'; - userAgreement: 'User Agreement'; - welcomeSubtitle: 'Some lives grow richer the more you savor them.'; - welcomeTitle: 'Welcome back'; - }; - }; - common: { - chapterLabel: ''; - chapterReading: { - backgroundColor: ''; - bgPureWhite: ''; - bgSepia: ''; - close: ''; - fontSize: ''; - readingSettings: ''; - typography: ''; - }; - continueWriting: ''; - docs: 'Docs'; - emptySubtitle: ''; - emptyTitle: ''; - readMemory: ''; - startChapter: ''; - statusDrafting: ''; - statusLocked: ''; - statusPending: ''; - wordsCount: ''; - }; - conversation: { - addMore: 'More'; - agentName: 'Life Echo'; - assistantReplying: 'Replying…'; - cancel: 'Cancel'; - cancelRecording: 'Cancel recording'; - cannotReadAloud: 'Read unavailable'; - chatQueueSendTimeout: 'Connection timed out. Check your network and try again.'; - chatTitle: 'Conversation'; - chatUnavailableConnecting: 'Reconnecting now. You can keep typing and send once the connection is back.'; - chatUnavailableDisconnected: 'Connection lost. You can keep typing and send after reconnecting.'; - chatUnavailableTitle: 'Chat unavailable'; - confirm: 'OK'; - confirmDeleteConversation: 'Are you sure you want to delete this conversation? It cannot be recovered.'; - connectionConnected: 'Connected'; - connectionConnecting: 'Connecting...'; - connectionDisconnected: 'Disconnected'; - createError: 'Unable to create conversation. Please check your network and try again.'; - delete: 'Delete'; - deleteConversation: 'Delete Conversation'; - emptyGreetingSubtitle: 'Chat with your companion and record your stories.'; - greetingTitle: 'Say Hello'; - inputPlaceholder: 'Type a message...'; - inputPlaceholderVoice: 'Type here or hold the mic to speak...'; - me: 'Me'; - readAloudAgain: 'Play again'; - readingAloud: 'Reading aloud…'; - recentChats: 'Recent Chats'; - recordingPermissionDenied: 'Microphone permission is required to record'; - recordingStartFailed: 'Unable to start recording. Please try again.'; - resumeChatSubtitle: 'Open your latest conversation to keep talking.'; - resumeChatTitle: 'Continue chatting'; - send: 'Send'; - startNewSubtitle: 'Capture a new memory or share your thoughts with your companion.'; - stopReadingAloud: 'Stop reading aloud'; - switchToText: 'Switch to text input'; - switchToVoice: 'Switch to voice input'; - tapToEndRecording: 'Tap to end'; - tapToStartRecording: 'Tap to start recording'; - timeDaysAgo_one: '{{count}} day ago'; - timeDaysAgo_other: '{{count}} days ago'; - timeHoursAgo_one: '{{count}} hour ago'; - timeHoursAgo_other: '{{count}} hours ago'; - timeJustNow: 'Just now'; - timeMinutesAgo_one: '{{count}} minute ago'; - timeMinutesAgo_other: '{{count}} minutes ago'; - viewAll: 'View All'; - voiceMessagePreview: 'Voice message'; - }; - explore: {}; - home: {}; - legal: { - titlePrivacy: 'Privacy Policy'; - titleTerms: 'User Agreement'; - }; - memoir: { - chapterLabel: 'Chapter {{index}}'; - chapterReading: { - back: 'Back'; - backgroundColor: 'Background'; - bgPureWhite: 'White'; - bgSepia: 'Sepia'; - cancel: 'Cancel'; - chapterNotFound: 'Chapter not found'; - close: 'Close'; - confirmDeleteMessage: 'Are you sure you want to delete this chapter? You will no longer be able to view it, but the content will be kept for future reference.'; - deleteChapter: 'Delete Chapter'; - deleteChapterAction: 'Delete'; - fontSans: 'Sans'; - fontSerif: 'Serif'; - fontSize: 'Font Size'; - fontSizeDefault: 'Medium'; - fontSizeLarge: 'Large'; - fontSizeSmall: 'Small'; - readingSettings: 'Reading Settings'; - settings: 'Settings'; - typography: 'Typography'; - }; - continueWriting: 'Continue Writing'; - emptySubtitle: 'Chat with your companion to record your stories'; - emptyTitle: 'No memoir yet'; - frameworkChapters: { - chapter1: 'Childhood and upbringing'; - chapter2: 'Education and young adulthood'; - chapter3: 'Early career'; - chapter4: 'Major achievements and peak moments'; - chapter5: 'Setbacks, challenges, and turning points'; - chapter6: 'Family and relationships'; - chapter7: 'Beliefs and values'; - chapter8: 'Life summary'; - }; - loadErrorMessage: 'Could not load chapters'; - loadErrorRetry: 'Retry'; - pageTitle: 'Memoir'; - readMemory: 'Read Memory'; - startChapter: 'Start Writing'; - statusDrafting: 'Drafting'; - statusLocked: 'Locked'; - statusPending: 'Pending'; - wordsCount: '{{count}} words'; - }; - profile: { - about: { - aboutUs: 'About Us'; - title: 'About'; - }; - appExperience: { - language: 'Language'; - languageDesc: 'Display language'; - largeText: 'Large Text'; - largeTextDesc: 'Make reading easier'; - nightMode: 'Night Mode'; - nightModeDesc: 'Use dark theme'; - theme: 'Theme'; - themeDesc: 'Color theme'; - title: 'App Experience'; - }; - dataPrivacy: { - deleteAll: 'Delete All Data'; - deleteUnderDevelopment: 'Delete data feature is under development.'; - exportAll: 'Export All Data'; - exportUnderDevelopment: 'Export feature is under development.'; - purgeDialogCancel: 'Cancel'; - purgeDialogConfirm: 'Delete permanently'; - purgeDialogDescription: 'This cannot be undone. Your data will be removed immediately.'; - purgeDialogTitle: 'Final confirmation'; - purgeInputLabel: 'Confirmation phrase'; - purgeInputPlaceholder: 'Type the phrase shown above'; - purgeOpenConfirm: 'I understand, continue'; - purgePhraseHint: 'Type the following Chinese sentence exactly (every character and punctuation). The server only accepts this exact phrase:'; - purgeSubmitting: 'Deleting…'; - purgeWarningBody: 'This permanently deletes your conversations, memory, stories, chapters, orders, and related files in cloud storage. Profile fields such as birth year, birthplace, where you grew up, and occupation will also be cleared. All devices will be signed out.\nYou can still log in with the same phone number, but your previous content cannot be restored.'; - purgeWarningTitle: 'Before you continue'; - title: 'Data & Privacy'; - }; - editAvatar: 'Edit Profile Picture'; - helpSupport: { - faq: 'FAQ'; - feedback: 'Feedback & Support'; - feedbackPageTitle: 'Share your thoughts'; - title: 'Help & Support'; - }; - signOut: 'Sign Out'; - signingOut: 'Signing out...'; - userNamePlaceholder: 'User'; - userTier: '{{tier}}'; - }; + "app": { + "languages": { + "en": "English", + "system": "System", + "zh": "Chinese" + }, + "name": "Life Echo", + "tabs": { + "conversations": "Chats", + "explore": "Explore", + "home": "Home", + "memoir": "Memoir", + "profile": "Profile" + }, + "theme": { + "default": "Default" + } + }, + "auth": { + "login": { + "codeLabel": "Verification Code", + "getCode": "Get Code", + "getCodeCountdown": "Retry in {{seconds}}s", + "networkError": "Network error. Please try again later.", + "phoneLabel": "Phone Number", + "phonePlaceholder": "Enter your phone number", + "privacyPolicy": "Privacy Policy", + "submit": "Login", + "termsAnd": "and", + "termsIntro": "I agree to the", + "termsRequired": "Please agree to the User Agreement and Privacy Policy first", + "termsRequiredConfirm": "OK", + "termsRequiredTitle": "Agreement Required", + "userAgreement": "User Agreement", + "welcomeSubtitle": "Some lives grow richer the more you savor them.", + "welcomeTitle": "Welcome back" + } + }, + "common": { + "chapterLabel": "", + "chapterReading": { + "backgroundColor": "", + "bgPureWhite": "", + "bgSepia": "", + "close": "", + "fontSize": "", + "readingSettings": "", + "typography": "" + }, + "continueWriting": "", + "docs": "Docs", + "emptySubtitle": "", + "emptyTitle": "", + "readMemory": "", + "startChapter": "", + "statusDrafting": "", + "statusLocked": "", + "statusPending": "", + "wordsCount": "" + }, + "conversation": { + "addMore": "More", + "agentName": "Life Echo", + "assistantReplying": "Replying…", + "cancel": "Cancel", + "cancelRecording": "Cancel recording", + "cannotReadAloud": "Read unavailable", + "chatQueueSendTimeout": "Connection timed out. Check your network and try again.", + "chatTitle": "Conversation", + "chatUnavailableConnecting": "Reconnecting now. You can keep typing and send once the connection is back.", + "chatUnavailableDisconnected": "Connection lost. You can keep typing and send after reconnecting.", + "chatUnavailableTitle": "Chat unavailable", + "confirm": "OK", + "confirmDeleteConversation": "Are you sure you want to delete this conversation? It cannot be recovered.", + "connectionConnected": "Connected", + "connectionConnecting": "Connecting...", + "connectionDisconnected": "Disconnected", + "createError": "Unable to create conversation. Please check your network and try again.", + "delete": "Delete", + "deleteConversation": "Delete Conversation", + "emptyGreetingSubtitle": "Chat with your companion and record your stories.", + "greetingTitle": "Say Hello", + "inputPlaceholder": "Type a message...", + "inputPlaceholderVoice": "Type here or hold the mic to speak...", + "me": "Me", + "readAloudAgain": "Play again", + "readAloudPause": "Pause reading", + "readAloudResume": "Resume reading", + "readingAloud": "Reading aloud…", + "recentChats": "Recent Chats", + "recordingPermissionDenied": "Microphone permission is required to record", + "recordingStartFailed": "Unable to start recording. Please try again.", + "resumeChatSubtitle": "Open your latest conversation to keep talking.", + "resumeChatTitle": "Continue chatting", + "send": "Send", + "startNewSubtitle": "Capture a new memory or share your thoughts with your companion.", + "stopReadingAloud": "Stop reading aloud", + "switchToText": "Switch to text input", + "switchToVoice": "Switch to voice input", + "tapToEndRecording": "Tap to end", + "tapToStartRecording": "Tap to start recording", + "timeDaysAgo_one": "{{count}} day ago", + "timeDaysAgo_other": "{{count}} days ago", + "timeHoursAgo_one": "{{count}} hour ago", + "timeHoursAgo_other": "{{count}} hours ago", + "timeJustNow": "Just now", + "timeMinutesAgo_one": "{{count}} minute ago", + "timeMinutesAgo_other": "{{count}} minutes ago", + "viewAll": "View All", + "voiceMessagePreview": "Voice message" + }, + "explore": { + + }, + "home": { + + }, + "legal": { + "titlePrivacy": "Privacy Policy", + "titleTerms": "User Agreement" + }, + "memoir": { + "chapterLabel": "Chapter {{index}}", + "chapterReading": { + "back": "Back", + "backgroundColor": "Background", + "bgPureWhite": "White", + "bgSepia": "Sepia", + "cancel": "Cancel", + "chapterNotFound": "Chapter not found", + "close": "Close", + "confirmDeleteMessage": "Are you sure you want to delete this chapter? You will no longer be able to view it, but the content will be kept for future reference.", + "deleteChapter": "Delete Chapter", + "deleteChapterAction": "Delete", + "fontSans": "Sans", + "fontSerif": "Serif", + "fontSize": "Font Size", + "fontSizeDefault": "Medium", + "fontSizeLarge": "Large", + "fontSizeSmall": "Small", + "readingSettings": "Reading Settings", + "settings": "Settings", + "typography": "Typography" + }, + "continueWriting": "Continue Writing", + "emptySubtitle": "Chat with your companion to record your stories", + "emptyTitle": "No memoir yet", + "frameworkChapters": { + "chapter1": "Childhood and upbringing", + "chapter2": "Education and young adulthood", + "chapter3": "Early career", + "chapter4": "Major achievements and peak moments", + "chapter5": "Setbacks, challenges, and turning points", + "chapter6": "Family and relationships", + "chapter7": "Beliefs and values", + "chapter8": "Life summary" + }, + "loadErrorMessage": "Could not load chapters", + "loadErrorRetry": "Retry", + "pageTitle": "Memoir", + "readMemory": "Read Memory", + "startChapter": "Start Writing", + "statusDrafting": "Drafting", + "statusLocked": "Locked", + "statusPending": "Pending", + "wordsCount": "{{count}} words" + }, + "profile": { + "about": { + "aboutUs": "About Us", + "title": "About" + }, + "appExperience": { + "language": "Language", + "languageDesc": "Display language", + "largeText": "Large Text", + "largeTextDesc": "Make reading easier", + "nightMode": "Night Mode", + "nightModeDesc": "Use dark theme", + "theme": "Theme", + "themeDesc": "Color theme", + "title": "App Experience" + }, + "dataPrivacy": { + "deleteAll": "Delete All Data", + "deleteUnderDevelopment": "Delete data feature is under development.", + "exportAll": "Export All Data", + "exportUnderDevelopment": "Export feature is under development.", + "purgeDialogCancel": "Cancel", + "purgeDialogConfirm": "Delete permanently", + "purgeDialogDescription": "This cannot be undone. Your data will be removed immediately.", + "purgeDialogTitle": "Final confirmation", + "purgeInputLabel": "Confirmation phrase", + "purgeInputPlaceholder": "Type the phrase shown above", + "purgeOpenConfirm": "I understand, continue", + "purgePhraseHint": "Type the following Chinese sentence exactly (every character and punctuation). The server only accepts this exact phrase:", + "purgeSubmitting": "Deleting…", + "purgeWarningBody": "This permanently deletes your conversations, memory, stories, chapters, orders, and related files in cloud storage. Profile fields such as birth year, birthplace, where you grew up, and occupation will also be cleared. All devices will be signed out.\nYou can still log in with the same phone number, but your previous content cannot be restored.", + "purgeWarningTitle": "Before you continue", + "title": "Data & Privacy" + }, + "editAvatar": "Edit Profile Picture", + "helpSupport": { + "faq": "FAQ", + "feedback": "Feedback & Support", + "feedbackPageTitle": "Share your thoughts", + "title": "Help & Support" + }, + "personalInfo": { + "avatarPresetFailed": "Could not set preset avatar", + "avatarUploadFailed": "Could not upload avatar", + "birthPlacePlaceholder": "Birthplace", + "birthYearPlaceholder": "Birth year", + "cancel": "Cancel", + "changeAvatar": "Change photo", + "chooseFromLibrary": "Choose from library", + "choosePreset": "Preset avatars", + "grewUpPlaceholder": "Where you grew up", + "libraryPermissionDenied": "Photo library access is required to pick an image", + "nickname": "Nickname", + "nicknamePlaceholder": "Enter nickname", + "nicknameRequired": "Please enter a nickname", + "occupationPlaceholder": "Occupation", + "presetPickTitle": "Choose a preset", + "save": "Save", + "saveFailed": "Could not save", + "savePartialBody": "Your nickname was saved, but profile fields below could not be saved. Check your connection and tap Save again.", + "savePartialTitle": "Partially saved", + "saving": "Saving…", + "title": "Personal info" + }, + "signOut": "Sign Out", + "signingOut": "Signing out...", + "userNamePlaceholder": "User", + "userTier": "{{tier}}" + } } export default Resources; diff --git a/app-expo/src/i18n/locales/en/conversation.json b/app-expo/src/i18n/locales/en/conversation.json index 127f056..bc392ce 100644 --- a/app-expo/src/i18n/locales/en/conversation.json +++ b/app-expo/src/i18n/locales/en/conversation.json @@ -27,6 +27,8 @@ "recentChats": "Recent Chats", "stopReadingAloud": "Stop reading aloud", "readAloudAgain": "Play again", + "readAloudPause": "Pause reading", + "readAloudResume": "Resume reading", "cannotReadAloud": "Read unavailable", "readingAloud": "Reading aloud…", "recordingPermissionDenied": "Microphone permission is required to record", diff --git a/app-expo/src/i18n/locales/en/profile.json b/app-expo/src/i18n/locales/en/profile.json index 6bfc981..c9d4cdf 100644 --- a/app-expo/src/i18n/locales/en/profile.json +++ b/app-expo/src/i18n/locales/en/profile.json @@ -33,6 +33,29 @@ "title": "Data & Privacy" }, "editAvatar": "Edit Profile Picture", + "personalInfo": { + "avatarPresetFailed": "Could not set preset avatar", + "avatarUploadFailed": "Could not upload avatar", + "cancel": "Cancel", + "birthPlacePlaceholder": "Birthplace", + "birthYearPlaceholder": "Birth year", + "changeAvatar": "Change photo", + "chooseFromLibrary": "Choose from library", + "choosePreset": "Preset avatars", + "grewUpPlaceholder": "Where you grew up", + "libraryPermissionDenied": "Photo library access is required to pick an image", + "nickname": "Nickname", + "nicknamePlaceholder": "Enter nickname", + "nicknameRequired": "Please enter a nickname", + "occupationPlaceholder": "Occupation", + "presetPickTitle": "Choose a preset", + "save": "Save", + "saveFailed": "Could not save", + "savePartialBody": "Your nickname was saved, but profile fields below could not be saved. Check your connection and tap Save again.", + "savePartialTitle": "Partially saved", + "saving": "Saving…", + "title": "Personal info" + }, "helpSupport": { "faq": "FAQ", "feedback": "Feedback & Support", diff --git a/app-expo/src/i18n/locales/zh/conversation.json b/app-expo/src/i18n/locales/zh/conversation.json index 9fffe59..4893cd9 100644 --- a/app-expo/src/i18n/locales/zh/conversation.json +++ b/app-expo/src/i18n/locales/zh/conversation.json @@ -27,6 +27,8 @@ "recentChats": "最近对话", "stopReadingAloud": "停止朗读", "readAloudAgain": "再读", + "readAloudPause": "暂停朗读", + "readAloudResume": "继续朗读", "cannotReadAloud": "暂无法朗读", "readingAloud": "朗读中…", "recordingPermissionDenied": "需要麦克风权限才能录音", diff --git a/app-expo/src/i18n/locales/zh/profile.json b/app-expo/src/i18n/locales/zh/profile.json index 9ed5b81..e993e8c 100644 --- a/app-expo/src/i18n/locales/zh/profile.json +++ b/app-expo/src/i18n/locales/zh/profile.json @@ -33,6 +33,29 @@ "title": "数据与隐私" }, "editAvatar": "编辑头像", + "personalInfo": { + "avatarPresetFailed": "设置预设头像失败", + "avatarUploadFailed": "上传头像失败", + "cancel": "取消", + "birthPlacePlaceholder": "出生地", + "birthYearPlaceholder": "出生年份", + "changeAvatar": "更换头像", + "chooseFromLibrary": "从相册选择", + "choosePreset": "预设头像", + "grewUpPlaceholder": "成长地", + "libraryPermissionDenied": "需要相册权限才能选择图片", + "nickname": "昵称", + "nicknamePlaceholder": "请输入昵称", + "nicknameRequired": "请填写昵称", + "occupationPlaceholder": "职业", + "presetPickTitle": "选择预设", + "save": "保存", + "saveFailed": "保存失败", + "savePartialBody": "昵称已更新,但下面的档案字段未能保存。请检查网络后再次点击保存。", + "savePartialTitle": "部分保存成功", + "saving": "保存中…", + "title": "个人信息" + }, "helpSupport": { "faq": "常见问题", "feedback": "反馈与客服", diff --git a/app-expo/tests/features/voice/use-player.test.tsx b/app-expo/tests/features/voice/use-player.test.tsx index 3122b94..15f90c1 100644 --- a/app-expo/tests/features/voice/use-player.test.tsx +++ b/app-expo/tests/features/voice/use-player.test.tsx @@ -1,5 +1,6 @@ -import { renderHook } from '@testing-library/react-native'; +import { act, renderHook } from '@testing-library/react-native'; +import { audioFocus } from '@/core/audio/audio-focus'; import { usePlayer } from '@/features/voice/hooks/use-player'; const mockUseAudioPlayer = jest.fn(); @@ -34,6 +35,8 @@ describe('usePlayer', () => { currentTime: 0, duration: 0, }); + jest.mocked(audioFocus.acquireForPlayback).mockResolvedValue(true); + jest.mocked(audioFocus.releaseIfOwnedBy).mockResolvedValue(undefined); }); test('keeps the native audio session active while app-level audio focus owns teardown', () => { @@ -47,4 +50,81 @@ describe('usePlayer', () => { }), ); }); + + test('pausePlayback toggles playing→paused and invokes native pause', async () => { + mockUseAudioPlayerStatus.mockReturnValue({ + isLoaded: true, + playing: true, + currentTime: 0.1, + duration: 10, + }); + const pause = jest.fn(); + const play = jest.fn(); + mockUseAudioPlayer.mockReturnValue({ pause, play }); + + const { result } = renderHook(() => usePlayer()); + + await act(async () => { + await result.current.enqueueExclusive({ + uri: 'file:///fixture.mp3', + kind: 'voice', + }); + }); + + expect(result.current.status).toBe('playing'); + + act(() => { + result.current.pausePlayback(); + }); + + expect(pause).toHaveBeenCalled(); + expect(result.current.status).toBe('paused'); + }); + + test('resumePlayback toggles paused→playing and invokes native play', async () => { + mockUseAudioPlayerStatus.mockReturnValue({ + isLoaded: true, + playing: false, + currentTime: 0.1, + duration: 10, + }); + const pause = jest.fn(); + const play = jest.fn(); + mockUseAudioPlayer.mockReturnValue({ pause, play }); + + const { result } = renderHook(() => usePlayer()); + + await act(async () => { + await result.current.enqueueExclusive({ + uri: 'file:///fixture.mp3', + kind: 'voice', + }); + }); + + act(() => { + result.current.pausePlayback(); + }); + expect(result.current.status).toBe('paused'); + + await act(async () => { + await result.current.resumePlayback(); + }); + + expect(play).toHaveBeenCalled(); + expect(result.current.status).toBe('playing'); + }); + + test('pausePlayback is a no-op while idle', async () => { + const pause = jest.fn(); + mockUseAudioPlayer.mockReturnValue({ pause, play: jest.fn() }); + + const { result } = renderHook(() => usePlayer()); + + act(() => { + result.current.pausePlayback(); + }); + + expect(pause).not.toHaveBeenCalled(); + expect(result.current.status).toBe('idle'); + }); });