diff --git a/backend/unispeaking-server/Dockerfile b/backend/unispeaking-server/Dockerfile index 45f51761..9db53fe4 100644 --- a/backend/unispeaking-server/Dockerfile +++ b/backend/unispeaking-server/Dockerfile @@ -23,6 +23,7 @@ WORKDIR /app ARG DEBIAN_MIRROR=https://mirrors.aliyun.com/debian ARG DEBIAN_SECURITY_MIRROR=https://mirrors.aliyun.com/debian-security ARG PYPI_INDEX_URL=https://mirrors.aliyun.com/pypi/simple/ +ARG PYPI_FALLBACK_INDEX_URL=https://pypi.org/simple ARG PADDLE_PDX_MODEL_SOURCE=bos RUN sed -i \ @@ -53,9 +54,15 @@ ENV PIP_DEFAULT_TIMEOUT=120 COPY docker/ocr/requirements.txt /tmp/ocr-requirements.txt RUN python -m pip install \ - --no-cache-dir \ - --retries 5 \ - -r /tmp/ocr-requirements.txt + --no-cache-dir \ + --retries 5 \ + -r /tmp/ocr-requirements.txt \ + || python -m pip install \ + --no-cache-dir \ + --index-url "${PYPI_FALLBACK_INDEX_URL}" \ + --retries 5 \ + --timeout 300 \ + -r /tmp/ocr-requirements.txt RUN mkdir -p /app/ocr/models COPY src/main/resources/ocr/paddle_ocr_runner.py /app/ocr/paddle_ocr_runner.py diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/component/profile/WeeklyGoalProgressCalculator.java b/backend/unispeaking-server/src/main/java/com/unispeaking/component/profile/WeeklyGoalProgressCalculator.java index d553e685..eeef16a3 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/component/profile/WeeklyGoalProgressCalculator.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/component/profile/WeeklyGoalProgressCalculator.java @@ -17,7 +17,10 @@ public class WeeklyGoalProgressCalculator { private static final Set INCLUDED_TYPES = - EnumSet.of(SceneType.FREE_CHAT, SceneType.CUSTOM_SCENE); + EnumSet.of( + SceneType.FREE_CHAT, + SceneType.CUSTOM_SCENE, + SceneType.IELTS_SCENE); public WeeklyGoalProgress calculate( List records, diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/controller/HelpCenterController.java b/backend/unispeaking-server/src/main/java/com/unispeaking/controller/HelpCenterController.java new file mode 100644 index 00000000..e718e529 --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/controller/HelpCenterController.java @@ -0,0 +1,47 @@ +package com.unispeaking.controller; + +import com.unispeaking.common.response.ApiResponse; +import com.unispeaking.domain.dto.help.HelpCenterResponse; +import com.unispeaking.domain.dto.help.HelpArticleResponse; +import com.unispeaking.domain.dto.help.HelpCategoryDetailResponse; +import com.unispeaking.service.help.HelpCenterService; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/help-center") +public class HelpCenterController { + + private final HelpCenterService helpCenterService; + + public HelpCenterController(HelpCenterService helpCenterService) { + this.helpCenterService = helpCenterService; + } + + @GetMapping + public ApiResponse getHelpCenter() { + return ApiResponse.success(helpCenterService.getHelpCenter()); + } + + @GetMapping("/categories/{categoryId}") + public ApiResponse getCategory( + @PathVariable String categoryId) { + return ApiResponse.success(helpCenterService.getCategory(categoryId) + .orElseThrow(() -> notFound("帮助分类不存在"))); + } + + @GetMapping("/articles/{articleId}") + public ApiResponse getArticle( + @PathVariable String articleId) { + return ApiResponse.success(helpCenterService.getArticle(articleId) + .orElseThrow(() -> notFound("帮助文章不存在"))); + } + + private ResponseStatusException notFound(String message) { + return new ResponseStatusException(HttpStatus.NOT_FOUND, message); + } +} diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/help/HelpArticleResponse.java b/backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/help/HelpArticleResponse.java new file mode 100644 index 00000000..69dfa776 --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/help/HelpArticleResponse.java @@ -0,0 +1,9 @@ +package com.unispeaking.domain.dto.help; + +public record HelpArticleResponse( + String id, + String categoryId, + String title, + String summary, + String updatedAt) { +} diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/help/HelpArticleSummaryResponse.java b/backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/help/HelpArticleSummaryResponse.java new file mode 100644 index 00000000..d0bdcf34 --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/help/HelpArticleSummaryResponse.java @@ -0,0 +1,7 @@ +package com.unispeaking.domain.dto.help; + +public record HelpArticleSummaryResponse( + String id, + String title, + String summary) { +} diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/help/HelpCategoryDetailResponse.java b/backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/help/HelpCategoryDetailResponse.java new file mode 100644 index 00000000..239b2b4d --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/help/HelpCategoryDetailResponse.java @@ -0,0 +1,14 @@ +package com.unispeaking.domain.dto.help; + +import java.util.List; + +public record HelpCategoryDetailResponse( + String id, + String title, + String description, + List articles) { + + public HelpCategoryDetailResponse { + articles = List.copyOf(articles); + } +} diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/help/HelpCenterResponse.java b/backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/help/HelpCenterResponse.java new file mode 100644 index 00000000..292f1085 --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/help/HelpCenterResponse.java @@ -0,0 +1,17 @@ +package com.unispeaking.domain.dto.help; + +import java.util.List; + +public record HelpCenterResponse(List categories) { + + public HelpCenterResponse { + categories = List.copyOf(categories); + } + + public record HelpCategoryResponse( + String id, + String title, + String description, + int articleCount) { + } +} diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/config/SecurityConfig.java b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/config/SecurityConfig.java index 187e8ae1..b9820176 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/config/SecurityConfig.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/config/SecurityConfig.java @@ -50,6 +50,7 @@ SecurityFilterChain securityFilterChain( .authorizeHttpRequests(authorize -> authorize .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers("/error").permitAll() + .requestMatchers(HttpMethod.GET, "/api/help-center", "/api/help-center/**").permitAll() .requestMatchers("/api/auth/register", "/api/auth/login", "/api/auth/email/**", "/api/auth/mobile/email/**", "/api/auth/logout", "/api/admin/auth/login", "/api/admin/auth/logout", "/actuator/health").permitAll() diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/service/help/HelpCenterService.java b/backend/unispeaking-server/src/main/java/com/unispeaking/service/help/HelpCenterService.java new file mode 100644 index 00000000..e9750ae7 --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/service/help/HelpCenterService.java @@ -0,0 +1,109 @@ +package com.unispeaking.service.help; + +import com.unispeaking.domain.dto.help.HelpCenterResponse; +import com.unispeaking.domain.dto.help.HelpArticleResponse; +import com.unispeaking.domain.dto.help.HelpArticleSummaryResponse; +import com.unispeaking.domain.dto.help.HelpCategoryDetailResponse; +import java.util.List; +import java.util.Optional; +import org.springframework.stereotype.Service; + +@Service +public class HelpCenterService { + private static final String UPDATED_AT = "2026-08-04"; + + private final HelpCenterResponse helpCenter = new HelpCenterResponse(List.of( + category("quick-start", "快速开始", "完成首次设置,选择适合自己的口语练习方式。", 3), + category("account-login", "账号与登录", "了解注册、登录、个人资料和密码安全。", 4), + category("ai-training", "AI 对话与训练", "使用自由对话和情景口语完成一次完整练习。", 5), + category("audio", "麦克风和音频", "排查麦克风权限、播放声音和实时连接问题。", 4), + category("learning-records", "学习记录", "查看学习资产、评分、打卡和练习统计。", 4), + category("membership", "会员与额度", "了解当前会员页面、额度提示和功能开放状态。", 3), + category("privacy-security", "隐私与安全", "保护账号信息,安全使用麦克风并提交问题反馈。", 3), + category("feedback", "问题反馈", "整理问题信息,帮助我们更快定位使用异常。", 3))); + + private final List
articles = List.of( + article("complete-first-time-setup", "quick-start", "如何完成首次设置并开始练习?", "设置英语水平和 AI 老师后,即可进入自由对话或情景训练。"), + article("choose-practice-mode", "quick-start", "自由对话和情景训练有什么区别?", "自由对话适合即时开口,情景训练适合围绕具体任务循序练习。"), + article("adjust-assistant-settings", "quick-start", "如何更换英语水平、AI 老师和语速?", "在个人中心的助手设置中调整对话体验。"), + article("register-and-login", "account-login", "如何注册和登录 UniSpeaking?", "使用有效邮箱和密码创建账号,之后可从登录页再次进入。"), + article("change-account-password", "account-login", "如何修改账号密码?", "在账号与安全中验证当前密码并设置新密码。"), + article("why-login-expired", "account-login", "为什么系统要求我重新登录?", "登录凭证失效、密码变更或账号状态变化时,系统会要求重新认证。"), + article("update-profile-details", "account-login", "如何修改昵称和头像?", "通过个人概览顶部的编辑按钮更新展示昵称和个人头像。"), + article("start-free-conversation", "ai-training", "如何开始一次 AI 自由对话?", "进入自由对话,确认麦克风可用后开始实时语音交流。"), + article("create-custom-scene", "ai-training", "如何创建自己的情景口语训练?", "描述想练习的真实情景,由系统生成对应学习内容。"), + article("learn-read-speak-flow", "ai-training", "“学、读、说”三个阶段分别做什么?", "先理解表达,再练习朗读,最后在完整情景中开口。"), + article("use-subtitles-and-translation", "ai-training", "如何使用字幕和翻译?", "在实时对话中按需要显示完整字幕并翻译对话内容。"), + article("finish-training-correctly", "ai-training", "怎样正确结束一次训练?", "使用页面中的结束操作,让系统完成会话收尾和结果保存。"), + article("grant-microphone-permission", "audio", "如何允许应用使用麦克风?", "在系统权限提示中允许麦克风,并确认使用正确的输入设备。"), + article("microphone-not-detected", "audio", "应用检测不到麦克风怎么办?", "检查设备连接、系统输入设置和应用权限。"), + article("cannot-hear-ai-audio", "audio", "听不到 AI 老师的声音怎么办?", "检查输出设备、页面播放状态和系统音量。"), + article("realtime-connection-interrupted", "audio", "实时对话连接中断后怎么办?", "结束异常会话,检查网络和权限后重新开始。"), + article("what-learning-assets-save", "learning-records", "学习资产会保存哪些内容?", "集中查看已完成场景中的语言材料、对话记录和可用评分。"), + article("view-conversation-feedback", "learning-records", "如何查看对话记录和评分?", "从学习资产打开最近完成的场景对话详情。"), + article("practice-from-assets", "learning-records", "如何从学习资产再次练习?", "复用已有场景直接练口语,或从头重新学习内容。"), + article("understand-checkin-statistics", "learning-records", "自动打卡和学习统计是怎样计算的?", "打卡来自已生成的训练报告,学习时长来自正常完成的有效会话。"), + article("open-membership-page", "membership", "在哪里查看会员与额度页面?", "从个人中心进入会员权益,查看当前方案和额度信息。"), + article("membership-payment-status", "membership", "当前会员升级会产生真实扣费吗?", "不会。当前版本的升级和支付流程是界面演示。"), + article("understand-quota-reminders", "membership", "页面中的额度提示代表什么?", "当前额度和特训限制用于展示预期体验,不构成正式计费承诺。"), + article("protect-account-security", "privacy-security", "如何保护我的 UniSpeaking 账号?", "使用独立密码,妥善保管登录状态,并在异常时及时修改密码。"), + article("avoid-sensitive-feedback", "privacy-security", "提交问题反馈时不应包含哪些信息?", "不要提交密码、令牌、密钥、完整身份证明或其他敏感数据。"), + article("use-microphone-safely", "privacy-security", "使用麦克风练习时需要注意什么?", "只在开始口语练习时授权麦克风,结束后及时停止会话。"), + article("check-before-feedback", "feedback", "反馈问题前应该先做哪些检查?", "先确认网络、权限和页面状态,避免重复提交可以自行恢复的问题。"), + article("prepare-feedback-details", "feedback", "一条有效的问题反馈应包含什么?", "提供发生位置、复现步骤、实际结果、期望结果和环境信息。"), + article("report-security-concern", "feedback", "发现账号或隐私安全问题时怎么办?", "先保护账号并停止继续暴露数据,再整理最小必要问题信息。")); + + public HelpCenterResponse getHelpCenter() { + return helpCenter; + } + + public Optional getCategory(String categoryId) { + return helpCenter.categories().stream() + .filter(category -> category.id().equals(categoryId)) + .findFirst() + .map(category -> new HelpCategoryDetailResponse( + category.id(), + category.title(), + category.description(), + articles.stream() + .filter(article -> article.categoryId().equals(categoryId)) + .map(article -> new HelpArticleSummaryResponse( + article.id(), article.title(), article.summary())) + .toList())); + } + + public Optional getArticle(String articleId) { + return articles.stream() + .filter(article -> article.id().equals(articleId)) + .findFirst() + .map(article -> new HelpArticleResponse( + article.id(), + article.categoryId(), + article.title(), + article.summary(), + UPDATED_AT)); + } + + private static HelpCenterResponse.HelpCategoryResponse category( + String id, + String title, + String description, + int articleCount) { + return new HelpCenterResponse.HelpCategoryResponse( + id, + title, + description, + articleCount); + } + + private static Article article( + String id, + String categoryId, + String title, + String summary) { + return new Article(id, categoryId, title, summary); + } + + private record Article(String id, String categoryId, String title, String summary) { + } +} diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/controller/HelpCenterControllerTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/controller/HelpCenterControllerTest.java new file mode 100644 index 00000000..3a6b6a59 --- /dev/null +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/controller/HelpCenterControllerTest.java @@ -0,0 +1,59 @@ +package com.unispeaking.controller; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.unispeaking.service.help.HelpCenterService; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +class HelpCenterControllerTest { + + @Test + void returnsHelpCategoriesFromTheBackendContract() throws Exception { + var mvc = MockMvcBuilders + .standaloneSetup(new HelpCenterController(new HelpCenterService())) + .build(); + + mvc.perform(get("/api/help-center")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.categories.length()").value(8)) + .andExpect(jsonPath("$.data.categories[0].id").value("quick-start")) + .andExpect(jsonPath("$.data.categories[0].title").value("快速开始")) + .andExpect(jsonPath("$.data.categories[0].articleCount").value(3)); + } + + @Test + void returnsCategoryArticlesAndArticleDetails() throws Exception { + var mvc = MockMvcBuilders + .standaloneSetup(new HelpCenterController(new HelpCenterService())) + .build(); + + mvc.perform(get("/api/help-center/categories/quick-start")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.title").value("快速开始")) + .andExpect(jsonPath("$.data.articles.length()").value(3)) + .andExpect(jsonPath("$.data.articles[0].id") + .value("complete-first-time-setup")); + + mvc.perform(get("/api/help-center/articles/complete-first-time-setup")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.categoryId").value("quick-start")) + .andExpect(jsonPath("$.data.title") + .value("如何完成首次设置并开始练习?")); + } + + @Test + void returnsNotFoundForUnknownHelpResources() throws Exception { + var mvc = MockMvcBuilders + .standaloneSetup(new HelpCenterController(new HelpCenterService())) + .build(); + + mvc.perform(get("/api/help-center/categories/missing")) + .andExpect(status().isNotFound()); + mvc.perform(get("/api/help-center/articles/missing")) + .andExpect(status().isNotFound()); + } +} diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/service/profile/WeeklyGoalProgressCalculatorTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/service/profile/WeeklyGoalProgressCalculatorTest.java index 64fadae6..f717ae34 100644 --- a/backend/unispeaking-server/src/test/java/com/unispeaking/service/profile/WeeklyGoalProgressCalculatorTest.java +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/service/profile/WeeklyGoalProgressCalculatorTest.java @@ -56,27 +56,33 @@ void calculatesOverlappingDurationAndCompletionWeekCount() { NOW, ZONE_ID); - assertEquals(330, progress.completedDurationSeconds()); + assertEquals(930, progress.completedDurationSeconds()); assertEquals(0, progress.remainingDurationSeconds()); assertEquals(100.0, progress.durationProgress()); assertTrue(progress.durationAchieved()); - assertEquals(2, progress.completedTrainingCount()); - assertEquals(2, progress.remainingTrainingCount()); - assertEquals(50.0, progress.countProgress()); + assertEquals(3, progress.completedTrainingCount()); + assertEquals(1, progress.remainingTrainingCount()); + assertEquals(75.0, progress.countProgress()); assertFalse(progress.countAchieved()); - assertEquals(2, progress.trainingTypeDurations().size()); + assertEquals(3, progress.trainingTypeDurations().size()); assertEquals(SceneType.FREE_CHAT, progress.trainingTypeDurations().get(0).type()); assertEquals(30, progress.trainingTypeDurations().get(0).durationSeconds()); - assertEquals(9.1, + assertEquals(3.2, progress.trainingTypeDurations().get(0).percentage()); assertEquals(SceneType.CUSTOM_SCENE, progress.trainingTypeDurations().get(1).type()); assertEquals(300, progress.trainingTypeDurations().get(1).durationSeconds()); - assertEquals(90.9, + assertEquals(32.3, progress.trainingTypeDurations().get(1).percentage()); + assertEquals(SceneType.IELTS_SCENE, + progress.trainingTypeDurations().get(2).type()); + assertEquals(600, + progress.trainingTypeDurations().get(2).durationSeconds()); + assertEquals(64.5, + progress.trainingTypeDurations().get(2).percentage()); assertEquals(Instant.parse("2026-08-02T16:00:00Z"), progress.weekStartsAt()); assertEquals(Instant.parse("2026-08-09T16:00:00Z"), @@ -107,7 +113,7 @@ void roundsProgressToOneDecimalAndReturnsRemainingValues() { } @Test - void returnsEmptyDistributionWhenNoSessionContributesDuration() { + void includesIeltsSessionsInLearningDuration() { var progress = calculator.calculate( List.of(record( SceneType.IELTS_SCENE, @@ -118,7 +124,10 @@ void returnsEmptyDistributionWhenNoSessionContributesDuration() { NOW, ZONE_ID); - assertTrue(progress.trainingTypeDurations().isEmpty()); + assertEquals(300, progress.completedDurationSeconds()); + assertEquals(1, progress.completedTrainingCount()); + assertEquals(SceneType.IELTS_SCENE, + progress.trainingTypeDurations().getFirst().type()); } private PracticeSessionRecord record( diff --git a/frontend/mobile/app.json b/frontend/mobile/app.json index 74feb6e0..c251daef 100644 --- a/frontend/mobile/app.json +++ b/frontend/mobile/app.json @@ -41,6 +41,12 @@ } ], "expo-secure-store", + [ + "expo-image-picker", + { + "photosPermission": "允许 UniSpeaking 访问照片,以便选择并上传个人头像。" + } + ], "expo-audio", "@siteed/audio-studio" ], diff --git a/frontend/mobile/package-lock.json b/frontend/mobile/package-lock.json index 7efedae6..04f04f9f 100644 --- a/frontend/mobile/package-lock.json +++ b/frontend/mobile/package-lock.json @@ -8,49 +8,51 @@ "name": "unispeaking-mobile", "version": "1.0.0", "dependencies": { - "@expo/ui": "~57.0.7", + "@expo/ui": "~57.0.10", "@expo/vector-icons": "^15.0.2", "@react-native-async-storage/async-storage": "2.2.0", "@siteed/audio-studio": "^3.2.1", - "expo": "~57.0.8", + "expo": "~57.0.12", + "expo-asset": "~57.0.7", "expo-audio": "~57.0.3", - "expo-constants": "~57.0.7", + "expo-constants": "~57.0.10", "expo-device": "~57.0.1", - "expo-file-system": "~57.0.1", + "expo-file-system": "~57.0.2", "expo-font": "~57.0.1", "expo-glass-effect": "~57.0.1", - "expo-image": "~57.0.1", + "expo-image": "~57.0.2", + "expo-image-picker": "~57.0.9", "expo-linear-gradient": "~57.0.1", - "expo-linking": "~57.0.4", - "expo-router": "~57.0.8", + "expo-linking": "~57.0.5", + "expo-router": "~57.0.12", "expo-secure-store": "~57.0.1", - "expo-splash-screen": "~57.0.5", + "expo-splash-screen": "~57.0.6", "expo-status-bar": "~57.0.1", - "expo-symbols": "~57.0.1", - "expo-system-ui": "~57.0.1", + "expo-symbols": "~57.0.2", + "expo-system-ui": "~57.0.2", "expo-web-browser": "~57.0.2", "phosphor-react-native": "^3.0.6", "react": "19.2.3", "react-dom": "19.2.3", - "react-native": "0.86.0", + "react-native": "0.86.2", "react-native-gesture-handler": "~2.32.0", - "react-native-reanimated": "4.5.0", + "react-native-reanimated": "4.5.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "~4.26.0", "react-native-svg": "15.15.4", "react-native-web": "~0.21.0", "react-native-webrtc": "^124.0.8", - "react-native-worklets": "0.10.0" + "react-native-worklets": "0.10.1" }, "devDependencies": { - "@react-native/jest-preset": "^0.86.0", + "@react-native/jest-preset": "0.86.2", "@testing-library/react-native": "^14.0.1", "@types/jest": "^29.5.14", "@types/react": "~19.2.2", "eslint": "^9.0.0", "eslint-config-expo": "~57.0.1", "jest": "^29.7.0", - "jest-expo": "^57.0.2", + "jest-expo": "~57.0.4", "test-renderer": "^1.2.0", "typescript": "~6.0.3" } @@ -1198,9 +1200,9 @@ } }, "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.7.tgz", - "integrity": "sha512-rNNFV0DBAJp988xW2DOntfDoYn1eR8GGF5AT5vYc+rjyfaQkM242c9tZUHHPe7KYaiJizXPWhQTzzdbXySyhBw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.8.tgz", + "integrity": "sha512-0UpIXPtdDtMXfnV2OJAVMLpj3H/92vmkA6lpSRakmycJvj3VUy6Xs1dM8tXRugupykr5WB+LpiVl0J8LMVg2mg==", "license": "MIT", "peer": true, "dependencies": { @@ -1640,9 +1642,9 @@ } }, "node_modules/@expo-google-fonts/material-symbols": { - "version": "0.4.42", - "resolved": "https://registry.npmjs.org/@expo-google-fonts/material-symbols/-/material-symbols-0.4.42.tgz", - "integrity": "sha512-KZmHZRcthJ3KFZZlpzHjopA9guZgWR9fb3uVZlTR0BNlvG2pw1bnYBCpkze2PB0vRllwGhAM7lWXsfmcWCbXYg==", + "version": "0.4.43", + "resolved": "https://registry.npmjs.org/@expo-google-fonts/material-symbols/-/material-symbols-0.4.43.tgz", + "integrity": "sha512-jJzgVcbkJnl48X5iBFNaL5CQHwoBElJapl8unc2Ri8DnyuBR+gRiuAD4gu3ESZjjo3sknIoYIwloOsWzKbLknQ==", "license": "MIT AND Apache-2.0" }, "node_modules/@expo/code-signing-certificates": { @@ -1655,12 +1657,12 @@ } }, "node_modules/@expo/config": { - "version": "57.0.6", - "resolved": "https://registry.npmjs.org/@expo/config/-/config-57.0.6.tgz", - "integrity": "sha512-VpMJpB/De/fb9bBFVVBiK6Ntg9lt0kAleLH9hcZz85CYRUQ3jVFVA8rNC5f8y4cp2+FiiPNFp62+kEOFI6pDiw==", + "version": "57.0.7", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-57.0.7.tgz", + "integrity": "sha512-4A+V8x5OmQqNm76l84S+RrB6kVoeFrvcm/Xn/6d+ELPF/HeucDheAFchdYxYNy+NEvWzuNlI/oCvrftIeK+dbQ==", "license": "MIT", "dependencies": { - "@expo/config-plugins": "~57.0.6", + "@expo/config-plugins": "~57.0.7", "@expo/config-types": "^57.0.2", "@expo/json-file": "^11.0.1", "@expo/require-utils": "^57.0.4", @@ -1673,9 +1675,9 @@ } }, "node_modules/@expo/config-plugins": { - "version": "57.0.6", - "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-57.0.6.tgz", - "integrity": "sha512-7CmKrS5Rnu8aSZyNlxH2qzA7Ls1HEa4EQvEVOAkHDKPr1e4Cg/nz7I7dUl09QDTVjMvkKYDH4Th0DuAsgqASaw==", + "version": "57.0.7", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-57.0.7.tgz", + "integrity": "sha512-jvXMiNuH8W7fmU9yCk4/jVwDX2G/5rWUg5PZ22mriccEeQVS9HJjQiUHivMaK6MxEG5L9f0RPScxe/nQfnpQvg==", "license": "MIT", "dependencies": { "@expo/config-types": "^57.0.2", @@ -1771,9 +1773,9 @@ "license": "MIT" }, "node_modules/@expo/fingerprint": { - "version": "0.20.6", - "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.20.6.tgz", - "integrity": "sha512-cmC/6BOPRbdKr77Mgjwszb8aM0hY2RKBpMRCmjSdn9zIcn2FGor/ic4fHVr46cQFa1G6RDGg1GyAjRw3US4CCQ==", + "version": "0.20.7", + "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.20.7.tgz", + "integrity": "sha512-tYyZD4XZSn1C30pr9IvjN/BjAqpf6r9e1NL09lPvheO1DMLByOVdmMHFLSwW8Pu6veVgzue7GDWmq9P8mc8GMg==", "license": "MIT", "dependencies": { "@expo/env": "^2.4.2", @@ -1808,12 +1810,12 @@ } }, "node_modules/@expo/inline-modules": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@expo/inline-modules/-/inline-modules-0.1.3.tgz", - "integrity": "sha512-eHSxWYfgq65mP3Qz8PclVjUkSrDIlGl3va9U7PMcTpGItOvee/i0ZzGinH5A25oARR5ouD64eESBKwtT/CwdHg==", + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@expo/inline-modules/-/inline-modules-0.1.5.tgz", + "integrity": "sha512-LC+kWeIwnvsGIvDaFBd8uzleWzWZiZTCG7CmtfxBLjW/Gify596X67ZqTi76iHzm5KToAfwwX0nppAKPDA9vQw==", "license": "MIT", "dependencies": { - "@expo/config-plugins": "~57.0.5" + "@expo/config-plugins": "~57.0.7" } }, "node_modules/@expo/json-file": { @@ -1827,19 +1829,19 @@ } }, "node_modules/@expo/local-build-cache-provider": { - "version": "57.0.4", - "resolved": "https://registry.npmjs.org/@expo/local-build-cache-provider/-/local-build-cache-provider-57.0.4.tgz", - "integrity": "sha512-B/cI73shkLSYBYuFyh+zCbS+WhqJgawWPW4MPdMiNLJKv9RmV4dv1FGjsidiIiG2k4kYKerBDLK4bLbC7qERQQ==", + "version": "57.0.6", + "resolved": "https://registry.npmjs.org/@expo/local-build-cache-provider/-/local-build-cache-provider-57.0.6.tgz", + "integrity": "sha512-6aFMROb1SzIvrefpwhgS5QGNELU9T2lpK0Hcl6oiZ4/mbKgZRk0WEPauo/dHH6IgDmyK25InCMHF5nj7XY4LWg==", "license": "MIT", "dependencies": { - "@expo/config": "~57.0.5", + "@expo/config": "~57.0.7", "chalk": "^4.1.2" } }, "node_modules/@expo/log-box": { - "version": "57.0.1", - "resolved": "https://registry.npmjs.org/@expo/log-box/-/log-box-57.0.1.tgz", - "integrity": "sha512-fuVNHhOerdRWtpq27gD6JTSVYESsfRu+SMdrNCWxW+gFnusS6dGKfx3lKGBZ4ZkMNiLWn8maBHo39YKzJNXFYQ==", + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/@expo/log-box/-/log-box-57.0.2.tgz", + "integrity": "sha512-ZsFyfIR7YCbQAdVLzuTUmMHofZC7ZS9ywYCJNPlLc78x59cI8GwXFEIVbRjjC0uJERpNtXx/tsNNnkhexXlzMw==", "license": "MIT", "dependencies": { "@expo/dom-webview": "^57.0.1", @@ -1876,15 +1878,15 @@ } }, "node_modules/@expo/metro-config": { - "version": "57.0.7", - "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-57.0.7.tgz", - "integrity": "sha512-bVfEkg4zF1cA62OqAdYXmFOooJ6TB/I+REi7Se6Ct+PbSC+89TwSqWXnYx34L08eIs4z+1ilgbATakTZpgefmQ==", + "version": "57.0.8", + "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-57.0.8.tgz", + "integrity": "sha512-cZOVjbljqRBMCXcloc5k23gsFOhWdiKXhDkp5jU/wY6IXR6G7cYtNOwxKfxI1QLhs0KcXY0gDmZ2UIueoHzY7g==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", - "@expo/config": "~57.0.6", + "@expo/config": "~57.0.7", "@expo/env": "~2.4.2", "@expo/json-file": "~11.0.1", "@expo/metro": "~56.0.0", @@ -1929,19 +1931,19 @@ } }, "node_modules/@expo/metro-runtime": { - "version": "57.0.7", - "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-57.0.7.tgz", - "integrity": "sha512-95UeoN/YsLellvskKsFGN9vKBwNc5k70ysO3skqfL3VusWlYIiYmPY+MpEWNz8A8W2CjLg9AKwZKAGrFj6znQg==", + "version": "57.0.9", + "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-57.0.9.tgz", + "integrity": "sha512-gtly6wOk59Ip7S5NtYSYMmMmz7VyKeNWo/dERF7b1q3Rmek4fBwEV5tTHIXaDTKboAryXdWR9gjSv1DzErP/Dg==", "license": "MIT", "dependencies": { - "@expo/log-box": "^57.0.1", + "@expo/log-box": "^57.0.2", "anser": "^1.4.9", "pretty-format": "^29.7.0", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0" }, "peerDependencies": { - "@expo/log-box": "^57.0.1", + "@expo/log-box": "^57.0.2", "expo": "*", "react": "*", "react-dom": "*", @@ -1991,17 +1993,17 @@ } }, "node_modules/@expo/prebuild-config": { - "version": "57.0.9", - "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-57.0.9.tgz", - "integrity": "sha512-8g7RoXFvO/dxvLzRE/bvphzDL4bfV0w3/4Aj6DfwvgymZ1ULz5gW2x0js94opZRoZvWw4SolH6/74hLlZT3rAA==", + "version": "57.0.11", + "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-57.0.11.tgz", + "integrity": "sha512-GcBX2xQ6l4VrkxmlAXm6lVUeHhnURe0Jh86hNVQqoYYIFflUx4IFmMm9B9bN0mbp/Jb+j+CGjkSPQcIYq9NM/Q==", "license": "MIT", "dependencies": { - "@expo/config": "~57.0.6", - "@expo/config-plugins": "~57.0.6", + "@expo/config": "~57.0.7", + "@expo/config-plugins": "~57.0.7", "@expo/config-types": "^57.0.2", "@expo/image-utils": "^0.11.4", "@expo/json-file": "^11.0.1", - "@react-native/normalize-colors": "0.86.0", + "@react-native/normalize-colors": "0.86.2", "debug": "^4.3.1", "expo-modules-autolinking": "~57.0.9", "resolve-from": "^5.0.0", @@ -2058,9 +2060,9 @@ "license": "MIT" }, "node_modules/@expo/ui": { - "version": "57.0.7", - "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-57.0.7.tgz", - "integrity": "sha512-WqRVabl8VpHf3+YLHVjUy7PMIuXXI6DG88Vgmavro7Nd8Ks13h9sEJH/RLSCaJE2daVnqzEKY1v797BibuY9aw==", + "version": "57.0.10", + "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-57.0.10.tgz", + "integrity": "sha512-cYVo6R6JmJgza2p1jyE1lGfNPWncHJGWRxhTyOi+pLRAAdiwo8Z2LcdaArewEXVUwJTQhczLRxeXGL0i99NUwQ==", "license": "MIT", "dependencies": { "sf-symbols-typescript": "^2.1.0", @@ -3255,31 +3257,31 @@ } }, "node_modules/@react-native/assets-registry": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.86.0.tgz", - "integrity": "sha512-nIaXbm2jX1OTYp0qbviJ3O6KZivoE8z3BnhUQ2LsqfZSWRoOK/n1qsiAr6oALiNKWnXY3j2KPwtYORnZzp8xew==", + "version": "0.86.2", + "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.86.2.tgz", + "integrity": "sha512-vcX/mBjWAVnWofu7KecotquI2unZ/tITwA7OGdq/mdY/zmGXIEvYhfEYyOQij/LRqi9WAL+iizInTBWnxDhK/Q==", "license": "MIT", "engines": { "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } }, "node_modules/@react-native/babel-plugin-codegen": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.86.0.tgz", - "integrity": "sha512-qdsABWNW7uTll90l4Vh03gjeyu3WVDi2CyiiyvYGMRDcoYbjbQi6df3BMAm9lQI2yslZ1T14LlDDAsgTwNxplA==", + "version": "0.86.2", + "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.86.2.tgz", + "integrity": "sha512-NNDZqOlNbH5SzgPks1jFDYH3234Rpa5e/nhZymxhIiBH3NcE3uD+rGj/HWXhH7nHF2ToGK6XbUpqy7nmJPeh+g==", "license": "MIT", "dependencies": { "@babel/traverse": "^7.29.0", - "@react-native/codegen": "0.86.0" + "@react-native/codegen": "0.86.2" }, "engines": { "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } }, "node_modules/@react-native/babel-preset": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.86.0.tgz", - "integrity": "sha512-bYQcWiPySNvF4dns9Ls9gMmwgq66ohvM9Fwc/Kn8r85t66UNHxch3p1QwPiSorDelFauZwJbgo9+ReibTgvpbA==", + "version": "0.86.2", + "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.86.2.tgz", + "integrity": "sha512-4XKEJ6jKW9lXMB1O5o47gBoGgolde1fbX13gLW/erlcn+1ky+MHHo5UjuM3RWGdPJHIvIzekDDumSUHhB9x5iQ==", "license": "MIT", "peer": true, "dependencies": { @@ -3312,7 +3314,7 @@ "@babel/plugin-transform-runtime": "^7.24.7", "@babel/plugin-transform-typescript": "^7.25.2", "@babel/plugin-transform-unicode-regex": "^7.24.7", - "@react-native/babel-plugin-codegen": "0.86.0", + "@react-native/babel-plugin-codegen": "0.86.2", "babel-plugin-syntax-hermes-parser": "0.36.0", "babel-plugin-transform-flow-enums": "^0.0.2", "react-refresh": "^0.14.0" @@ -3325,9 +3327,9 @@ } }, "node_modules/@react-native/codegen": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.86.0.tgz", - "integrity": "sha512-uTs9DBo3+/lUqinsGZK0FKJRBVClrwMXoZToaDxE1Q2SL2e55vs2GwyZfIKzPl5uJnbu4PfFMIp0/mLXLWUMuA==", + "version": "0.86.2", + "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.86.2.tgz", + "integrity": "sha512-xKkudsahUJ1n//55g4fXk5BStVqqmZlz8HQveL45ZxcfDnwvhuYe2GymksQANFsSN+slvrarjrfq8kIxJzbceA==", "license": "MIT", "dependencies": { "@babel/core": "^7.25.2", @@ -3346,12 +3348,12 @@ } }, "node_modules/@react-native/community-cli-plugin": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.86.0.tgz", - "integrity": "sha512-Jv8p1ebEPfTzs8gmrjsdT2XMXFfeAg45Pman+XPLFGaSeGAZkutRFRyX9Cs9aGTSOyIA9YPJ6vDNb1ayTf1FKQ==", + "version": "0.86.2", + "resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.86.2.tgz", + "integrity": "sha512-YHXNKoM6Y/HjREySZ5arET2xgiHgg67r1MdwJB//MPJAJ0Xc5g0u6UHxY9VzsHO3Y07dre6s0BinYwjt1SEWvQ==", "license": "MIT", "dependencies": { - "@react-native/dev-middleware": "0.86.0", + "@react-native/dev-middleware": "0.86.2", "debug": "^4.4.0", "invariant": "^2.2.4", "metro": "^0.84.3", @@ -3364,7 +3366,7 @@ }, "peerDependencies": { "@react-native-community/cli": "*", - "@react-native/metro-config": "0.86.0" + "@react-native/metro-config": "0.86.2" }, "peerDependenciesMeta": { "@react-native-community/cli": { @@ -3376,18 +3378,18 @@ } }, "node_modules/@react-native/debugger-frontend": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.86.0.tgz", - "integrity": "sha512-7Mb3nDfyJeys+ELF75Ageu7VKERlnIMoO+aNPoXqTXvz+b41L6l2CqMyLpDHxkBSlenij6gEepPNgaIyWHbJZw==", + "version": "0.86.2", + "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.86.2.tgz", + "integrity": "sha512-KGS1aV5F6cIqpnoIUhLBXyVzy1oAj8jBFGau6vX4Vy0HXRJN7p+68RU7x6NuyraHvQcR14ccMGT5TkFuNjQ4gA==", "license": "BSD-3-Clause", "engines": { "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } }, "node_modules/@react-native/debugger-shell": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@react-native/debugger-shell/-/debugger-shell-0.86.0.tgz", - "integrity": "sha512-Y0zEkZzLz8ou6o/VLml1A31X/rMgc6DRjwxwzPMa94qRTMY070WeBCNTITQo4kKTBAUgbxh07oXPQqp0Tpja8w==", + "version": "0.86.2", + "resolved": "https://registry.npmjs.org/@react-native/debugger-shell/-/debugger-shell-0.86.2.tgz", + "integrity": "sha512-/TaVJ2+gGajZPJGrFaObUQmHmlaxAlfmOPZicl6pNKDUjzSgFMpcLkdTOExvb+USYTVdGX1XwxXyvjQdUO2bvg==", "license": "MIT", "dependencies": { "cross-spawn": "^7.0.6", @@ -3399,14 +3401,14 @@ } }, "node_modules/@react-native/dev-middleware": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.86.0.tgz", - "integrity": "sha512-20pTO6yTybmvXvro520H6C7jydIQnLKOl5qFtVEcHSdFrY63r3OGei+Rx9bILgSRmH6jgnfEcijcMx7pwWuQtw==", + "version": "0.86.2", + "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.86.2.tgz", + "integrity": "sha512-B7L0vKvg+IcEElT7Vpqh1xj5yJAqWUegjbP+bQRaorJMAYnv11GkliTnZV2AdTDfZQJWgOEx8i8LGkHkUg7bnA==", "license": "MIT", "dependencies": { "@isaacs/ttlcache": "^1.4.1", - "@react-native/debugger-frontend": "0.86.0", - "@react-native/debugger-shell": "0.86.0", + "@react-native/debugger-frontend": "0.86.2", + "@react-native/debugger-shell": "0.86.2", "chrome-launcher": "^0.15.2", "chromium-edge-launcher": "^0.3.0", "connect": "^3.6.5", @@ -3422,23 +3424,23 @@ } }, "node_modules/@react-native/gradle-plugin": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.86.0.tgz", - "integrity": "sha512-a1RcfaEDqWExCGfCwadIxt4l8FvKYgFqeMf2uzeKyAOnb+vTGNIeCvifFL2MqvgaeYxlER437HbMIajGcuJ1pQ==", + "version": "0.86.2", + "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.86.2.tgz", + "integrity": "sha512-2F6x14NcHMpVmfTTFKfMkpV5dZedZrLiv6PE+c3vgnesV2bjleUBydr4U+NI8VkI7OwW71L0A5qQ76I9LCrfoQ==", "license": "MIT", "engines": { "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } }, "node_modules/@react-native/jest-preset": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@react-native/jest-preset/-/jest-preset-0.86.0.tgz", - "integrity": "sha512-KA+xpIP3DvJy7PQJ9c6ZdEKkOPChl+Rk/rV2MhQACEAzfhWU84407KZQv4ccyO3B4caD0gPrFjE96a4P993nsQ==", + "version": "0.86.2", + "resolved": "https://registry.npmjs.org/@react-native/jest-preset/-/jest-preset-0.86.2.tgz", + "integrity": "sha512-wneoqwKWdv6wIcWotVgp6NMlMWcJDjxDnp7fVYym2Pn6JLgAKgkbmuHGmxW0cLCGoa9wtcO680uciv+Kzx0G7w==", "devOptional": true, "license": "MIT", "dependencies": { "@jest/create-cache-key-function": "^29.7.0", - "@react-native/js-polyfills": "0.86.0", + "@react-native/js-polyfills": "0.86.2", "babel-jest": "^29.7.0", "jest-environment-node": "^29.7.0", "regenerator-runtime": "^0.13.2" @@ -3451,23 +3453,23 @@ } }, "node_modules/@react-native/js-polyfills": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.86.0.tgz", - "integrity": "sha512-zYy/Cjd1VTnZ2iCNaG9bDF9C3l2ntESiPRscjIlI5FKugu6aeTwsDSv1aI8Bc4Kp3vEdoVg+UQhLAhE4svREaQ==", + "version": "0.86.2", + "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.86.2.tgz", + "integrity": "sha512-bIwNcGBaQ74shB5z1mRkxOpjikimuwsnOCEkZSzL67Z1FTyK1ObpENfyd2QvcvVW9Cjl+tHuw9ynpBnb2jPoJQ==", "license": "MIT", "engines": { "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } }, "node_modules/@react-native/metro-babel-transformer": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@react-native/metro-babel-transformer/-/metro-babel-transformer-0.86.0.tgz", - "integrity": "sha512-SjKej3E5qIahqo/G+rSOrmJUQM44RyKtWtO+VfmKAAMoJWkBFomM22hTLKCIS5cdbIAJ9COAmU+KAi2wVSO0wQ==", + "version": "0.86.2", + "resolved": "https://registry.npmjs.org/@react-native/metro-babel-transformer/-/metro-babel-transformer-0.86.2.tgz", + "integrity": "sha512-mX1wgLErdb2hDgXJr9zM9SWLe+ZteZTFTwRWGOQ53yEJwc9DVSnxdTlAtVdMeOj2ntycKh0R8jYdat2Am43cwQ==", "license": "MIT", "peer": true, "dependencies": { "@babel/core": "^7.25.2", - "@react-native/babel-preset": "0.86.0", + "@react-native/babel-preset": "0.86.2", "hermes-parser": "0.36.0", "nullthrows": "^1.1.1" }, @@ -3479,14 +3481,14 @@ } }, "node_modules/@react-native/metro-config": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@react-native/metro-config/-/metro-config-0.86.0.tgz", - "integrity": "sha512-7v+xbTeEci9ZcQ/Z1OqI4RXcqN69wSMDYL5BAMvOReZ7U04+aDQ0/SQhClYPn6x2/RxM4WzMKSAuNyLKqvYVtw==", + "version": "0.86.2", + "resolved": "https://registry.npmjs.org/@react-native/metro-config/-/metro-config-0.86.2.tgz", + "integrity": "sha512-hJno256j+MS0b3JD1aD3ouTGZVacKNVBuXL2atMQQ8BZ060vl1ptnZ83y569aDW+/rgFSOcqn6ydKeSz4uUKQQ==", "license": "MIT", "peer": true, "dependencies": { - "@react-native/js-polyfills": "0.86.0", - "@react-native/metro-babel-transformer": "0.86.0", + "@react-native/js-polyfills": "0.86.2", + "@react-native/metro-babel-transformer": "0.86.2", "metro-config": "^0.84.3", "metro-runtime": "^0.84.3" }, @@ -3495,15 +3497,15 @@ } }, "node_modules/@react-native/normalize-colors": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.86.0.tgz", - "integrity": "sha512-kG0wfCGghUKlfxkJyyHCDVutWVYWK7/DG58ojA/4v9EfulgF+osuSQmlbNb3rcKX58qutm7JcldSeVLgGFha9g==", + "version": "0.86.2", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.86.2.tgz", + "integrity": "sha512-EzPFc9Y6lzYOWeso2almwXI7f8+qReHxWvT+algsOczb2UhWXIWXDoSvkdwoSfiwwmGt/ijJgKJoeHlzPkLwRg==", "license": "MIT" }, "node_modules/@react-native/virtualized-lists": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.86.0.tgz", - "integrity": "sha512-4/ZLXdf/OSpPDVO0AsQ1SJdRIzt5t9BNQ46QwGgxvX7/cirYR5k8KXctNGGgW8lQo2gZChEfY2zFCZg9nM/jiw==", + "version": "0.86.2", + "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.86.2.tgz", + "integrity": "sha512-uO0J72gh3EvE+1/GHRk18QRyBDTRHRB0AraAfojsRjbT7VMuJwKrZYaKGshavoaEud6aw00ZB9/8mTMIKjjcAw==", "license": "MIT", "dependencies": { "invariant": "^2.2.4", @@ -3515,7 +3517,7 @@ "peerDependencies": { "@types/react": "^19.2.0", "react": "*", - "react-native": "0.86.0" + "react-native": "0.86.2" }, "peerDependenciesMeta": { "@types/react": { @@ -4620,9 +4622,9 @@ } }, "node_modules/agent-cli-detector": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/agent-cli-detector/-/agent-cli-detector-0.1.4.tgz", - "integrity": "sha512-qPgevFvpaQoBaRJVKzr8R7h1WPvV3DtbgRIQlne4le66KBzXx5hNBwo/+NTw67LgkKBlhCzksrdautpUdlls0Q==", + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/agent-cli-detector/-/agent-cli-detector-0.1.5.tgz", + "integrity": "sha512-6xvLw0EGPuxoYGZeqyMV4AK+WoZ31jsqb7a5pln1BTf6oDFsrgvfCJT7E7y9oAqifJZhJ3fZ0J36H7o6JzrB0Q==", "license": "MIT", "bin": { "agent-cli-detector": "dist/cli.js" @@ -5128,9 +5130,9 @@ } }, "node_modules/babel-preset-expo": { - "version": "57.0.4", - "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-57.0.4.tgz", - "integrity": "sha512-EkFcNoE23HVzQT6ZNXs/adN8+G7rqEAF6tQn6LpRPYa1YY7wmX5GxhJF0kaYMNtBndNrMTN4+0rYE17VG13KFg==", + "version": "57.0.6", + "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-57.0.6.tgz", + "integrity": "sha512-ASyy0iP7yPQtq2QaMEnZG4O7YQ/x4sTMguk7PZcow2OHFnWsBpX6v+UCmh/uwCzGfD1q93jDQEuACWwWx4kSrw==", "license": "MIT", "dependencies": { "@babel/generator": "^7.20.5", @@ -5169,7 +5171,7 @@ "@babel/plugin-transform-typescript": "^7.25.2", "@babel/plugin-transform-unicode-regex": "^7.24.7", "@babel/preset-typescript": "^7.23.0", - "@react-native/babel-plugin-codegen": "0.86.0", + "@react-native/babel-plugin-codegen": "0.86.2", "babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-native-web": "~0.21.0", "babel-plugin-syntax-hermes-parser": "^0.36.0", @@ -5179,7 +5181,7 @@ "peerDependencies": { "@babel/runtime": "^7.20.0", "expo": "*", - "expo-widgets": "^57.0.6", + "expo-widgets": "^57.0.8", "react-refresh": ">=0.14.0 <1.0.0" }, "peerDependenciesMeta": { @@ -7371,31 +7373,31 @@ } }, "node_modules/expo": { - "version": "57.0.8", - "resolved": "https://registry.npmjs.org/expo/-/expo-57.0.8.tgz", - "integrity": "sha512-0IxxoPZbT54IH4fHL5NihkvED9HBVQx3uNdPvyv8pFUHWJ81RdFjL0aJys1IB7hbo09KTz4xW4I2aWVOXKAcJQ==", + "version": "57.0.12", + "resolved": "https://registry.npmjs.org/expo/-/expo-57.0.12.tgz", + "integrity": "sha512-sVgXaMjh5uapBvBkik3QibxbKI2g1zNtNeHntjRfixWAI3tlQZbaK4ACdInY4+jUBVymkf8u9BXJUaSes9jdtA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.0", - "@expo/cli": "^57.0.10", - "@expo/config": "~57.0.6", - "@expo/config-plugins": "~57.0.6", + "@expo/cli": "^57.0.14", + "@expo/config": "~57.0.7", + "@expo/config-plugins": "~57.0.7", "@expo/devtools": "~57.0.1", "@expo/dom-webview": "~57.0.1", - "@expo/fingerprint": "^0.20.6", - "@expo/local-build-cache-provider": "^57.0.4", - "@expo/log-box": "^57.0.1", + "@expo/fingerprint": "^0.20.7", + "@expo/local-build-cache-provider": "^57.0.6", + "@expo/log-box": "^57.0.2", "@expo/metro": "~56.0.0", - "@expo/metro-config": "~57.0.7", + "@expo/metro-config": "~57.0.8", "@ungap/structured-clone": "^1.3.0", - "babel-preset-expo": "~57.0.4", - "expo-asset": "~57.0.7", - "expo-constants": "~57.0.7", - "expo-file-system": "~57.0.1", + "babel-preset-expo": "~57.0.6", + "expo-asset": "~57.0.10", + "expo-constants": "~57.0.10", + "expo-file-system": "~57.0.2", "expo-font": "~57.0.1", "expo-keep-awake": "~57.0.1", "expo-modules-autolinking": "~57.0.9", - "expo-modules-core": "~57.0.7", + "expo-modules-core": "~57.0.10", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.2" @@ -7433,13 +7435,13 @@ } }, "node_modules/expo-asset": { - "version": "57.0.7", - "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-57.0.7.tgz", - "integrity": "sha512-TA6MRQq1HL0N6KGvNMzL6djdH5tGJ/eHPIhML+6bVu52mnXldmup2swdvP37zDnvoMUUXRVGsvwVHFmvhCbW6Q==", + "version": "57.0.10", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-57.0.10.tgz", + "integrity": "sha512-32QpNkWlb8ftxq3ClAriwFXcZlpzuP7Qx4z3Gc9vhbAm1QZzGsCN+PwYt3fN8W46HBw5qaI6DSXcAyBlYzeVqA==", "license": "MIT", "dependencies": { "@expo/image-utils": "^0.11.4", - "expo-constants": "~57.0.7" + "expo-constants": "~57.0.10" }, "peerDependencies": { "expo": "*", @@ -7460,9 +7462,9 @@ } }, "node_modules/expo-constants": { - "version": "57.0.7", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.7.tgz", - "integrity": "sha512-ShDwaKnh3UieCQ/dG0kO8PuicTTatn3WDGmXbq/fukyzPXWhdUxf37DINVDQS6D3DDG7nfqItij+QvnDHSQhTg==", + "version": "57.0.10", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.10.tgz", + "integrity": "sha512-GCDXYEsloBfouMdT3BzoGhAkcLnYxEFNLQoSbNKIvIrD9FY5MmSeWuvRSveJdOiuydwQY2iH6hy020vTaQflCQ==", "license": "MIT", "dependencies": { "@expo/env": "~2.4.2" @@ -7485,9 +7487,9 @@ } }, "node_modules/expo-file-system": { - "version": "57.0.1", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-57.0.1.tgz", - "integrity": "sha512-w7/ERvQFrGP2apTO9lDtZ+O6JQIhfakL7+Xqzh+rfMO9B4LB4qwrz+YvLgir8KFRVX64JHBnRuYBVLY1oQZcqw==", + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-57.0.2.tgz", + "integrity": "sha512-bPgzpaOJ3NWHZxV++Osj/1QoFzFe/QOo1jBF4EODJdiZgrrxyi2dv+7PLhKuut5QqPBuihUOITRh3s5jhRtA5A==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -7520,9 +7522,9 @@ } }, "node_modules/expo-image": { - "version": "57.0.1", - "resolved": "https://registry.npmjs.org/expo-image/-/expo-image-57.0.1.tgz", - "integrity": "sha512-EP0lisd2bUqtErry4weRcMW9bLMxtKsht/MLLK3/3do5u4ZMiJbWkY5zfYV+WYmeGab7x9G0sjbeFeEagYGjMw==", + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-image/-/expo-image-57.0.2.tgz", + "integrity": "sha512-SAHDJiQ/Sf8JJ6NJ5/RbSewo8HtQtIGn4bDEgcvipwIw5lPURP0vXPzIOIrZ/ZroZ0abPgwTaWmkspoEO8Sxcw==", "license": "MIT", "dependencies": { "sf-symbols-typescript": "^2.2.0" @@ -7539,6 +7541,27 @@ } } }, + "node_modules/expo-image-loader": { + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-57.0.1.tgz", + "integrity": "sha512-uhrZKLT/cTl2mXyR28kPpVkS5O+PK9N1QA/07IFM4f5T4g0lTW1JHT3NEWwEEsGFldPmVX4j7LwUVVZxE+woug==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-image-picker": { + "version": "57.0.9", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-57.0.9.tgz", + "integrity": "sha512-tXs3N/f1yDON31KbOsI6DZuLZU8rgxyAo/r0JJm0DG/KPv8sZBp+enVwbgc0SkJ++RAaAuUReUaErUWgfb+6IQ==", + "license": "MIT", + "dependencies": { + "expo-image-loader": "~57.0.1" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-keep-awake": { "version": "57.0.1", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-57.0.1.tgz", @@ -7561,12 +7584,12 @@ } }, "node_modules/expo-linking": { - "version": "57.0.4", - "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-57.0.4.tgz", - "integrity": "sha512-e1alfHNJdywIfJkCuKMc6M3hBfAGPd2gKMeF/6V7qwFWzHCS2mTBqU+KaO4FLpltA5Nt6CYEx6zmUlGfUF+8lA==", + "version": "57.0.5", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-57.0.5.tgz", + "integrity": "sha512-SmJI3wr0EVfeKPGf+Qgr9gUbrXk8mM0ATqYLvAX/EAzawDjohPzMJ5pTt7TYMX0Wknj4XCtyCQrGhnERMXT6cQ==", "license": "MIT", "dependencies": { - "expo-constants": "~57.0.7", + "expo-constants": "~57.0.9", "invariant": "^2.2.4" }, "peerDependencies": { @@ -7590,9 +7613,9 @@ } }, "node_modules/expo-modules-core": { - "version": "57.0.7", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.7.tgz", - "integrity": "sha512-5HrbCfgYmLs0a5dzfM4GmRGelVTPIg+eYp0vmSbWnKRTOPj5DyVOfg1rHGEsuNKp07QwDxfLJfQVp8CNbuvxMQ==", + "version": "57.0.10", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.10.tgz", + "integrity": "sha512-aRi3G8OctoyZl8x9CLTVpbPljClfKm2eqKRgBzhcGsrH3gS1t5Y2ye7+PIZK4XK2VVP5iGqygN2b1vtqRo3xVQ==", "license": "MIT", "dependencies": { "@expo/expo-modules-macros-plugin": "0.6.1", @@ -7620,15 +7643,15 @@ } }, "node_modules/expo-router": { - "version": "57.0.8", - "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.8.tgz", - "integrity": "sha512-xAyTnZl597G9/r17GOuyTy6VlhjYCVmgzgmP00bhZ9b+VstPl3tTrOOhSFagVpeln47nKp7x7vgkANNheCv4eQ==", + "version": "57.0.12", + "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.12.tgz", + "integrity": "sha512-vA+RUSzMwHmWa/pQpoYqJoTbhIQaX/OzvMvOlWVBeMflF1RC40zCzqLiXMKimWfEUh/G6ITnz31EtpNKUghxQw==", "license": "MIT", "dependencies": { - "@expo/log-box": "^57.0.1", - "@expo/metro-runtime": "^57.0.7", + "@expo/log-box": "^57.0.2", + "@expo/metro-runtime": "^57.0.9", "@expo/schema-utils": "^57.0.2", - "@expo/ui": "^57.0.7", + "@expo/ui": "^57.0.10", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-native-masked-view/masked-view": "^0.3.2", @@ -7639,8 +7662,8 @@ "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", "expo-glass-effect": "^57.0.1", - "expo-server": "^57.0.1", - "expo-symbols": "^57.0.1", + "expo-server": "^57.0.2", + "expo-symbols": "^57.0.2", "fast-deep-equal": "^3.1.3", "invariant": "^2.2.4", "nanoid": "^3.3.8", @@ -7656,12 +7679,12 @@ "vaul": "^1.1.2" }, "peerDependencies": { - "@expo/log-box": "^57.0.1", - "@expo/metro-runtime": "^57.0.7", + "@expo/log-box": "^57.0.2", + "@expo/metro-runtime": "^57.0.9", "@testing-library/react-native": ">= 13.2.0", "expo": "*", - "expo-constants": "^57.0.7", - "expo-linking": "^57.0.4", + "expo-constants": "^57.0.10", + "expo-linking": "^57.0.5", "react": "*", "react-dom": "*", "react-native": "*", @@ -7703,21 +7726,21 @@ } }, "node_modules/expo-server": { - "version": "57.0.1", - "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-57.0.1.tgz", - "integrity": "sha512-sBfVDH6dmKVHZxqUxbfkzS00PZELMZt1IpnHKxcOTMZtR/t7CtRAFrbXcisG+EyzeqHSVDacZT+1tbYfZt5D8w==", + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-57.0.2.tgz", + "integrity": "sha512-GzfiSHC19xU7I0Dq4O/7DOtWdmc14vynpxBb9nWDRvsF+7RjoSIdkVePFhx4Qm6ILFNbo6KxFGu95QnDdKxUdw==", "license": "MIT", "engines": { "node": ">=20.16.0" } }, "node_modules/expo-splash-screen": { - "version": "57.0.5", - "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-57.0.5.tgz", - "integrity": "sha512-ZN0LDXlhHRNFjXTYZDojXk8IfaoUIu7qa3hhoBTXgyj1UB/iewGlH6+M3Nvhun2lY2d/+xhwqMhv0hIRoBo09Q==", + "version": "57.0.6", + "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-57.0.6.tgz", + "integrity": "sha512-FY0E7hMyXVAsNh18yGzIjdooPXdjafalEb6mk2EcqNPjXspHuu+pVEhUscEunw4wF8F/w6MOpT7UfOwHVWez9g==", "license": "MIT", "dependencies": { - "@expo/config-plugins": "~57.0.6", + "@expo/config-plugins": "~57.0.7", "@expo/image-utils": "^0.11.4", "xml2js": "0.6.0" }, @@ -7737,9 +7760,9 @@ } }, "node_modules/expo-symbols": { - "version": "57.0.1", - "resolved": "https://registry.npmjs.org/expo-symbols/-/expo-symbols-57.0.1.tgz", - "integrity": "sha512-8Zf+a83OywV0vf1NUtSKpNqKcULmO0GTI+zfFnGYl7SLDH9FjL5RcEZoy6CHvCgq2KDrQF21pl3r7Tb4ItPscw==", + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-symbols/-/expo-symbols-57.0.2.tgz", + "integrity": "sha512-qZ0iqOflm5lZGwRsQ5Y8sDksw3GAUKwHSX1bJBoocXf7gu14vafIXXYWte+JT9VfXAUGyKodJhLHP/GoOrcNWg==", "license": "MIT", "dependencies": { "@expo-google-fonts/material-symbols": "^0.4.1", @@ -7753,12 +7776,12 @@ } }, "node_modules/expo-system-ui": { - "version": "57.0.1", - "resolved": "https://registry.npmjs.org/expo-system-ui/-/expo-system-ui-57.0.1.tgz", - "integrity": "sha512-r8a6Jk2suL0vI7Uq4iKJab5Eesk8dkB56Q6HksVNkzuAExV0axoikQwZv8aAyHGbu2VHp0artB0N1/PQDLSgBg==", + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-system-ui/-/expo-system-ui-57.0.2.tgz", + "integrity": "sha512-zABCRqFSioDBAo/RtmS0dQiGgDtDPZUVk01Y3Ti4ducobNM4HTM2sNAtq+YPpEUhaVW3hccPVsEUH4LK/ADVhA==", "license": "MIT", "dependencies": { - "@react-native/normalize-colors": "0.86.0", + "@react-native/normalize-colors": "0.86.2", "debug": "^4.3.2" }, "peerDependencies": { @@ -7783,34 +7806,34 @@ } }, "node_modules/expo/node_modules/@expo/cli": { - "version": "57.0.10", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.10.tgz", - "integrity": "sha512-mP+B9ZrTnRE94bxPM/kCVKPyuVuOTRvvsqbXVxdXtrAfOfF94YIZLGUtw/151cGFtdUPbsBsJ9gW02LhU+QMZA==", + "version": "57.0.14", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.14.tgz", + "integrity": "sha512-yu3sie3cDPDXBTUMvivqguW+lT8jnLIF8Q6e75MRaq7BoAzihor0KrJwtXAtC4ep5eOYYC1otEvtOuctG1xf8Q==", "license": "MIT", "dependencies": { "@expo/code-signing-certificates": "^0.0.6", - "@expo/config": "~57.0.6", - "@expo/config-plugins": "~57.0.6", + "@expo/config": "~57.0.7", + "@expo/config-plugins": "~57.0.7", "@expo/devcert": "^1.2.1", "@expo/env": "~2.4.2", "@expo/image-utils": "^0.11.4", - "@expo/inline-modules": "^0.1.3", + "@expo/inline-modules": "^0.1.5", "@expo/json-file": "^11.0.1", - "@expo/log-box": "^57.0.1", + "@expo/log-box": "^57.0.2", "@expo/metro": "~56.0.0", - "@expo/metro-config": "~57.0.7", + "@expo/metro-config": "~57.0.8", "@expo/metro-file-map": "^57.0.1", "@expo/osascript": "^2.7.1", "@expo/package-manager": "^1.13.1", "@expo/plist": "^0.8.1", - "@expo/prebuild-config": "^57.0.9", + "@expo/prebuild-config": "^57.0.11", "@expo/require-utils": "^57.0.4", - "@expo/router-server": "^57.0.4", + "@expo/router-server": "^57.0.5", "@expo/schema-utils": "^57.0.2", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^2.0.0", "@expo/xcpretty": "^4.4.4", - "@react-native/dev-middleware": "0.86.0", + "@react-native/dev-middleware": "0.86.2", "accepts": "^1.3.8", "agent-cli-detector": "^0.1.2", "arg": "^5.0.2", @@ -7822,7 +7845,7 @@ "connect": "^3.7.0", "debug": "^4.3.4", "dnssd-advertise": "^1.1.4", - "expo-server": "^57.0.1", + "expo-server": "^57.0.2", "fetch-nodeshim": "^0.4.10", "getenv": "^2.0.0", "glob": "^13.0.0", @@ -7865,17 +7888,17 @@ } }, "node_modules/expo/node_modules/@expo/cli/node_modules/@expo/router-server": { - "version": "57.0.4", - "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-57.0.4.tgz", - "integrity": "sha512-ucqCP0hK8nZb9+S8QJYdQxNCkfRClzkKdg2RpWYDKDYgRIHyoRyxEDRgDgvbhH+q78yfY83abU8OTx8MMOrf1g==", + "version": "57.0.5", + "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-57.0.5.tgz", + "integrity": "sha512-vke39l0bo3H2q9JB/KXpAJ7HpscdTG3Mktbxanc8yn3riWzzSsnv0uxwZGZCrZrnDQzFxlLTXgbZrGkU26ng1w==", "license": "MIT", "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { - "@expo/metro-runtime": "^57.0.7", + "@expo/metro-runtime": "^57.0.8", "expo": "*", - "expo-constants": "^57.0.7", + "expo-constants": "^57.0.9", "expo-font": "^57.0.1", "expo-router": "*", "expo-server": "^57.0.1", @@ -7966,9 +7989,9 @@ } }, "node_modules/expo/node_modules/ws": { - "version": "8.21.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", - "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -8305,7 +8328,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -8670,9 +8692,9 @@ } }, "node_modules/hermes-compiler": { - "version": "250829098.0.14", - "resolved": "https://registry.npmjs.org/hermes-compiler/-/hermes-compiler-250829098.0.14.tgz", - "integrity": "sha512-5meXwsZxgiqFaJjNzwjzI9IyUkuGGBisu+z9BvQWmGVpjH6nz11hgqkyxe4dl8UAdyIV4lTbz91+Dlnjz0VxqA==", + "version": "250829098.0.16", + "resolved": "https://registry.npmjs.org/hermes-compiler/-/hermes-compiler-250829098.0.16.tgz", + "integrity": "sha512-xsgzk+mUyvt9t1nUbF8USBlYxajTUtPJhVZ86q85s/SEoMKCF+52YZcudb0ENSnV3T3lV9mgB3s6R7+pH90zgw==", "license": "MIT" }, "node_modules/hermes-estree": { @@ -9999,9 +10021,9 @@ } }, "node_modules/jest-expo": { - "version": "57.0.2", - "resolved": "https://registry.npmjs.org/jest-expo/-/jest-expo-57.0.2.tgz", - "integrity": "sha512-xoKiYyu8c0fdBsFMkeFnxoTZ/0g4rLldA9isVb7VJSGBGesmhkVor7YkftkHqQ5rWiZ99IY+/uIrzTgb1nC/UA==", + "version": "57.0.4", + "resolved": "https://registry.npmjs.org/jest-expo/-/jest-expo-57.0.4.tgz", + "integrity": "sha512-EpTc8zizYWepKXHdHYuYBdEP0mZLjZfB1ldb2dyLKHug0HYFGuBnQ3Qp3gmBWCj3rSRaJYP90Lq/qPozzx2Khg==", "dev": true, "license": "MIT", "dependencies": { @@ -10022,7 +10044,7 @@ "jest": "bin/jest.js" }, "peerDependencies": { - "@react-native/jest-preset": "^0.86.0", + "@react-native/jest-preset": "^0.86.2", "expo": "*", "react-native": "*", "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" @@ -11157,6 +11179,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -11177,6 +11202,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -11197,6 +11225,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -11217,6 +11248,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -11935,15 +11969,15 @@ "license": "MIT" }, "node_modules/multitars": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/multitars/-/multitars-1.0.0.tgz", - "integrity": "sha512-H/J4fMLedtudftaYMOg7ajzLYgT3/rwbWVJbqr/iUgB8DQztn38ys5HOqI1CzSxx8QhXXwOOnnBvd4v3jG5+Mg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/multitars/-/multitars-1.0.2.tgz", + "integrity": "sha512-6GwVw5eLi9sThdtlS4PKwC7yRLaf45pYhIEzKBHdKxi+YOXGKFX8acIniH+Uh/+k9mS2lQOupTccjoe5r0/1IQ==", "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -12797,9 +12831,9 @@ } }, "node_modules/postcss": { - "version": "8.5.23", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", - "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "funding": [ { "type": "opencollective", @@ -12816,7 +12850,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -13086,18 +13120,18 @@ "license": "MIT" }, "node_modules/react-native": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.86.0.tgz", - "integrity": "sha512-17ALh/dd6AO4pgOVmOO5Axll5PbErEo3XFyLokyzW6usyi+OShIEPwUW26wLPlhVifgSOIfECCH0WN+0IqtJ1w==", - "license": "MIT", - "dependencies": { - "@react-native/assets-registry": "0.86.0", - "@react-native/codegen": "0.86.0", - "@react-native/community-cli-plugin": "0.86.0", - "@react-native/gradle-plugin": "0.86.0", - "@react-native/js-polyfills": "0.86.0", - "@react-native/normalize-colors": "0.86.0", - "@react-native/virtualized-lists": "0.86.0", + "version": "0.86.2", + "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.86.2.tgz", + "integrity": "sha512-zbJXGZpwfZGA79Z9ob6Atvfx4nAQL8yJBa35s58E4Oo+khPykfQP2sTeumkKbjwajFYfVayg8pj7Il9nIfTk7A==", + "license": "MIT", + "dependencies": { + "@react-native/assets-registry": "0.86.2", + "@react-native/codegen": "0.86.2", + "@react-native/community-cli-plugin": "0.86.2", + "@react-native/gradle-plugin": "0.86.2", + "@react-native/js-polyfills": "0.86.2", + "@react-native/normalize-colors": "0.86.2", + "@react-native/virtualized-lists": "0.86.2", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", @@ -13105,7 +13139,7 @@ "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", - "hermes-compiler": "250829098.0.14", + "hermes-compiler": "250829098.0.16", "invariant": "^2.2.4", "memoize-one": "^5.0.0", "metro-runtime": "^0.84.3", @@ -13131,7 +13165,7 @@ "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" }, "peerDependencies": { - "@react-native/jest-preset": "0.86.0", + "@react-native/jest-preset": "0.86.2", "@types/react": "^19.1.1", "react": "^19.2.3" }, @@ -13145,9 +13179,9 @@ } }, "node_modules/react-native-drawer-layout": { - "version": "4.2.9", - "resolved": "https://registry.npmjs.org/react-native-drawer-layout/-/react-native-drawer-layout-4.2.9.tgz", - "integrity": "sha512-ETOxvlhhb4LmuuG3RN7A3qwt9jr9AZ2it+1G2kNE4g2fTyxxay7QQkPm1HfKi/JzmMSWC5+YTepGSgZNpJIDGg==", + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/react-native-drawer-layout/-/react-native-drawer-layout-4.2.10.tgz", + "integrity": "sha512-O6TQdZ5LSm3dqnuR4rX9KPtE+9dVg7jsezEYz1l01rkQTe4fQvYZIoPu2sZFl5X2N70uhRdjnPULySVR3sBUwA==", "license": "MIT", "dependencies": { "color": "^4.2.3", @@ -13187,9 +13221,9 @@ } }, "node_modules/react-native-reanimated": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.5.0.tgz", - "integrity": "sha512-+iPfvK34PKKYP/p/4TaBliFkbfvjGDIvXuiiaxvISP5ip7sWegvlacwU/uAV6zNDSSmX0tDyER7PurPMKGDipA==", + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.5.1.tgz", + "integrity": "sha512-RnMvtDuR+68ig864gAvZCOdZehqhC5rFmMo0kn+ARfgVSTvFeF6IFLBVgMPUu0KwihaapEyW24WRi6nEyy1kSA==", "license": "MIT", "dependencies": { "react-native-is-edge-to-edge": "^1.3.1", @@ -13309,9 +13343,9 @@ "license": "MIT" }, "node_modules/react-native-worklets": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.10.0.tgz", - "integrity": "sha512-JhE6IxDf6iabC0qu3+TAKA4v9RlluXmoIngPQX7/QUByf75lfrsHZ6/dQhyjEWnp1EEQiwzz8Cpew140ZcewDw==", + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.10.1.tgz", + "integrity": "sha512-62mRM19bDpfpdI8HLkEErcdOsrAPDtE9lA/sw+5lLRpzBHNhxaoj9QyY2KjXqUmirelxkX4zuPGTC3VdA0feJA==", "license": "MIT", "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.27.1", diff --git a/frontend/mobile/package.json b/frontend/mobile/package.json index ef3309dc..006c2f55 100644 --- a/frontend/mobile/package.json +++ b/frontend/mobile/package.json @@ -3,49 +3,51 @@ "main": "expo-router/entry", "version": "1.0.0", "dependencies": { - "@expo/ui": "~57.0.7", + "@expo/ui": "~57.0.10", "@expo/vector-icons": "^15.0.2", "@react-native-async-storage/async-storage": "2.2.0", "@siteed/audio-studio": "^3.2.1", - "expo": "~57.0.8", + "expo": "~57.0.12", + "expo-asset": "~57.0.7", "expo-audio": "~57.0.3", - "expo-constants": "~57.0.7", + "expo-constants": "~57.0.10", "expo-device": "~57.0.1", - "expo-file-system": "~57.0.1", + "expo-file-system": "~57.0.2", "expo-font": "~57.0.1", "expo-glass-effect": "~57.0.1", - "expo-image": "~57.0.1", + "expo-image": "~57.0.2", + "expo-image-picker": "~57.0.9", "expo-linear-gradient": "~57.0.1", - "expo-linking": "~57.0.4", - "expo-router": "~57.0.8", + "expo-linking": "~57.0.5", + "expo-router": "~57.0.12", "expo-secure-store": "~57.0.1", - "expo-splash-screen": "~57.0.5", + "expo-splash-screen": "~57.0.6", "expo-status-bar": "~57.0.1", - "expo-symbols": "~57.0.1", - "expo-system-ui": "~57.0.1", + "expo-symbols": "~57.0.2", + "expo-system-ui": "~57.0.2", "expo-web-browser": "~57.0.2", "phosphor-react-native": "^3.0.6", "react": "19.2.3", "react-dom": "19.2.3", - "react-native": "0.86.0", + "react-native": "0.86.2", "react-native-gesture-handler": "~2.32.0", - "react-native-reanimated": "4.5.0", + "react-native-reanimated": "4.5.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "~4.26.0", "react-native-svg": "15.15.4", "react-native-web": "~0.21.0", "react-native-webrtc": "^124.0.8", - "react-native-worklets": "0.10.0" + "react-native-worklets": "0.10.1" }, "devDependencies": { - "@react-native/jest-preset": "^0.86.0", + "@react-native/jest-preset": "0.86.2", "@testing-library/react-native": "^14.0.1", "@types/jest": "^29.5.14", "@types/react": "~19.2.2", "eslint": "^9.0.0", "eslint-config-expo": "~57.0.1", "jest": "^29.7.0", - "jest-expo": "^57.0.2", + "jest-expo": "~57.0.4", "test-renderer": "^1.2.0", "typescript": "~6.0.3" }, diff --git a/frontend/mobile/src/app/(app)/(tabs)/_layout.tsx b/frontend/mobile/src/app/(app)/(tabs)/_layout.tsx index b7d7b4a5..269493fe 100644 --- a/frontend/mobile/src/app/(app)/(tabs)/_layout.tsx +++ b/frontend/mobile/src/app/(app)/(tabs)/_layout.tsx @@ -138,6 +138,10 @@ export default function TabsLayout() { listeners={{ tabPress: (event) => { if (pathname === '/profile') return; + if (pathname.startsWith('/profile/')) { + event.preventDefault(); + return; + } event.preventDefault(); void (async () => { if (pathname === '/ielts' || pathname === '/interview') await rememberSpecialty(pathname === '/ielts' ? 'ielts' : 'interview'); diff --git a/frontend/mobile/src/app/(app)/(tabs)/profile/__tests__/index.test.tsx b/frontend/mobile/src/app/(app)/(tabs)/profile/__tests__/index.test.tsx new file mode 100644 index 00000000..d871aadf --- /dev/null +++ b/frontend/mobile/src/app/(app)/(tabs)/profile/__tests__/index.test.tsx @@ -0,0 +1,49 @@ +import { fireEvent, render } from '@testing-library/react-native'; +import { useRouter } from 'expo-router'; + +import ProfileHomeRoute from '../index'; + +jest.mock('expo-router', () => ({ + useRouter: jest.fn(), +})); + +jest.mock('@/model/AppModel', () => ({ + useAppModel: () => ({ signOut: jest.fn() }), +})); + +jest.mock('@/screens/ProfileScreen', () => { + const React = jest.requireActual('react'); + const { Pressable, Text } = jest.requireActual('react-native'); + return { + ProfileHome: ({ activeRoute, onOpen }: { activeRoute: string; onOpen: (route: string) => void }) => + React.createElement( + React.Fragment, + null, + React.createElement(Text, { testID: 'active-route' }, activeRoute), + React.createElement( + Pressable, + { accessibilityRole: 'button', onPress: () => onOpen('insights') }, + React.createElement(Text, null, '学习目标与洞察'), + ), + ), + }; +}); + +const mockUseRouter = jest.mocked(useRouter); +const push = jest.fn(); + +describe('ProfileHomeRoute', () => { + beforeEach(() => { + push.mockClear(); + mockUseRouter.mockReturnValue({ push } as unknown as ReturnType); + }); + + it('keeps the last opened profile section selected when the child route returns', async () => { + const view = await render(); + + await fireEvent.press(view.getByRole('button', { name: '学习目标与洞察' })); + + expect(view.getByTestId('active-route')).toHaveTextContent('insights'); + expect(push).toHaveBeenCalledWith('/profile/insights'); + }); +}); diff --git a/frontend/mobile/src/app/(app)/(tabs)/profile/_layout.tsx b/frontend/mobile/src/app/(app)/(tabs)/profile/_layout.tsx new file mode 100644 index 00000000..d48a61cc --- /dev/null +++ b/frontend/mobile/src/app/(app)/(tabs)/profile/_layout.tsx @@ -0,0 +1,5 @@ +import { Stack } from 'expo-router'; + +export default function ProfileLayout() { + return ; +} diff --git a/frontend/mobile/src/app/(app)/profile/about.tsx b/frontend/mobile/src/app/(app)/(tabs)/profile/about.tsx similarity index 100% rename from frontend/mobile/src/app/(app)/profile/about.tsx rename to frontend/mobile/src/app/(app)/(tabs)/profile/about.tsx diff --git a/frontend/mobile/src/app/(app)/profile/account.tsx b/frontend/mobile/src/app/(app)/(tabs)/profile/account.tsx similarity index 100% rename from frontend/mobile/src/app/(app)/profile/account.tsx rename to frontend/mobile/src/app/(app)/(tabs)/profile/account.tsx diff --git a/frontend/mobile/src/app/(app)/profile/assistant.tsx b/frontend/mobile/src/app/(app)/(tabs)/profile/assistant.tsx similarity index 100% rename from frontend/mobile/src/app/(app)/profile/assistant.tsx rename to frontend/mobile/src/app/(app)/(tabs)/profile/assistant.tsx diff --git a/frontend/mobile/src/app/(app)/(tabs)/profile/help.tsx b/frontend/mobile/src/app/(app)/(tabs)/profile/help.tsx new file mode 100644 index 00000000..c788b3ed --- /dev/null +++ b/frontend/mobile/src/app/(app)/(tabs)/profile/help.tsx @@ -0,0 +1,14 @@ +import { useRouter } from 'expo-router'; + +import { routes } from '@/navigation/routes'; +import { HelpCenter } from '@/screens/ProfileScreen'; + +export default function HelpRoute() { + const router = useRouter(); + return ( + router.back()} + onOpenCategory={(categoryId) => router.push(routes.profile.helpCategory(categoryId))} + /> + ); +} diff --git a/frontend/mobile/src/app/(app)/(tabs)/profile/help/[categoryId].tsx b/frontend/mobile/src/app/(app)/(tabs)/profile/help/[categoryId].tsx new file mode 100644 index 00000000..bfff7edb --- /dev/null +++ b/frontend/mobile/src/app/(app)/(tabs)/profile/help/[categoryId].tsx @@ -0,0 +1,19 @@ +import { useLocalSearchParams, useRouter } from 'expo-router'; + +import { routes } from '@/navigation/routes'; +import { HelpCategory } from '@/screens/ProfileScreen'; + +export default function HelpCategoryRoute() { + const router = useRouter(); + const params = useLocalSearchParams<{ categoryId?: string | string[] }>(); + const categoryId = Array.isArray(params.categoryId) + ? params.categoryId[0] + : params.categoryId ?? ''; + return ( + router.back()} + onOpenArticle={(articleId) => router.push(routes.profile.helpArticle(articleId))} + /> + ); +} diff --git a/frontend/mobile/src/app/(app)/(tabs)/profile/help/article/[articleId].tsx b/frontend/mobile/src/app/(app)/(tabs)/profile/help/article/[articleId].tsx new file mode 100644 index 00000000..a4389e16 --- /dev/null +++ b/frontend/mobile/src/app/(app)/(tabs)/profile/help/article/[articleId].tsx @@ -0,0 +1,12 @@ +import { useLocalSearchParams, useRouter } from 'expo-router'; + +import { HelpArticle } from '@/screens/ProfileScreen'; + +export default function HelpArticleRoute() { + const router = useRouter(); + const params = useLocalSearchParams<{ articleId?: string | string[] }>(); + const articleId = Array.isArray(params.articleId) + ? params.articleId[0] + : params.articleId ?? ''; + return router.back()} />; +} diff --git a/frontend/mobile/src/app/(app)/(tabs)/profile/index.tsx b/frontend/mobile/src/app/(app)/(tabs)/profile/index.tsx index 07252e60..5304c248 100644 --- a/frontend/mobile/src/app/(app)/(tabs)/profile/index.tsx +++ b/frontend/mobile/src/app/(app)/(tabs)/profile/index.tsx @@ -1,4 +1,5 @@ import { useRouter } from 'expo-router'; +import { useState } from 'react'; import { useAppModel } from '@/model/AppModel'; import { routes } from '@/navigation/routes'; @@ -7,8 +8,10 @@ import { ProfileHome, type ProfileRoute } from '@/screens/ProfileScreen'; export default function ProfileHomeRoute() { const router = useRouter(); const { signOut } = useAppModel(); + const [activeRoute, setActiveRoute] = useState('overview'); const open = (route: ProfileRoute) => { + setActiveRoute(route); if (route === 'overview') router.push(routes.profile.overview); else if (route === 'insights') router.push(routes.profile.insights); else if (route === 'membership') router.push(routes.profile.membership); @@ -18,5 +21,5 @@ export default function ProfileHomeRoute() { else if (route === 'about') router.push(routes.profile.about); }; - return ; + return ; } diff --git a/frontend/mobile/src/app/(app)/profile/insights.tsx b/frontend/mobile/src/app/(app)/(tabs)/profile/insights.tsx similarity index 100% rename from frontend/mobile/src/app/(app)/profile/insights.tsx rename to frontend/mobile/src/app/(app)/(tabs)/profile/insights.tsx diff --git a/frontend/mobile/src/app/(app)/profile/membership.tsx b/frontend/mobile/src/app/(app)/(tabs)/profile/membership.tsx similarity index 100% rename from frontend/mobile/src/app/(app)/profile/membership.tsx rename to frontend/mobile/src/app/(app)/(tabs)/profile/membership.tsx diff --git a/frontend/mobile/src/app/(app)/profile/overview.tsx b/frontend/mobile/src/app/(app)/(tabs)/profile/overview.tsx similarity index 100% rename from frontend/mobile/src/app/(app)/profile/overview.tsx rename to frontend/mobile/src/app/(app)/(tabs)/profile/overview.tsx diff --git a/frontend/mobile/src/app/(app)/profile/help.tsx b/frontend/mobile/src/app/(app)/profile/help.tsx deleted file mode 100644 index cae06577..00000000 --- a/frontend/mobile/src/app/(app)/profile/help.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { useRouter } from 'expo-router'; - -import { HelpCenter } from '@/screens/ProfileScreen'; - -export default function HelpRoute() { - const router = useRouter(); - return router.back()} />; -} diff --git a/frontend/mobile/src/features/audio/TurnAudioCapture.ts b/frontend/mobile/src/features/audio/TurnAudioCapture.ts index 27175c16..696b90f0 100644 --- a/frontend/mobile/src/features/audio/TurnAudioCapture.ts +++ b/frontend/mobile/src/features/audio/TurnAudioCapture.ts @@ -4,6 +4,7 @@ export type TurnAudioCapturePort = { start(): Promise; stop(): boolean; take(): Promise; + release(): Promise; }; export function createTurnAudioCapture( @@ -51,5 +52,18 @@ export function createTurnAudioCapture( finalized = false; return audio; }, + async release() { + stopRequested = true; + if (startPromise) await startPromise.catch(() => undefined); + if (active) { + await recorder.cancel().catch(() => undefined); + } else if (finalized) { + await audioPromise.catch(() => null); + } + active = false; + finalized = false; + stopRequested = false; + audioPromise = Promise.resolve(null); + }, }; } diff --git a/frontend/mobile/src/features/audio/WavRecorder.ts b/frontend/mobile/src/features/audio/WavRecorder.ts index 8fc4cd51..f1dd374e 100644 --- a/frontend/mobile/src/features/audio/WavRecorder.ts +++ b/frontend/mobile/src/features/audio/WavRecorder.ts @@ -50,21 +50,38 @@ export class WavRecorder { if (!permission.granted) { throw new Error('请允许麦克风权限后再朗读'); } - await this.nativeRecorder.startRecording(pcm16WavConfig); + try { + await this.nativeRecorder.startRecording(pcm16WavConfig); + } catch (error) { + if (!this.isAlreadyRecordingError(error)) throw error; + // The native module can keep recording after a previous screen or + // realtime session disappears. Clear that orphan before retrying. + await this.nativeRecorder.stopRecording().catch(() => null); + await this.nativeRecorder.startRecording(pcm16WavConfig); + } this.active = true; } async stop() { if (!this.active) throw new Error('当前没有正在进行的录音'); - this.active = false; - const result = await this.nativeRecorder.stopRecording(); + const result = await this.nativeRecorder.stopRecording().finally(() => { + this.active = false; + }); if (!result?.fileUri) throw new Error('录音文件生成失败,请重新朗读'); return result.fileUri; } async cancel() { if (!this.active) return; - this.active = false; - await this.nativeRecorder.stopRecording(); + await this.nativeRecorder.stopRecording().finally(() => { + this.active = false; + }); + } + + private isAlreadyRecordingError(error: unknown) { + if (!error || typeof error !== 'object') return false; + const value = error as { code?: unknown; message?: unknown }; + return value.code === 'ALREADY_RECORDING' || + /recording is already in progress/i.test(String(value.message ?? '')); } } diff --git a/frontend/mobile/src/features/audio/__tests__/TurnAudioCapture.test.ts b/frontend/mobile/src/features/audio/__tests__/TurnAudioCapture.test.ts index a42355e5..1124f45c 100644 --- a/frontend/mobile/src/features/audio/__tests__/TurnAudioCapture.test.ts +++ b/frontend/mobile/src/features/audio/__tests__/TurnAudioCapture.test.ts @@ -36,4 +36,46 @@ describe('createTurnAudioCapture', () => { await expect(capture.take()).resolves.toBe('file:///turn-race.wav'); expect(recorder.stop).toHaveBeenCalledTimes(1); }); + + it('releases an active recording when the realtime session ends', async () => { + const recorder = { + start: jest.fn(async () => undefined), + stop: jest.fn(async () => 'file:///turn.wav'), + cancel: jest.fn(async () => undefined), + }; + const capture = createTurnAudioCapture(recorder); + + await capture.start(); + await capture.release(); + + expect(recorder.cancel).toHaveBeenCalledTimes(1); + await capture.start(); + expect(recorder.start).toHaveBeenCalledTimes(2); + }); + + it('releases a recording whose native start is still pending', async () => { + let finishStart!: () => void; + let finishStop!: (uri: string) => void; + const recorder = { + start: jest.fn(() => new Promise((resolve) => { finishStart = resolve; })), + stop: jest.fn(() => new Promise((resolve) => { finishStop = resolve; })), + cancel: jest.fn(async () => undefined), + }; + const capture = createTurnAudioCapture(recorder); + + const starting = capture.start(); + const releasing = capture.release(); + finishStart(); + await starting; + + let released = false; + void releasing.then(() => { released = true; }); + await Promise.resolve(); + expect(released).toBe(false); + finishStop('file:///turn.wav'); + await releasing; + + expect(recorder.stop).toHaveBeenCalledTimes(1); + expect(recorder.cancel).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/mobile/src/features/audio/__tests__/WavRecorder.test.ts b/frontend/mobile/src/features/audio/__tests__/WavRecorder.test.ts index f354355f..5e2ee356 100644 --- a/frontend/mobile/src/features/audio/__tests__/WavRecorder.test.ts +++ b/frontend/mobile/src/features/audio/__tests__/WavRecorder.test.ts @@ -58,4 +58,20 @@ describe('WavRecorder', () => { expect(nativeRecorder.stopRecording).toHaveBeenCalledTimes(1); }); + + it('clears an orphaned native recording and retries once', async () => { + const nativeRecorder = createNativeRecorder(); + nativeRecorder.startRecording + .mockRejectedValueOnce(Object.assign(new Error('Recording is already in progress'), { + code: 'ALREADY_RECORDING', + })) + .mockResolvedValueOnce(undefined); + const recorder = new WavRecorder(nativeRecorder); + + await recorder.start(); + await expect(recorder.stop()).resolves.toBe('file:///take.wav'); + + expect(nativeRecorder.startRecording).toHaveBeenCalledTimes(2); + expect(nativeRecorder.stopRecording).toHaveBeenCalledTimes(2); + }); }); diff --git a/frontend/mobile/src/features/ielts/IeltsPracticeScoreDialog.tsx b/frontend/mobile/src/features/ielts/IeltsPracticeScoreDialog.tsx new file mode 100644 index 00000000..4a9a9115 --- /dev/null +++ b/frontend/mobile/src/features/ielts/IeltsPracticeScoreDialog.tsx @@ -0,0 +1,140 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { formatBand } from './ieltsMappings'; +import type { IeltsEvaluationResult } from './types'; + +const palette = { + canvas: '#FCFAFF', + paper: '#FFFFFF', + border: '#E6DBFF', + purple: '#8060E8', + purpleDark: '#5A3DBB', + text: '#171323', + muted: '#847D92', +} as const; + +export function IeltsPracticeScoreDialog({ + evaluation, + onHome, + onDetails, +}: { + evaluation: IeltsEvaluationResult | null; + onHome: () => void; + onDetails: () => void; +}) { + const scores = evaluation ? [ + ['流利度与连贯性', evaluation.fluencyCoherenceScore], + ['词汇资源', evaluation.lexicalResourceScore], + ['语法多样性与准确性', evaluation.grammaticalRangeAccuracyScore], + ['发音', evaluation.pronunciationScore], + ] as const : []; + + return ( + + + IELTS SPEAKING + 本次专项练习已结束 + 评分结果已自动保存到学习资产 + + + + {evaluation ? '评分完成' : '有效回答不足'} + + {evaluation ? '本次专项表现' : '本次暂时无法评分'} + + + {evaluation + ? '本页只反映当前 Part 的四项能力表现,不作为完整雅思口语预估分。' + : '至少完成一轮有效英文回答后,才能生成四项能力评分。'} + + + {evaluation ? ( + + {scores.map(([label, score]) => ( + + {label} + + {formatBand(score)} / 9 + + + ))} + + ) : null} + + + [styles.button, styles.secondaryButton, pressed && styles.pressed]} + > + 返回训练中心 + + {evaluation ? ( + [styles.button, styles.primaryButton, pressed && styles.pressed]} + > + 查看详细报告 + + ) : null} + + + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: palette.canvas }, + background: { + minHeight: 190, + paddingHorizontal: 24, + paddingTop: 34, + paddingBottom: 58, + backgroundColor: palette.purple, + }, + backgroundEyebrow: { color: '#EDE7FF', fontSize: 11, lineHeight: 16, fontWeight: '600' }, + backgroundTitle: { marginTop: 10, color: '#FFFFFF', fontSize: 28, lineHeight: 36, fontWeight: '700' }, + backgroundCopy: { marginTop: 8, color: '#EDE7FF', fontSize: 13, lineHeight: 20 }, + dialog: { + marginTop: -38, + marginHorizontal: 18, + marginBottom: 28, + padding: 22, + borderWidth: 1, + borderColor: palette.border, + borderRadius: 18, + backgroundColor: palette.paper, + shadowColor: palette.purpleDark, + shadowOffset: { width: 0, height: 10 }, + shadowOpacity: 0.14, + shadowRadius: 24, + elevation: 5, + boxShadow: '0px 10px 24px rgba(90, 61, 187, 0.14)', + }, + dialogEyebrow: { color: palette.purpleDark, fontSize: 12, lineHeight: 17, fontWeight: '700' }, + dialogTitle: { marginTop: 7, color: palette.text, fontSize: 25, lineHeight: 33, fontWeight: '700' }, + dialogCopy: { marginTop: 8, color: palette.muted, fontSize: 13, lineHeight: 20 }, + dimensionGrid: { marginTop: 20, flexDirection: 'row', flexWrap: 'wrap', gap: 10 }, + dimensionItem: { + width: '48%', + minHeight: 102, + flexGrow: 1, + padding: 15, + justifyContent: 'space-between', + borderWidth: 1, + borderColor: palette.border, + borderRadius: 12, + backgroundColor: '#FDFBFF', + }, + dimensionLabel: { color: palette.muted, fontSize: 12, lineHeight: 17, fontWeight: '500' }, + dimensionValue: { marginTop: 12, color: palette.purpleDark, fontSize: 30, lineHeight: 36, fontWeight: '700' }, + dimensionSuffix: { color: palette.muted, fontSize: 12, fontWeight: '600' }, + actions: { marginTop: 22, flexDirection: 'row', gap: 10 }, + button: { minHeight: 48, flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 12, borderWidth: 1, borderRadius: 24 }, + secondaryButton: { borderColor: palette.border, backgroundColor: palette.paper }, + primaryButton: { borderColor: palette.purple, backgroundColor: palette.purple }, + secondaryButtonText: { color: palette.text, fontSize: 13, fontWeight: '600', textAlign: 'center' }, + primaryButtonText: { color: '#FFFFFF', fontSize: 13, fontWeight: '600', textAlign: 'center' }, + pressed: { opacity: 0.78 }, +}); diff --git a/frontend/mobile/src/features/ielts/__tests__/IeltsPracticeScoreDialog.test.tsx b/frontend/mobile/src/features/ielts/__tests__/IeltsPracticeScoreDialog.test.tsx new file mode 100644 index 00000000..bb724c43 --- /dev/null +++ b/frontend/mobile/src/features/ielts/__tests__/IeltsPracticeScoreDialog.test.tsx @@ -0,0 +1,47 @@ +import { fireEvent, render } from '@testing-library/react-native'; + +import { IeltsPracticeScoreDialog } from '../IeltsPracticeScoreDialog'; +import type { IeltsEvaluationResult } from '../types'; + +const evaluation: IeltsEvaluationResult = { + part: 'PART_1', + assessmentType: 'PART', + overallBandScore: null, + fluencyCoherenceScore: 6.5, + lexicalResourceScore: 7, + grammaticalRangeAccuracyScore: 6, + pronunciationScore: 6.5, + summary: '本次专项表现稳定。', + strengths: [], + improvements: [], + recommendedExpressions: [], +}; + +describe('IeltsPracticeScoreDialog', () => { + it('shows only the four part-practice dimensions without an overall score', async () => { + const screen = await render( + , + ); + + expect(screen.getByText('本次专项表现')).toBeTruthy(); + expect(screen.getByText('流利度与连贯性')).toBeTruthy(); + expect(screen.getByText('词汇资源')).toBeTruthy(); + expect(screen.getByText('语法多样性与准确性')).toBeTruthy(); + expect(screen.getByText('发音')).toBeTruthy(); + expect(screen.queryByText('本次模拟评分')).toBeNull(); + expect(screen.queryByText('ESTIMATED BAND')).toBeNull(); + }); + + it('provides the same two completion actions as the Web dialog', async () => { + const onHome = jest.fn(); + const onDetails = jest.fn(); + const screen = await render( + , + ); + + await fireEvent.press(screen.getByText('返回训练中心')); + await fireEvent.press(screen.getByText('查看详细报告')); + expect(onHome).toHaveBeenCalledTimes(1); + expect(onDetails).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/mobile/src/features/profile/ProfileApi.ts b/frontend/mobile/src/features/profile/ProfileApi.ts new file mode 100644 index 00000000..decc3760 --- /dev/null +++ b/frontend/mobile/src/features/profile/ProfileApi.ts @@ -0,0 +1,217 @@ +export type ProfileAccount = { + userId: string; + email: string; + nickname: string | null; + displayName: string; + avatarUrl: string | null; + avatarUrlExpiresAt: string | null; +}; + +export type DailyPractice = { + date: string; + practiceSeconds: number; +}; + +export type ProfileOverview = { + account: ProfileAccount; + statistics: { + weeklyPracticeSeconds: number; + trainingRecordCount: number; + consecutiveLearningDays: number; + lastSevenDays: DailyPractice[]; + }; + calendar: { + month: string; + checkedDates: string[]; + checkedInToday: boolean; + }; +}; + +export type WeeklyGoals = { + weekStartsAt: string; + weekEndsAt: string; + durationTargetMinutes: number; + completedDurationSeconds: number; + remainingDurationSeconds: number; + durationProgress: number; + durationAchieved: boolean; + trainingCountTarget: number; + completedTrainingCount: number; + remainingTrainingCount: number; + countProgress: number; + countAchieved: boolean; +}; + +export type AbilityScores = { + accuracy: number | null; + fluency: number | null; + grammar: number | null; + vocabulary: number | null; + naturalness: number | null; +}; + +export type ProfileInsights = { + weeklyGoals: WeeklyGoals; + trainingTypeDistribution: { + type: string; + durationSeconds: number; + percentage: number; + }[]; + abilityTrends: { + sessionId: string; + completedAt: string; + trainingType: string; + scores: AbilityScores; + }[]; + weaknessAnalysis: { + sampleCount: number; + minimumSampleCount: number; + reliable: boolean; + }; + weaknesses: { + dimension: string; + rank: number; + averageScore: number; + recentChange: number; + basis: string; + }[]; + recommendations: { + dimension: string; + trainingType: string; + reason: string; + }[]; +}; + +export type AchievementMilestone = { + achievementId: string; + level: number; + title: string; + description: string; + threshold: number; + unlocked: boolean; + unlockedAt: string | null; +}; + +export type AchievementSeries = { + seriesId: string; + category: string; + title: string; + unit: string; + currentValue: number; + currentLevel: number; + currentTitle: string | null; + nextLevel: number | null; + nextTitle: string | null; + nextThreshold: number | null; + completed: boolean; + milestones: AchievementMilestone[]; +}; + +export type AchievementOverview = { series: AchievementSeries[] }; + +export type HelpCategory = { + id: string; + title: string; + description: string; + articleCount: number; +}; + +export type HelpCenterContent = { + categories: HelpCategory[]; +}; + +export type HelpArticleSummary = { + id: string; + title: string; + summary: string; +}; + +export type HelpCategoryDetail = { + id: string; + title: string; + description: string; + articles: HelpArticleSummary[]; +}; + +export type HelpArticle = HelpArticleSummary & { + categoryId: string; + updatedAt: string; +}; + +export type ProfileAvatar = { + uri: string; + mimeType: string; + fileName: string; + fileSize?: number | null; +}; + +type ApiRequester = { + request(path: string, options?: RequestInit): Promise; +}; + +export class ProfileApi { + constructor(private readonly client: ApiRequester) {} + + getOverview(month?: string) { + const query = month ? `?month=${encodeURIComponent(month)}` : ''; + return this.client.request(`/api/profile/overview${query}`); + } + + getInsights() { + return this.client.request('/api/profile/insights'); + } + + updateWeeklyGoals(input: { durationTargetMinutes: number; trainingCountTarget: number }) { + return this.client.request('/api/profile/insights/goals', { + method: 'PUT', + body: JSON.stringify(input), + }); + } + + getAchievements() { + return this.client.request('/api/achievements'); + } + + updateNickname(nickname: string) { + return this.client.request<{ nickname: string; displayName: string }>('/api/profile', { + method: 'PATCH', + body: JSON.stringify({ nickname }), + }); + } + + uploadAvatar(avatar: ProfileAvatar) { + const formData = new FormData(); + formData.append('avatar', { + uri: avatar.uri, + type: avatar.mimeType, + name: avatar.fileName, + } as unknown as Blob); + return this.client.request<{ + avatarUrl: string; + avatarUrlExpiresAt: string; + }>('/api/profile/avatar', { method: 'POST', body: formData }); + } + + changePassword(input: { currentPassword: string; newPassword: string }) { + return this.client.request<{ reauthenticationRequired: boolean }>('/api/auth/password', { + method: 'PUT', + body: JSON.stringify(input), + }); + } + + getHelpCenter() { + return this.client.request('/api/help-center'); + } + + getHelpCategory(categoryId: string) { + return this.client.request( + `/api/help-center/categories/${encodeURIComponent(categoryId)}`, + ); + } + + getHelpArticle(articleId: string) { + return this.client.request( + `/api/help-center/articles/${encodeURIComponent(articleId)}`, + ); + } +} diff --git a/frontend/mobile/src/features/profile/__tests__/ProfileApi.test.ts b/frontend/mobile/src/features/profile/__tests__/ProfileApi.test.ts new file mode 100644 index 00000000..161d22da --- /dev/null +++ b/frontend/mobile/src/features/profile/__tests__/ProfileApi.test.ts @@ -0,0 +1,92 @@ +import { ProfileApi } from '../ProfileApi'; + +describe('ProfileApi', () => { + it('uses the same overview and insights contracts as the web client', async () => { + const request = jest.fn().mockResolvedValue({}); + const api = new ProfileApi({ request }); + + await api.getOverview('2026-08'); + await api.getInsights(); + await api.updateWeeklyGoals({ + durationTargetMinutes: 180, + trainingCountTarget: 6, + }); + + expect(request).toHaveBeenNthCalledWith(1, '/api/profile/overview?month=2026-08'); + expect(request).toHaveBeenNthCalledWith(2, '/api/profile/insights'); + expect(request).toHaveBeenNthCalledWith(3, '/api/profile/insights/goals', { + method: 'PUT', + body: JSON.stringify({ + durationTargetMinutes: 180, + trainingCountTarget: 6, + }), + }); + }); + + it('updates account data and password through authenticated endpoints', async () => { + const request = jest.fn().mockResolvedValue({}); + const api = new ProfileApi({ request }); + + await api.updateNickname('方婧'); + await api.changePassword({ + currentPassword: 'old-password', + newPassword: 'new-password', + }); + await api.getAchievements(); + + expect(request).toHaveBeenNthCalledWith(1, '/api/profile', { + method: 'PATCH', + body: JSON.stringify({ nickname: '方婧' }), + }); + expect(request).toHaveBeenNthCalledWith(2, '/api/auth/password', { + method: 'PUT', + body: JSON.stringify({ + currentPassword: 'old-password', + newPassword: 'new-password', + }), + }); + expect(request).toHaveBeenNthCalledWith(3, '/api/achievements'); + }); + + it('uploads an avatar as multipart form data', async () => { + const request = jest.fn().mockResolvedValue({}); + const api = new ProfileApi({ request }); + + await api.uploadAvatar({ + uri: 'file:///avatar.jpg', + mimeType: 'image/jpeg', + fileName: 'avatar.jpg', + }); + + expect(request).toHaveBeenCalledWith('/api/profile/avatar', { + method: 'POST', + body: expect.any(FormData), + }); + }); + + it('loads help center content from the backend', async () => { + const request = jest.fn().mockResolvedValue({ categories: [] }); + const api = new ProfileApi({ request }); + + await api.getHelpCenter(); + + expect(request).toHaveBeenCalledWith('/api/help-center'); + }); + + it('loads help category and article routes with encoded identifiers', async () => { + const request = jest.fn().mockResolvedValue({}); + const api = new ProfileApi({ request }); + + await api.getHelpCategory('quick start'); + await api.getHelpArticle('first setup'); + + expect(request).toHaveBeenNthCalledWith( + 1, + '/api/help-center/categories/quick%20start', + ); + expect(request).toHaveBeenNthCalledWith( + 2, + '/api/help-center/articles/first%20setup', + ); + }); +}); diff --git a/frontend/mobile/src/features/realtime/RealtimeSessionController.ts b/frontend/mobile/src/features/realtime/RealtimeSessionController.ts index b72d38a2..ad7e4004 100644 --- a/frontend/mobile/src/features/realtime/RealtimeSessionController.ts +++ b/frontend/mobile/src/features/realtime/RealtimeSessionController.ts @@ -348,6 +348,7 @@ export class RealtimeSessionController { return { sessionId: backend.sessionId }; } catch (error) { this.inputEnabled = false; + await this.releaseTurnAudioCapture(); this.dependencies.transport.setAudioEnabled(false); this.dependencies.transport.close(); this.dependencies.sessionSocket.close(); @@ -529,6 +530,7 @@ export class RealtimeSessionController { : 'PEER_CONNECTION_FAILED'; this.inputEnabled = false; this.applyAudioEnabled(); + void this.releaseTurnAudioCapture(); this.machine.dispatch({ type: 'FAIL', error: { @@ -680,6 +682,7 @@ export class RealtimeSessionController { } this.inputEnabled = false; this.applyAudioEnabled(); + void this.releaseTurnAudioCapture(); this.machine.dispatch({ type: 'FAIL', error: { @@ -753,6 +756,9 @@ export class RealtimeSessionController { if (this.options.mode === 'scene') { await this.waitForPendingTurnEvaluations(); } + if (this.dependencies.turnAudioCapture?.release) { + await this.releaseTurnAudioCapture(); + } const stopTime = this.now().toISOString(); completion = this.options.mode === 'scene' && this.dependencies.sceneDialogue @@ -767,6 +773,9 @@ export class RealtimeSessionController { } return completion; } finally { + if (this.dependencies.turnAudioCapture?.release) { + await this.releaseTurnAudioCapture(); + } this.dependencies.transport.close(); this.dependencies.sessionSocket.close(); this.unsubscribeTransport(); @@ -862,6 +871,13 @@ export class RealtimeSessionController { } } + private async releaseTurnAudioCapture() { + const release = this.dependencies.turnAudioCapture?.release; + if (typeof release === 'function') { + await release().catch(() => undefined); + } + } + private async evaluateIeltsTurn( sessionId: string, turnNo: number, diff --git a/frontend/mobile/src/features/realtime/__tests__/RealtimeSessionController.test.ts b/frontend/mobile/src/features/realtime/__tests__/RealtimeSessionController.test.ts index 75f13da6..1e9c178a 100644 --- a/frontend/mobile/src/features/realtime/__tests__/RealtimeSessionController.test.ts +++ b/frontend/mobile/src/features/realtime/__tests__/RealtimeSessionController.test.ts @@ -316,6 +316,7 @@ describe('RealtimeSessionController', () => { start: jest.fn(async () => undefined), stop: jest.fn(() => true), take: jest.fn(async () => 'file:///scene-turn.wav'), + release: jest.fn(async () => undefined), }; dependencies.turnAudioCapture = turnAudioCapture; const sceneDialogue = { @@ -1111,6 +1112,7 @@ describe('RealtimeSessionController', () => { start: jest.fn(async () => undefined), stop: jest.fn(() => true), take: jest.fn(async () => 'file:///ielts-part2.wav'), + release: jest.fn(async () => undefined), }; dependencies.turnAudioCapture = turnAudioCapture; const ieltsDialogue: NonNullable = { @@ -1177,6 +1179,7 @@ describe('RealtimeSessionController', () => { start: jest.fn(async () => undefined), stop: jest.fn(() => true), take: jest.fn(async () => 'file:///ielts-part2-final.wav'), + release: jest.fn(async () => undefined), }; dependencies.ieltsDialogue = { advanceState: jest.fn(), diff --git a/frontend/mobile/src/model/AppModel.tsx b/frontend/mobile/src/model/AppModel.tsx index 51cfc2fe..8e71902d 100644 --- a/frontend/mobile/src/model/AppModel.tsx +++ b/frontend/mobile/src/model/AppModel.tsx @@ -1,25 +1,11 @@ -import { - createContext, - type PropsWithChildren, - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from 'react'; +import { createContext, type PropsWithChildren, useCallback, useContext, useEffect, useMemo, useState } from 'react'; -import { - AuthSessionController, - type AuthSessionState, -} from '@/features/auth/AuthSessionController'; -import { - AuthService, - type EmailChallenge, - type UserPreference, -} from '@/features/auth/AuthService'; +import { AuthSessionController, type AuthSessionState } from '@/features/auth/AuthSessionController'; +import { AuthService, type EmailChallenge, type UserPreference } from '@/features/auth/AuthService'; import { cefrLevelForLevel, levelForCefrLevel, + speedCodeForLabel, speedLabelForCode, teacherForVoice, voiceForTeacher, @@ -73,13 +59,16 @@ type AppModelValue = { signOut: () => Promise; nickname: string; setNickname: (value: string) => void; + email: string; speed: string; setSpeed: (value: string) => void; + saveSpeed: (value: string) => Promise; level: string; setLevel: (value: string) => void; saveLevel: (value: string) => Promise; teacher: Teacher; setTeacher: (value: Teacher) => void; + saveTeacher: (value: Teacher) => Promise; sceneRecords: SceneLearningRecord[]; ieltsRecords: IeltsLearningRecord[]; interviewRecords: InterviewLearningRecord[]; @@ -115,9 +104,7 @@ export function AppModelProvider({ const [authController] = useState( () => injectedAuthController ?? createDefaultAuthController(), ); - const [authState, setAuthState] = useState( - () => authController.getSnapshot(), - ); + const [authState, setAuthState] = useState(() => authController.getSnapshot()); const [nickname, setNickname] = useState('Yufan'); const [speed, setSpeed] = useState('自然'); const [level, setLevel] = useState('starter'); @@ -169,8 +156,7 @@ export function AppModelProvider({ ); const issueEmailChallenge = useCallback( - (input: { email: string }) => - authController.issueEmailChallenge(input), + (input: { email: string }) => authController.issueEmailChallenge(input), [authController], ); @@ -188,21 +174,42 @@ export function AppModelProvider({ }); }, [authController, level, teacher]); - const saveLevel = useCallback(async (value: string) => { - const selectedLevel = levels.find((option) => option.id === value) ?? levels[0]; - const preference = await authController.updatePreference({ - cefrLevel: cefrLevelForLevel(selectedLevel), - }); - setLevel(levelForCefrLevel(preference.cefrLevel, levels).id); - }, [authController]); + const saveLevel = useCallback( + async (value: string) => { + const selectedLevel = levels.find((option) => option.id === value) ?? levels[0]; + const preference = await authController.updatePreference({ + cefrLevel: cefrLevelForLevel(selectedLevel), + }); + setLevel(levelForCefrLevel(preference.cefrLevel, levels).id); + }, + [authController], + ); + + const saveSpeed = useCallback( + async (value: string) => { + const preference = await authController.updatePreference({ + preferredAiSpeechSpeed: speedCodeForLabel(value), + }); + setSpeed(speedLabelForCode(preference.preferredAiSpeechSpeed)); + }, + [authController], + ); + + const saveTeacher = useCallback( + async (value: Teacher) => { + const preference = await authController.updatePreference({ + preferredVoice: voiceForTeacher(value), + }); + setTeacher(teacherForVoice(preference.preferredVoice, teachers)); + }, + [authController], + ); const signOut = useCallback(() => authController.logout(), [authController]); const isModelReady = authState.status !== 'booting'; const isAuthenticated = authState.status === 'authenticated'; - const hasCompletedOnboarding = Boolean( - authState.preference?.cefrLevel && authState.preference?.preferredVoice, - ); + const hasCompletedOnboarding = Boolean(authState.preference?.cefrLevel && authState.preference?.preferredVoice); const value = useMemo( () => ({ @@ -218,13 +225,16 @@ export function AppModelProvider({ signOut, nickname, setNickname, + email: authState.user?.username ?? '', speed, setSpeed, + saveSpeed, level, setLevel, saveLevel, teacher, setTeacher, + saveTeacher, sceneRecords, ieltsRecords, interviewRecords, @@ -246,12 +256,15 @@ export function AppModelProvider({ issueEmailChallenge, authState.error, authState.status, + authState.user, level, membership, nickname, ieltsRecords, interviewRecords, removeSceneRecord, + saveSpeed, + saveTeacher, saveLevel, sceneRecords, signIn, diff --git a/frontend/mobile/src/model/__tests__/AppModel.test.tsx b/frontend/mobile/src/model/__tests__/AppModel.test.tsx index 85d13aec..f896044b 100644 --- a/frontend/mobile/src/model/__tests__/AppModel.test.tsx +++ b/frontend/mobile/src/model/__tests__/AppModel.test.tsx @@ -5,11 +5,7 @@ import type { AuthSessionState } from '@/features/auth/AuthSessionController'; import type { UserPreference } from '@/features/auth/AuthService'; import { teachers } from '@/theme/tokens'; -import { - AppModelProvider, - type AppModelAuthController, - useAppModel, -} from '../AppModel'; +import { AppModelProvider, type AppModelAuthController, useAppModel } from '../AppModel'; function createController(state: AuthSessionState): AppModelAuthController & { emit(nextState: AuthSessionState): void; @@ -108,10 +104,18 @@ function OnboardingProbe() { model.setLevel('basic')} /> void model.saveLevel('independent')} /> model.setTeacher(teachers[1])} /> - void model.completeOnboarding()} - /> + void model.completeOnboarding()} /> + + ); +} + +function ProfileSettingsProbe() { + const model = useAppModel(); + return ( + + {model.email} + void model.saveSpeed('慢一些')} /> + void model.saveTeacher(teachers[2])} /> ); } @@ -208,4 +212,26 @@ describe('AppModelProvider authentication binding', () => { }), ); }); + + it('exposes the authenticated email and persists profile assistant settings', async () => { + const controller = createController(authenticatedState); + const screen = await render( + + + , + ); + + expect(screen.getByTestId('profile-email').props.children).toBe('learner@example.com'); + await fireEvent.press(screen.getByLabelText('save-speed')); + await fireEvent.press(screen.getByLabelText('save-teacher')); + + await waitFor(() => { + expect(controller.updatePreference).toHaveBeenCalledWith({ + preferredAiSpeechSpeed: 'SLOWER', + }); + expect(controller.updatePreference).toHaveBeenCalledWith({ + preferredVoice: teachers[2].voiceId, + }); + }); + }); }); diff --git a/frontend/mobile/src/navigation/routes.ts b/frontend/mobile/src/navigation/routes.ts index 6332bffb..2d73c012 100644 --- a/frontend/mobile/src/navigation/routes.ts +++ b/frontend/mobile/src/navigation/routes.ts @@ -55,6 +55,8 @@ export const routes = { assistant: href('/profile/assistant'), account: href('/profile/account'), help: href('/profile/help'), + helpCategory: (id: string) => href(`/profile/help/${encodeURIComponent(id)}`), + helpArticle: (id: string) => href(`/profile/help/article/${encodeURIComponent(id)}`), about: href('/profile/about'), }, } as const; diff --git a/frontend/mobile/src/screens/ProfileScreen.tsx b/frontend/mobile/src/screens/ProfileScreen.tsx index 0a363792..bc533306 100644 --- a/frontend/mobile/src/screens/ProfileScreen.tsx +++ b/frontend/mobile/src/screens/ProfileScreen.tsx @@ -1,6 +1,17 @@ import { Image } from 'expo-image'; -import { useState, type ReactNode } from 'react'; -import { Modal, Pressable, StyleSheet, Switch, Text, TextInput, View } from 'react-native'; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; +import { + ActivityIndicator, + Alert, + Modal, + Platform, + Pressable, + StyleSheet, + Text, + TextInput, + View, + type ImageSourcePropType, +} from 'react-native'; import { BookOpenTextIcon } from 'phosphor-react-native/src/icons/BookOpenText'; import { CalendarCheckIcon } from 'phosphor-react-native/src/icons/CalendarCheck'; import { ChartLineUpIcon } from 'phosphor-react-native/src/icons/ChartLineUp'; @@ -27,132 +38,1750 @@ import { SectionTitle, } from '@/components/ui'; import { LevelSelector, SpeedSelector, TeacherSelector } from '@/components/ConversationSettings'; +import { + ProfileApi, + type AchievementOverview, + type HelpArticle as HelpArticleData, + type HelpCategoryDetail, + type HelpCenterContent, + type ProfileAvatar, + type ProfileInsights, + type ProfileOverview, + type WeeklyGoals, +} from '@/features/profile/ProfileApi'; +import { SecureTokenStore } from '@/infrastructure/auth/SecureTokenStore'; +import { getRuntimeConfig } from '@/infrastructure/config/runtimeConfig'; +import { ApiClient } from '@/infrastructure/http/ApiClient'; import { useAppModel } from '@/model/AppModel'; -import { brandAssets, colors, teachers, type Teacher } from '@/theme/tokens'; +import { brandAssets, colors } from '@/theme/tokens'; export type ProfileRoute = 'home' | 'overview' | 'insights' | 'membership' | 'assistant' | 'account' | 'help' | 'about'; -const email = '123@123.com'; +const trainingTypeLabels: Record = { + FREE_CHAT: '自由对话', + CUSTOM_SCENE: '情景口语', + IELTS_SCENE: '雅思口语', +}; + +const dimensionLabels: Record = { + accuracy: '准确度', + fluency: '流利度', + grammar: '语法', + vocabulary: '词汇', + naturalness: '自然度', +}; + +function errorMessage(error: unknown, fallback: string) { + return error instanceof Error ? error.message : fallback; +} -function StatCard({ icon, label, value, suffix, onPress }: { icon: ReactNode; label: string; value: string | number; suffix: string; onPress?: () => void }) { - const content = <>{icon}{label}{value} {suffix}; - return onPress ? [styles.statCard, pressed && styles.pressed]}>{content} : {content}; +function useProfileApi() { + const { signOut } = useAppModel(); + return useMemo(() => { + const tokenStore = new SecureTokenStore(); + return new ProfileApi( + new ApiClient({ + baseUrl: getRuntimeConfig().backendUrl, + tokenStore, + onUnauthorized: signOut, + }), + ); + }, [signOut]); +} + +function StatCard({ + icon, + label, + value, + suffix, + onPress, +}: { + icon: ReactNode; + label: string; + value: string | number; + suffix: string; + onPress?: () => void; +}) { + const content = ( + <> + {icon} + + {label} + + {value} + {suffix} + + + + ); + return onPress ? ( + [styles.statCard, pressed && styles.pressed]} + > + {content} + + ) : ( + {content} + ); } -function ProfileMenuItem({ icon, title, active = false, onPress }: { icon: ReactNode; title: string; active?: boolean; onPress: () => void }) { - return [styles.profileMenuItem, active && styles.profileMenuItemActive, pressed && styles.pressed]}>{icon}{title}; +function ProfileMenuItem({ + icon, + title, + active = false, + onPress, +}: { + icon: ReactNode; + title: string; + active?: boolean; + onPress: () => void; +}) { + return ( + [ + styles.profileMenuItem, + active && styles.profileMenuItemActive, + pressed && styles.pressed, + ]} + > + {icon} + {title} + + ); } -function ProfileEditModal({ teacher, nickname, onClose, onSave, onTeacherChange }: { teacher: Teacher; nickname: string; onClose: () => void; onSave: (nickname: string) => void; onTeacherChange: (teacher: Teacher) => void }) { +function ProfileEditModal({ + avatarUrl, + fallbackAvatar, + nickname, + onClose, + onSave, +}: { + avatarUrl: string | null; + fallbackAvatar: ImageSourcePropType; + nickname: string; + onClose: () => void; + onSave: (nickname: string, avatar: ProfileAvatar | null) => Promise; +}) { const [draft, setDraft] = useState(nickname); - const [avatarChoicesOpen, setAvatarChoicesOpen] = useState(false); + const [avatar, setAvatar] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(''); + + const selectAvatar = async () => { + let imagePicker: typeof import('expo-image-picker'); + try { + imagePicker = await import('expo-image-picker'); + } catch { + setError('当前客户端不支持选择照片,请更新 Expo Go 或使用最新开发构建'); + return; + } + const permission = await imagePicker.requestMediaLibraryPermissionsAsync(); + if (!permission.granted) { + setError('需要允许访问照片后才能选择头像'); + return; + } + const result = await imagePicker.launchImageLibraryAsync({ + mediaTypes: ['images'], + allowsEditing: true, + aspect: [1, 1], + quality: 0.8, + }); + if (result.canceled) return; + const asset = result.assets[0]; + const mimeType = asset.mimeType ?? ''; + if (!['image/jpeg', 'image/png'].includes(mimeType)) { + setError('请选择 JPEG 或 PNG 图片'); + return; + } + if ((asset.fileSize ?? 0) > 2 * 1024 * 1024) { + setError('图片不能超过 2 MiB'); + return; + } + setError(''); + setAvatar({ + uri: asset.uri, + mimeType, + fileName: asset.fileName ?? `avatar.${mimeType === 'image/png' ? 'png' : 'jpg'}`, + fileSize: asset.fileSize, + }); + }; + + const submit = async () => { + const normalized = draft.trim(); + if (!normalized || normalized.length > 32) { + setError('用户名需为 1 到 32 个字符'); + return; + } + setSubmitting(true); + setError(''); + try { + await onSave(normalized, avatar); + onClose(); + } catch (requestError) { + setError(errorMessage(requestError, '个人资料保存失败')); + } finally { + setSubmitting(false); + } + }; + return ( - + - + - EDIT PROFILE编辑个人资料修改你的展示用户名或个人头像。 + + + EDIT PROFILE + 编辑个人资料 + 修改你的展示用户名或个人头像。 + + + + + - - 个人头像支持 JPEG、PNG,文件不超过 2 MiB setAvatarChoicesOpen((value) => !value)} style={styles.avatarPicker}>选择新头像 + + + 个人头像 + 支持 JPEG、PNG,文件不超过 2 MiB + + 选择新头像 + + + + + 用户名 + + + {error ? ( + + {error} + + ) : null} + + + - {avatarChoicesOpen ? {teachers.map((item) => { onTeacherChange(item); setAvatarChoicesOpen(false); }} style={styles.avatarChoice}>{item.name})} : null} - 用户名 - { onSave(draft.trim()); onClose(); }} style={styles.modalAction} /> ); } -function CalendarCard() { - const [selectedDay, setSelectedDay] = useState(7); - const leadingDays = 5; - const days = Array.from({ length: 31 }, (_, index) => index + 1); +function CalendarCard({ + calendar, + onMonthChange, +}: { + calendar: ProfileOverview['calendar']; + onMonthChange: (month: string) => void; +}) { + const [year, monthNumber] = calendar.month.split('-').map(Number); + const today = new Date(); + const todayDay = today.getFullYear() === year && today.getMonth() + 1 === monthNumber ? today.getDate() : null; + const [selectedDay, setSelectedDay] = useState(todayDay ?? 1); + const leadingDays = (new Date(year, monthNumber - 1, 1).getDay() + 6) % 7; + const daysInMonth = new Date(year, monthNumber, 0).getDate(); + const days = Array.from({ length: daysInMonth }, (_, index) => index + 1); + const checkedDates = new Set(calendar.checkedDates); + const selectedDate = `${calendar.month}-${String(selectedDay).padStart(2, '0')}`; + const selectedChecked = checkedDates.has(selectedDate); + const currentMonth = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`; + const shiftMonth = (offset: number) => { + const shifted = new Date(year, monthNumber - 1 + offset, 1); + onMonthChange(`${shifted.getFullYear()}-${String(shifted.getMonth() + 1).padStart(2, '0')}`); + }; return ( - LEARNING CALENDAR学习日历2026 年 8 月 - {['一', '二', '三', '四', '五', '六', '日'].map((day) => 周{day})} - {Array.from({ length: leadingDays }, (_, index) => )}{days.map((day) => setSelectedDay(day)} style={[styles.calendarCell, selectedDay === day && styles.calendarCellSelected]}>{day}{day === 7 ? 今天 : null})} - {selectedDay === 7 ? '未打卡' : '未打卡'}8 月 {selectedDay} 日这一天还没有五维评分报告 + + + LEARNING CALENDAR + 学习日历 + + + shiftMonth(-1)} style={styles.monthArrow}> + + + + {year} 年 {monthNumber} 月 + + = currentMonth} + onPress={() => shiftMonth(1)} + style={styles.monthArrow} + > + = currentMonth ? colors.line : colors.muted} /> + + + + + {['一', '二', '三', '四', '五', '六', '日'].map((day) => ( + + 周{day} + + ))} + + + {Array.from({ length: leadingDays }, (_, index) => ( + + ))} + {days.map((day) => { + const date = `${calendar.month}-${String(day).padStart(2, '0')}`; + const checked = checkedDates.has(date); + return ( + setSelectedDay(day)} + style={[ + styles.calendarCell, + selectedDay === day && styles.calendarCellSelected, + checked && styles.calendarCellChecked, + ]} + > + {day} + {day === todayDay ? ( + 今天 + ) : checked ? ( + + ) : null} + + ); + })} + + + + + {selectedChecked ? '已打卡' : '未打卡'} + + + + {monthNumber} 月 {selectedDay} 日 + + + {selectedChecked ? '已生成五维评分报告,自动打卡完成' : '这一天还没有五维评分报告'} + + + ); } -function AchievementSummary() { - const items = [ - { title: '对话历程', category: '开口', icon: , next: '初次开口', unit: '次' }, - { title: '连续学习', category: '连续', icon: , next: '三日启程', unit: '天' }, - { title: '场景探索', category: '场景', icon: , next: '场景初探', unit: '个' }, - { title: '表达质量', category: '成长', icon: , next: '表达进阶', unit: '分' }, - ]; - return ACHIEVEMENTS成就图鉴每一级进步,都由你真实的练习记录点亮。0 / 48 已获得{['全部 10', '开口 2', '连续 3', '场景 2', '成长 3'].map((item, index) => {item.split(' ')[0]}{item.split(' ')[1]})}{items.map((item) => {item.icon}{item.category}{item.title}Lv.0当前等级尚未解锁完成“{item.next}”后即可点亮该系列当前进度下一阶段0 {item.unit}{item.next}查看全部 5 个等级)}; +function AchievementSummary({ + overview, + loading, + error, + onRetry, +}: { + overview: AchievementOverview | null; + loading: boolean; + error: string; + onRetry: () => void; +}) { + const series = overview?.series ?? []; + const milestones = series.flatMap((item) => item.milestones); + const unlockedCount = milestones.filter((item) => item.unlocked).length; + return ( + + + + ACHIEVEMENTS + 成就图鉴 + 每一级进步,都由你真实的练习记录点亮。 + + {!loading && !error ? ( + + {unlockedCount} + / {milestones.length} 已获得 + + ) : null} + + {loading ? ( + + + 正在计算成就进度 + + ) : error ? ( + + {error} + + + ) : series.length === 0 ? ( + + 成就目录暂时为空 + + ) : ( + + {series.map((item) => { + const maximum = Number(item.nextThreshold ?? item.currentValue ?? 1); + return ( + + + + + + + {item.category} + {item.title} + + {item.completed ? '全部达成' : `Lv.${item.currentLevel}`} + + + 当前等级 + {item.currentTitle ?? '尚未解锁'} + + {item.completed ? '该系列所有成就已解锁' : `下一阶段:${item.nextTitle ?? '待解锁'}`} + + + + + 当前进度 + {item.completed ? '完成状态' : '下一阶段'} + + + + + {item.currentValue} {item.unit} + + + {item.completed ? '已完成' : (item.nextTitle ?? '待解锁')} + + + + + 共 {item.milestones.length} 个等级 + + + ); + })} + + )} + + ); } export function Overview({ onBack }: { onBack: () => void }) { - const { sceneRecords, ieltsRecords, interviewRecords } = useAppModel(); - const trainingRecordCount = sceneRecords.length + ieltsRecords.length + interviewRecords.length; - const weeklyMinutes = 74; - const consecutiveLearningDays = 0; + const api = useProfileApi(); + const now = new Date(); + const [month, setMonth] = useState(`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`); + const [overview, setOverview] = useState(null); + const [achievements, setAchievements] = useState(null); + const [loading, setLoading] = useState(true); + const [overviewError, setOverviewError] = useState(''); + const [overviewRetry, setOverviewRetry] = useState(0); + const [achievementError, setAchievementError] = useState(''); + const [achievementRetry, setAchievementRetry] = useState(0); + + useEffect(() => { + let cancelled = false; + api + .getOverview(month) + .then((value) => { + if (!cancelled) setOverview(value); + }) + .catch((error) => { + if (!cancelled) setOverviewError(errorMessage(error, '个人概览加载失败')); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [api, month, overviewRetry]); + + useEffect(() => { + let cancelled = false; + api + .getAchievements() + .then((value) => { + if (!cancelled) setAchievements(value); + }) + .catch((error) => { + if (!cancelled) setAchievementError(errorMessage(error, '成就数据加载失败')); + }); + return () => { + cancelled = true; + }; + }, [api, achievementRetry]); + + const statistics = overview?.statistics; + const days = statistics?.lastSevenDays ?? []; + const maximumSeconds = Math.max(1, ...days.map((day) => day.practiceSeconds)); + const changeMonth = (nextMonth: string) => { + setOverview(null); + setOverviewError(''); + setLoading(true); + setMonth(nextMonth); + }; + const retryOverview = () => { + setOverview(null); + setOverviewError(''); + setLoading(true); + setOverviewRetry((value) => value + 1); + }; + const retryAchievements = () => { + setAchievements(null); + setAchievementError(''); + setAchievementRetry((value) => value + 1); + }; return ( - }> + } + > PERSONAL OVERVIEW 你的学习空间 把每一次开口变成看得见、可继续的成长记录。 - } label="本周学习时长" value={weeklyMinutes} suffix="分钟" />} label="已保存学习资产" value={trainingRecordCount} suffix="项" />} label="连续学习天数" value={consecutiveLearningDays} suffix="天" /> - LAST SEVEN DAYS练习节奏{[0, 0, 0, 0, 0, 0, 0].map((value, index) => 0 && styles.rhythmBarActive]} />{value}m{['周六', '周日', '周一', '周二', '周三', '周四', '周五'][index]})} - + {loading && !overview ? ( + + + 正在加载真实学习数据 + + ) : overviewError ? ( + + {overviewError} + + + ) : overview ? ( + <> + + } + label="本周学习时长" + value={Math.ceil(statistics!.weeklyPracticeSeconds / 60)} + suffix="分钟" + /> + } + label="已保存学习资产" + value={statistics!.trainingRecordCount} + suffix="项" + /> + } + label="连续学习天数" + value={statistics!.consecutiveLearningDays} + suffix="天" + /> + + + + + LAST SEVEN DAYS + 练习节奏 + + {days.map((day) => { + const minutes = day.practiceSeconds > 0 ? Math.ceil(day.practiceSeconds / 60) : 0; + const weekday = new Intl.DateTimeFormat('zh-CN', { + weekday: 'short', + }).format(new Date(`${day.date}T12:00:00`)); + return ( + + 0 && styles.rhythmBarActive, + ]} + /> + {minutes}m + {weekday} + + ); + })} + + + + + ) : null} + ); } export function Insights({ onBack }: { onBack: () => void }) { + const api = useProfileApi(); const [goalsOpen, setGoalsOpen] = useState(false); - return setGoalsOpen(true)} style={styles.headerAction}>调整目标} />}>LEARNING INSIGHTS学习目标与洞察8 月 3 日 至 8 月 9 日口语时长进行中0%0 / 120 分钟还差 120 分钟训练次数进行中0%0 / 5 次还差 5 次TRAINING MIX本周训练类型占比按有效训练时长统计本周暂无有效训练记录{goalsOpen ? setGoalsOpen(false)} /> : null}; + const [insights, setInsights] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [retry, setRetry] = useState(0); + useEffect(() => { + let cancelled = false; + api + .getInsights() + .then((value) => { + if (!cancelled) setInsights(value); + }) + .catch((requestError) => { + if (!cancelled) setError(errorMessage(requestError, '学习目标加载失败')); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [api, retry]); + + const goals = insights?.weeklyGoals; + const completedMinutes = Math.ceil((goals?.completedDurationSeconds ?? 0) / 60); + const formatDate = (value: string) => + new Intl.DateTimeFormat('zh-CN', { month: 'long', day: 'numeric' }).format(new Date(value)); + const weekRange = goals + ? `${formatDate(goals.weekStartsAt)} 至 ${formatDate(new Date(new Date(goals.weekEndsAt).getTime() - 1).toISOString())}` + : '本周'; + const latestTrend = insights?.abilityTrends.at(-1); + const scoreEntries = latestTrend + ? Object.entries(latestTrend.scores).filter((entry): entry is [string, number] => typeof entry[1] === 'number') + : []; + const saveGoals = async (value: { durationTargetMinutes: number; trainingCountTarget: number }) => { + setInsights(await api.updateWeeklyGoals(value)); + }; + const retryInsights = () => { + setError(''); + setLoading(true); + setRetry((value) => value + 1); + }; + + return ( + setGoalsOpen(true)} style={styles.headerAction}> + + 调整目标 + + ) : null + } + /> + } + > + LEARNING INSIGHTS + 学习目标与洞察 + {weekRange} + {loading ? ( + + + 正在加载学习洞察 + + ) : error ? ( + + {error} + + + ) : goals && insights ? ( + <> + + + + + + + + 口语时长 + {goals.durationAchieved ? '已达标' : '进行中'} + + {Math.round(goals.durationProgress * 10) / 10}% + + + {completedMinutes} + / {goals.durationTargetMinutes} 分钟 + + + + {goals.durationAchieved + ? '本周目标已完成' + : `还差 ${Math.ceil(goals.remainingDurationSeconds / 60)} 分钟`} + + + + + + + + + 训练次数 + {goals.countAchieved ? '已达标' : '进行中'} + + {Math.round(goals.countProgress * 10) / 10}% + + + {goals.completedTrainingCount} + / {goals.trainingCountTarget} 次 + + + + {goals.countAchieved ? '本周目标已完成' : `还差 ${goals.remainingTrainingCount} 次`} + + + + + + + TRAINING MIX + 本周训练类型占比 + + 按有效训练时长统计 + + {insights.trainingTypeDistribution.length ? ( + + {insights.trainingTypeDistribution + .filter((item) => item.durationSeconds > 0) + .map((item) => ( + + + {trainingTypeLabels[item.type] ?? '其他训练'} + {Math.ceil(item.durationSeconds / 60)} 分钟 + + {Math.round(item.percentage * 10) / 10}% + + ))} + + ) : ( + + + 本周暂无有效训练记录 + + )} + + + {scoreEntries.length ? ( + + {scoreEntries.map(([key, value]) => ( + + {dimensionLabels[key] ?? key} + {Number(value).toFixed(1)} + + ))} + + ) : ( + + 完成训练后将显示能力趋势 + + )} + + + {insights.weaknessAnalysis.reliable ? ( + + {insights.weaknesses.map((item) => ( + + + {dimensionLabels[item.dimension.toLowerCase()] ?? item.dimension} ·{' '} + {Number(item.averageScore).toFixed(1)} + + {item.basis} + + ))} + {insights.recommendations.map((item, index) => ( + + {trainingTypeLabels[item.trainingType] ?? '推荐训练'} + {item.reason} + + ))} + + ) : ( + + + 已有 {insights.weaknessAnalysis.sampleCount} 份样本,至少需要{' '} + {insights.weaknessAnalysis.minimumSampleCount} 份才能生成可靠建议 + + + )} + + ) : null} + {goalsOpen && goals ? ( + setGoalsOpen(false)} /> + ) : null} + + ); } -function WeeklyGoalsModal({ onClose }: { onClose: () => void }) { - const [minutes, setMinutes] = useState('120'); - const [sessions, setSessions] = useState('5'); - return WEEKLY GOALS调整每周目标口语时长分钟 / 周训练次数次 / 周; +function WeeklyGoalsModal({ + goals, + onClose, + onSave, +}: { + goals: WeeklyGoals; + onClose: () => void; + onSave: (value: { durationTargetMinutes: number; trainingCountTarget: number }) => Promise; +}) { + const [minutes, setMinutes] = useState(String(goals.durationTargetMinutes)); + const [sessions, setSessions] = useState(String(goals.trainingCountTarget)); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + const submit = async () => { + const durationTargetMinutes = Number(minutes); + const trainingCountTarget = Number(sessions); + if (!Number.isInteger(durationTargetMinutes) || durationTargetMinutes < 1 || durationTargetMinutes > 1260) { + setError('时长目标需在 1 到 1260 分钟之间'); + return; + } + if (!Number.isInteger(trainingCountTarget) || trainingCountTarget < 1 || trainingCountTarget > 70) { + setError('训练次数需在 1 到 70 次之间'); + return; + } + setSaving(true); + setError(''); + try { + await onSave({ durationTargetMinutes, trainingCountTarget }); + onClose(); + } catch (requestError) { + setError(errorMessage(requestError, '目标保存失败')); + } finally { + setSaving(false); + } + }; + return ( + + + + + + + WEEKLY GOALS + 调整每周目标 + + + + + + + 口语时长 + + + 分钟 / 周 + + + + 训练次数 + + + 次 / 周 + + + {error ? ( + + {error} + + ) : null} + + + + + + + + ); } export function Membership({ onBack }: { onBack: () => void }) { - const { membership, setMembership } = useAppModel(); const plans = [ - { name: '免费版', price: '0', note: '适合轻量体验与每日开口', features: ['每天 5 分钟自由对话', '每天 1 次普通场景', '全部六位 AI 老师'] }, - { name: '专业版', price: '48', note: '适合稳定提升日常与职场口语', features: ['每月 600 分钟自由对话', '每月 50 次普通场景', '全部六位 AI 老师'] }, - { name: '特训版', price: '198', note: '适合雅思备考与英文面试', features: ['包含专业版全部权益', 'IELTS Part 1 / 2 / 3 模拟', '英文面试与材料分析', '每天 5 次特训,共用 150 次/月'] }, + { + name: '免费版', + price: '0', + note: '适合轻量体验与每日开口', + features: ['每天 5 分钟自由对话', '每天 1 次普通场景', '全部六位 AI 老师'], + }, + { + name: '专业版', + price: '48', + note: '适合稳定提升日常与职场口语', + features: ['每月 600 分钟自由对话', '每月 50 次普通场景', '全部六位 AI 老师'], + }, + { + name: '特训版', + price: '198', + note: '适合雅思备考与英文面试', + features: [ + '包含专业版全部权益', + 'IELTS Part 1 / 2 / 3 模拟', + '英文面试与材料分析', + '每天 5 次特训,共用 150 次/月', + ], + }, ]; - return }>MEMBERSHIP & PRICING会员与订阅中心练习额度平时不会打扰你,只会在不足 20% 或无法开始时提醒。{plans.map((plan) => { const selected = membership === plan.name; return {selected ? 当前方案 : plan.name === '专业版' ? 推荐 : null}{plan.name}{plan.note}¥{plan.price}/月{plan.features.map((feature) => {feature})} setMembership(plan.name)} />; })}; + return ( + } + > + MEMBERSHIP & PRICING + 会员与订阅中心 + 会员支付接口尚未开放,当前仅展示方案,不会伪造升级结果。 + + {plans.map((plan) => { + const selected = plan.name === '免费版'; + return ( + + + {selected ? 当前方案 : plan.name === '专业版' ? 推荐 : null} + {plan.name} + {plan.note} + + + ¥ + {plan.price} + /月 + + + {plan.features.map((feature) => ( + + + {feature} + + ))} + + + + ); + })} + + + ); } export function AssistantSettings({ onBack }: { onBack: () => void }) { - const { speed, setSpeed, level, setLevel, teacher, setTeacher } = useAppModel(); - const [translation, setTranslation] = useState(true); - const [sound, setSound] = useState(true); - return 设置已同步} />}>ASSISTANT SETTINGSAI 助手设置只调整真正影响对话体验的选项。对话语速选择更舒适的回应节奏。英语水平新对话会按照该难度调整表达。AI 老师每位老师有固定口音和陪练方式。自动显示翻译新字幕出现时同时显示中文参考。自动播放示范音频训练步骤切换后自动播放 AI 示范。; + const { speed, saveSpeed, level, saveLevel, teacher, saveTeacher } = useAppModel(); + const [syncState, setSyncState] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); + const save = async (operation: () => Promise) => { + setSyncState('saving'); + try { + await operation(); + setSyncState('saved'); + } catch (error) { + setSyncState('error'); + Alert.alert('设置保存失败', errorMessage(error, '请稍后重试')); + } + }; + const statusText = syncState === 'saving' ? '正在同步' : syncState === 'error' ? '同步失败' : '设置已同步'; + return ( + + {syncState === 'saving' ? '…' : syncState === 'error' ? '!' : '✓'} + {statusText} + + } + /> + } + > + ASSISTANT SETTINGS + AI 助手设置 + 设置会同步到后端,并在 Web 端和移动端保持一致。 + + + + 对话语速 + 选择更舒适的回应节奏。 + + { + void save(() => saveSpeed(value)); + }} + /> + + + + 英语水平 + 新对话会按照该难度调整表达。 + + { + void save(() => saveLevel(value)); + }} + /> + + + + AI 老师 + 每位老师有固定口音和陪练方式。 + + { + void save(() => saveTeacher(value)); + }} + /> + + + + ); } export function AccountSettings({ onBack, onLogout }: { onBack: () => void; onLogout?: () => void }) { - const { nickname, setNickname } = useAppModel(); + const api = useProfileApi(); + const { nickname, setNickname, email } = useAppModel(); const [draft, setDraft] = useState(nickname); - return }>ACCOUNT & SECURITY账号与安全管理登录凭据与当前登录状态 undefined} />展示用户名 setNickname(draft.trim() || nickname)} />; + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + const [nicknameOpen, setNicknameOpen] = useState(false); + const [passwordOpen, setPasswordOpen] = useState(false); + const saveNickname = async () => { + const normalized = draft.trim(); + if (!normalized || normalized.length > 32) { + setError('用户名需为 1 到 32 个字符'); + return; + } + setSaving(true); + setError(''); + try { + const updated = await api.updateNickname(normalized); + setNickname(updated.nickname); + setDraft(updated.nickname); + setNicknameOpen(false); + } catch (requestError) { + setError(errorMessage(requestError, '用户名保存失败')); + } finally { + setSaving(false); + } + }; + const changePassword = async (input: { currentPassword: string; newPassword: string }) => { + await api.changePassword(input); + setPasswordOpen(false); + Alert.alert('密码已修改', '请使用新密码重新登录'); + await onLogout?.(); + }; + return ( + }> + + + + + ACCOUNT & SECURITY + 账号与安全 + 管理登录凭据与当前登录状态 + + + + + { + setDraft(nickname); + setError(''); + setNicknameOpen(true); + }} + /> + setPasswordOpen(true)} + /> + + + + + + + {nicknameOpen ? ( + setNicknameOpen(false)} + onSubmit={saveNickname} + /> + ) : null} + {passwordOpen ? setPasswordOpen(false)} onSubmit={changePassword} /> : null} + + ); +} + +function NicknameChangeModal({ + draft, + error, + saving, + onChange, + onClose, + onSubmit, +}: { + draft: string; + error: string; + saving: boolean; + onChange: (value: string) => void; + onClose: () => void; + onSubmit: () => void; +}) { + return ( + + + + + + + 修改用户名 + 用户名将同步显示在个人概览中。 + + + + + + + 展示用户名 + + + {error ? {error} : null} + + + + + + + + ); +} + +function PasswordChangeModal({ + onClose, + onSubmit, +}: { + onClose: () => void; + onSubmit: (input: { currentPassword: string; newPassword: string }) => Promise; +}) { + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + const submit = async () => { + if (newPassword !== confirmPassword) { + setError('两次输入的新密码不一致'); + return; + } + if (currentPassword.length < 6 || newPassword.length < 6 || newPassword.length > 72) { + setError('密码长度需为 6 到 72 位'); + return; + } + setSaving(true); + setError(''); + try { + await onSubmit({ currentPassword, newPassword }); + } catch (requestError) { + setError(errorMessage(requestError, '密码修改失败')); + setSaving(false); + } + }; + return ( + + + + + + + ACCOUNT SECURITY + 修改密码 + 修改成功后,所有设备都需要重新登录。 + + + + + + + 当前密码 + + + + 新密码 + + + + 确认新密码 + + + {error ? ( + + {error} + + ) : null} + + + + + + + + ); +} + +export function HelpCenter({ + onBack, + onOpenCategory, +}: { + onBack: () => void; + onOpenCategory: (categoryId: string) => void; +}) { + const api = useProfileApi(); + const [content, setContent] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const load = useCallback(async () => { + setLoading(true); + try { + const value = await api.getHelpCenter(); + setContent(value); + setError(''); + } catch (requestError) { + setError(errorMessage(requestError, '帮助内容加载失败')); + } finally { + setLoading(false); + } + }, [api]); + useEffect(() => { + let cancelled = false; + api + .getHelpCenter() + .then((value) => { + if (cancelled) return; + setContent(value); + setError(''); + }) + .catch((requestError) => { + if (!cancelled) setError(errorMessage(requestError, '帮助内容加载失败')); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [api]); + + return ( + }> + HELP CENTER + 帮助中心 + 遇到问题时,从这里找到清晰的解决路径。 + {loading ? ( + + + 正在加载帮助内容 + + ) : error ? ( + + {error} + void load()} /> + + ) : ( + + {(content?.categories ?? []).map((category) => ( + onOpenCategory(category.id)} + style={({ pressed }) => pressed && styles.pressed} + > + + + {category.title} + {category.description} + 共 {category.articleCount} 篇说明 + + + + + ))} + + )} + + + + 仍然需要帮助? + 联系 UniSpeaking 支持团队,我们会继续协助你。 + + + + ); +} + +export function HelpCategory({ + categoryId, + onBack, + onOpenArticle, +}: { + categoryId: string; + onBack: () => void; + onOpenArticle: (articleId: string) => void; +}) { + const api = useProfileApi(); + const [category, setCategory] = useState(null); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(true); + const load = useCallback(async () => { + setLoading(true); + try { + setCategory(await api.getHelpCategory(categoryId)); + setError(''); + } catch (requestError) { + setError(errorMessage(requestError, '帮助分类加载失败')); + } finally { + setLoading(false); + } + }, [api, categoryId]); + useEffect(() => { + let cancelled = false; + api.getHelpCategory(categoryId) + .then((value) => { + if (!cancelled) setCategory(value); + }) + .catch((requestError) => { + if (!cancelled) setError(errorMessage(requestError, '帮助分类加载失败')); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { cancelled = true; }; + }, [api, categoryId]); + + return ( + } + > + {loading ? ( + + ) : error ? ( + + {error} + void load()} /> + + ) : category ? ( + <> + {category.title} + {category.description} + + {category.articles.map((article) => ( + onOpenArticle(article.id)} + style={({ pressed }) => pressed && styles.pressed} + > + + + {article.title} + {article.summary} + + + + + ))} + + + ) : null} + + ); } -export function HelpCenter({ onBack }: { onBack: () => void }) { - const helpItems = [['常见问题', '了解训练、报告和学习资产的使用方式'], ['训练与报告', '查看训练记录、评分和复练入口'], ['账户与安全', '管理登录信息、设置和跨端同步']]; - return }>HELP CENTER帮助中心遇到问题时,从这里找到清晰的解决路径。{helpItems.map(([title, note]) => {title}{note})}仍然需要帮助?联系 UniSpeaking 支持团队,我们会继续协助你。; +export function HelpArticle({ + articleId, + onBack, +}: { + articleId: string; + onBack: () => void; +}) { + const api = useProfileApi(); + const [article, setArticle] = useState(null); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(true); + const load = useCallback(async () => { + setLoading(true); + try { + setArticle(await api.getHelpArticle(articleId)); + setError(''); + } catch (requestError) { + setError(errorMessage(requestError, '帮助文章加载失败')); + } finally { + setLoading(false); + } + }, [api, articleId]); + useEffect(() => { + let cancelled = false; + api.getHelpArticle(articleId) + .then((value) => { + if (!cancelled) setArticle(value); + }) + .catch((requestError) => { + if (!cancelled) setError(errorMessage(requestError, '帮助文章加载失败')); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { cancelled = true; }; + }, [api, articleId]); + + return ( + } + > + {loading ? ( + + ) : error ? ( + + {error} + void load()} /> + + ) : article ? ( + <> + {article.title} + 更新时间:{article.updatedAt} + + 说明 + {article.summary} + + + ) : null} + + ); } export function AboutProduct({ onBack }: { onBack: () => void }) { - return }>ABOUT UNISPEAKING关于 UniSpeaking专注真实表达的 AI 英语口语训练工具当前版本v1.0产品形态Mobile App客服邮箱support@unispeaking.example更新方式自动更新; + return ( + } + > + + + + + ABOUT UNISPEAKING + 关于 UniSpeaking + 专注真实表达的 AI 英语口语训练工具 + + + + + 当前版本 + v1.0 + + + 产品形态 + Mobile App + + + 客服邮箱 + support@unispeaking.example + + + 更新方式 + 自动更新 + + + + ); } -export function ProfileHome({ onOpen, onLogout }: { onOpen: (route: ProfileRoute) => void; onLogout?: () => void }) { - const { nickname, setNickname, teacher, setTeacher } = useAppModel(); +export function ProfileHome({ + activeRoute = 'overview', + onOpen, + onLogout, +}: { + activeRoute?: ProfileRoute; + onOpen: (route: ProfileRoute) => void; + onLogout?: () => void; +}) { + const api = useProfileApi(); + const { nickname, setNickname, email, teacher } = useAppModel(); const [editOpen, setEditOpen] = useState(false); - return setEditOpen(true)} style={styles.profileEdit}>{nickname}{email}} title="个人概览" active onPress={() => onOpen('overview')} />} title="学习目标与洞察" onPress={() => onOpen('insights')} />} title="会员权益" onPress={() => onOpen('membership')} />} title="助手设置" onPress={() => onOpen('assistant')} />} title="账号与安全" onPress={() => onOpen('account')} />} title="帮助中心" onPress={() => onOpen('help')} />} title="关于产品" onPress={() => onOpen('about')} />退出登录{editOpen ? setEditOpen(false)} onSave={(value) => setNickname(value || nickname)} onTeacherChange={setTeacher} /> : null}; + const [overview, setOverview] = useState(null); + const [error, setError] = useState(''); + const loadOverview = useCallback(async () => { + try { + const value = await api.getOverview(); + setError(''); + setOverview(value); + setNickname(value.account.nickname ?? value.account.displayName); + } catch (requestError) { + setError(errorMessage(requestError, '个人资料加载失败')); + } + }, [api, setNickname]); + useEffect(() => { + let cancelled = false; + api + .getOverview() + .then((value) => { + if (cancelled) return; + setError(''); + setOverview(value); + setNickname(value.account.nickname ?? value.account.displayName); + }) + .catch((requestError) => { + if (!cancelled) setError(errorMessage(requestError, '个人资料加载失败')); + }); + return () => { + cancelled = true; + }; + }, [api, setNickname]); + const saveProfile = async (nextNickname: string, avatar: ProfileAvatar | null) => { + if (nextNickname !== (overview?.account.nickname ?? nickname)) { + const updated = await api.updateNickname(nextNickname); + setNickname(updated.nickname); + } + if (avatar) await api.uploadAvatar(avatar); + await loadOverview(); + }; + const account = overview?.account; + const displayName = account?.displayName || nickname || email.split('@')[0] || 'UniSpeaking User'; + const accountEmail = account?.email || email; + const avatarSource = account?.avatarUrl ? { uri: account.avatarUrl } : teacher.image; + return ( + + + + + + + setEditOpen(true)} + style={styles.profileEdit} + > + + + + + {displayName} + {accountEmail} + {error ? ( + + {error} + + ) : null} + + + + } + title="个人概览" + active={activeRoute === 'overview'} + onPress={() => onOpen('overview')} + /> + } + title="学习目标与洞察" + active={activeRoute === 'insights'} + onPress={() => onOpen('insights')} + /> + } + title="会员权益" + active={activeRoute === 'membership'} + onPress={() => onOpen('membership')} + /> + } + title="助手设置" + active={activeRoute === 'assistant'} + onPress={() => onOpen('assistant')} + /> + } + title="账号与安全" + active={activeRoute === 'account'} + onPress={() => onOpen('account')} + /> + } + title="帮助中心" + active={activeRoute === 'help'} + onPress={() => onOpen('help')} + /> + } + title="关于产品" + active={activeRoute === 'about'} + onPress={() => onOpen('about')} + /> + + + + 退出登录 + + {editOpen ? ( + setEditOpen(false)} + onSave={saveProfile} + /> + ) : null} + + ); } export function ProfileScreen() { @@ -162,133 +1791,526 @@ export function ProfileScreen() { if (route === 'membership') return setRoute('home')} />; if (route === 'assistant') return setRoute('home')} />; if (route === 'account') return setRoute('home')} />; - if (route === 'help') return setRoute('home')} />; + if (route === 'help') return setRoute('home')} onOpenCategory={() => undefined} />; if (route === 'about') return setRoute('home')} />; return ; } const styles = StyleSheet.create({ pageContent: { paddingBottom: 110 }, - profileContent: { minHeight: '100%', paddingHorizontal: 28, paddingTop: 32, paddingBottom: 110 }, + profileContent: { + minHeight: '100%', + paddingHorizontal: 28, + paddingTop: 32, + paddingBottom: 110, + }, flex: { flex: 1 }, - rowBetween: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, + rowBetween: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, pressed: { opacity: 0.7, transform: [{ scale: 0.985 }] }, - profileUser: { minHeight: 116, paddingHorizontal: 2, flexDirection: 'row', alignItems: 'center', gap: 18 }, + profileUser: { + minHeight: 116, + paddingHorizontal: 2, + flexDirection: 'row', + alignItems: 'center', + gap: 18, + }, profileAvatarWrap: { position: 'relative', width: 92, height: 92 }, - profileAvatar: { width: 92, height: 92, overflow: 'hidden', alignItems: 'center', justifyContent: 'flex-end', borderRadius: 46, backgroundColor: colors.soft }, + profileAvatar: { + width: 92, + height: 92, + overflow: 'hidden', + alignItems: 'center', + justifyContent: 'flex-end', + borderRadius: 46, + backgroundColor: colors.soft, + }, profileAvatarImage: { width: 92, height: 112, marginBottom: -12 }, - profileEdit: { position: 'absolute', top: -4, right: -4, width: 36, height: 36, alignItems: 'center', justifyContent: 'center', borderWidth: 1, borderColor: colors.white, borderRadius: 18, backgroundColor: colors.white, shadowColor: colors.ink, shadowOpacity: 0.12, shadowRadius: 8, elevation: 3 }, - profileName: { color: colors.ink, fontSize: 28, lineHeight: 34, fontWeight: '600' }, - profileEmail: { marginTop: 7, color: colors.muted, fontSize: 16, lineHeight: 22, fontWeight: '300' }, + profileEdit: { + position: 'absolute', + top: -4, + right: -4, + width: 36, + height: 36, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + borderColor: colors.white, + borderRadius: 18, + backgroundColor: colors.white, + shadowColor: colors.ink, + shadowOpacity: 0.12, + shadowRadius: 8, + elevation: 3, + }, + profileName: { + color: colors.ink, + fontSize: 28, + lineHeight: 34, + fontWeight: '600', + }, + profileEmail: { + marginTop: 7, + color: colors.muted, + fontSize: 16, + lineHeight: 22, + fontWeight: '300', + }, + profileError: { + marginTop: 4, + color: colors.red, + fontSize: 11, + lineHeight: 16, + }, profileMenu: { marginTop: 26, gap: 7 }, - profileMenuItem: { minHeight: 58, paddingHorizontal: 16, flexDirection: 'row', alignItems: 'center', gap: 14, borderRadius: 14 }, + profileMenuItem: { + minHeight: 58, + paddingHorizontal: 16, + flexDirection: 'row', + alignItems: 'center', + gap: 14, + borderRadius: 14, + }, profileMenuItemActive: { backgroundColor: colors.soft }, profileMenuIcon: { width: 28, alignItems: 'center' }, - profileMenuTitle: { color: colors.ink, fontSize: 20, lineHeight: 28, fontWeight: '400' }, - logout: { minHeight: 52, marginTop: 26, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 9, borderRadius: 14, backgroundColor: colors.redSoft }, + profileMenuTitle: { + color: colors.ink, + fontSize: 20, + lineHeight: 28, + fontWeight: '400', + }, + logout: { + minHeight: 52, + marginTop: 26, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 9, + borderRadius: 14, + backgroundColor: colors.redSoft, + }, logoutText: { color: colors.red, fontSize: 13, fontWeight: '500' }, - pageEyebrow: { marginTop: 9, color: colors.subtle, fontSize: 11, fontWeight: '600', letterSpacing: 1.7 }, - eyebrow: { color: colors.subtle, fontSize: 10, fontWeight: '600', letterSpacing: 1.7 }, - pageTitle: { marginTop: 8, color: colors.ink, fontSize: 35, lineHeight: 43, fontWeight: '600', letterSpacing: -1.2 }, - pageSubtitle: { marginTop: 7, color: colors.muted, fontSize: 15, lineHeight: 22, fontWeight: '300' }, + pageEyebrow: { + marginTop: 9, + color: colors.subtle, + fontSize: 11, + fontWeight: '600', + letterSpacing: 1.7, + }, + eyebrow: { + color: colors.subtle, + fontSize: 10, + fontWeight: '600', + letterSpacing: 1.7, + }, + pageTitle: { + marginTop: 8, + color: colors.ink, + fontSize: 35, + lineHeight: 43, + fontWeight: '600', + letterSpacing: 0, + }, + pageSubtitle: { + marginTop: 7, + color: colors.muted, + fontSize: 15, + lineHeight: 22, + fontWeight: '300', + }, statGrid: { marginTop: 24, gap: 10 }, - statCard: { minHeight: 92, padding: 16, flexDirection: 'row', alignItems: 'center', gap: 13, borderWidth: 1, borderColor: colors.line, borderRadius: 17, backgroundColor: colors.white }, - statIcon: { width: 38, height: 38, alignItems: 'center', justifyContent: 'center' }, + statCard: { + minHeight: 92, + padding: 16, + flexDirection: 'row', + alignItems: 'center', + gap: 13, + borderWidth: 1, + borderColor: colors.line, + borderRadius: 17, + backgroundColor: colors.white, + }, + statIcon: { + width: 38, + height: 38, + alignItems: 'center', + justifyContent: 'center', + }, statLabel: { color: colors.muted, fontSize: 13, fontWeight: '300' }, - statValue: { marginTop: 3, color: colors.ink, fontSize: 26, lineHeight: 31, fontWeight: '600' }, + statValue: { + marginTop: 3, + color: colors.ink, + fontSize: 26, + lineHeight: 31, + fontWeight: '600', + }, statSuffix: { fontSize: 12, fontWeight: '500' }, overviewGrid: { marginTop: 16, gap: 16 }, calendarCard: { gap: 16 }, - calendarHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8 }, - sectionHeadingLarge: { marginTop: 5, color: colors.ink, fontSize: 25, lineHeight: 31, fontWeight: '600' }, - monthSwitcher: { minHeight: 40, paddingHorizontal: 7, flexDirection: 'row', alignItems: 'center', gap: 4, borderWidth: 1, borderColor: colors.line, borderRadius: 22 }, - monthArrow: { width: 26, height: 28, alignItems: 'center', justifyContent: 'center' }, + calendarHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 8, + }, + sectionHeadingLarge: { + marginTop: 5, + color: colors.ink, + fontSize: 25, + lineHeight: 31, + fontWeight: '600', + }, + monthSwitcher: { + minHeight: 40, + paddingHorizontal: 7, + flexDirection: 'row', + alignItems: 'center', + gap: 4, + borderWidth: 1, + borderColor: colors.line, + borderRadius: 22, + }, + monthArrow: { + width: 26, + height: 28, + alignItems: 'center', + justifyContent: 'center', + }, monthLabel: { color: colors.ink, fontSize: 13, fontWeight: '600' }, calendarWeekdays: { flexDirection: 'row', justifyContent: 'space-between' }, - calendarWeekday: { width: 30, color: colors.subtle, fontSize: 10, textAlign: 'center', fontWeight: '300' }, + calendarWeekday: { + width: 30, + color: colors.subtle, + fontSize: 10, + textAlign: 'center', + fontWeight: '300', + }, calendarGrid: { flexDirection: 'row', flexWrap: 'wrap', rowGap: 8 }, - calendarCell: { width: '14.285%', minHeight: 34, alignItems: 'center', justifyContent: 'center', borderRadius: 9 }, + calendarCell: { + width: '14.285%', + minHeight: 34, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 9, + }, + calendarCellChecked: { borderWidth: 1, borderColor: colors.green }, calendarCellSelected: { backgroundColor: colors.ink }, calendarDay: { color: colors.muted, fontSize: 12, fontWeight: '300' }, calendarDaySelected: { color: colors.white, fontWeight: '600' }, calendarToday: { marginTop: 1, color: colors.white, fontSize: 7 }, - calendarSummary: { minHeight: 60, paddingHorizontal: 12, flexDirection: 'row', alignItems: 'center', gap: 10, borderRadius: 15, backgroundColor: colors.soft }, + calendarCheckDot: { + width: 4, + height: 4, + marginTop: 2, + borderRadius: 2, + backgroundColor: colors.green, + }, + calendarSummary: { + minHeight: 60, + paddingHorizontal: 12, + flexDirection: 'row', + alignItems: 'center', + gap: 10, + borderRadius: 15, + backgroundColor: colors.soft, + }, calendarSummaryActive: { backgroundColor: colors.soft }, - calendarStatus: { minHeight: 34, paddingHorizontal: 10, flexDirection: 'row', alignItems: 'center', gap: 5, borderRadius: 17, backgroundColor: '#EDEDE9' }, + calendarStatus: { + minHeight: 34, + paddingHorizontal: 10, + flexDirection: 'row', + alignItems: 'center', + gap: 5, + borderRadius: 17, + backgroundColor: '#EDEDE9', + }, calendarStatusText: { color: colors.muted, fontSize: 11, fontWeight: '500' }, calendarSummaryDate: { color: colors.ink, fontSize: 15, fontWeight: '600' }, - calendarSummaryNote: { marginTop: 2, color: colors.muted, fontSize: 11, fontWeight: '300' }, + calendarSummaryNote: { + marginTop: 2, + color: colors.muted, + fontSize: 11, + fontWeight: '300', + }, rhythmCard: { minHeight: 220, gap: 6 }, - rhythmBars: { minHeight: 140, paddingTop: 16, flexDirection: 'row', alignItems: 'flex-end', justifyContent: 'space-between', gap: 5 }, - rhythmBarColumn: { flex: 1, alignItems: 'center', justifyContent: 'flex-end', gap: 4 }, - rhythmBar: { width: '70%', height: 10, borderRadius: 6, backgroundColor: '#E4E4DF' }, + rhythmBars: { + minHeight: 140, + paddingTop: 16, + flexDirection: 'row', + alignItems: 'flex-end', + justifyContent: 'space-between', + gap: 5, + }, + rhythmBarColumn: { + flex: 1, + alignItems: 'center', + justifyContent: 'flex-end', + gap: 4, + }, + rhythmBar: { + width: '70%', + height: 10, + borderRadius: 6, + backgroundColor: '#E4E4DF', + }, rhythmBarActive: { backgroundColor: colors.ink }, rhythmValue: { color: colors.muted, fontSize: 9 }, rhythmDay: { color: colors.subtle, fontSize: 9 }, achievementSection: { marginTop: 26 }, achievementHeader: { flexDirection: 'row', alignItems: 'flex-end', gap: 12 }, - sectionSubcopy: { marginTop: 5, color: colors.muted, fontSize: 12, lineHeight: 18, fontWeight: '300' }, + sectionSubcopy: { + marginTop: 5, + color: colors.muted, + fontSize: 12, + lineHeight: 18, + fontWeight: '300', + }, achievementCount: { color: colors.ink, fontSize: 25, fontWeight: '600' }, achievementTotal: { color: colors.muted, fontSize: 11, fontWeight: '300' }, achievementFilters: { marginTop: 16, flexDirection: 'row', gap: 7 }, - filterPill: { minHeight: 34, paddingHorizontal: 11, flexDirection: 'row', alignItems: 'center', gap: 5, borderWidth: 1, borderColor: colors.line, borderRadius: 17, backgroundColor: colors.white }, + filterPill: { + minHeight: 34, + paddingHorizontal: 11, + flexDirection: 'row', + alignItems: 'center', + gap: 5, + borderWidth: 1, + borderColor: colors.line, + borderRadius: 17, + backgroundColor: colors.white, + }, filterPillActive: { borderColor: colors.ink, backgroundColor: colors.ink }, filterPillText: { color: colors.muted, fontSize: 11 }, filterPillTextActive: { color: colors.white }, - filterPillCount: { minWidth: 16, color: colors.subtle, fontSize: 10, textAlign: 'center' }, + filterPillCount: { + minWidth: 16, + color: colors.subtle, + fontSize: 10, + textAlign: 'center', + }, filterPillCountActive: { color: colors.white }, achievementGrid: { marginTop: 12, gap: 12 }, achievementCard: { padding: 0, overflow: 'hidden' }, - achievementCardHeader: { minHeight: 70, padding: 14, flexDirection: 'row', alignItems: 'center', gap: 10, borderBottomWidth: 1, borderBottomColor: colors.line }, - achievementIcon: { width: 42, height: 42, alignItems: 'center', justifyContent: 'center', borderWidth: 1, borderColor: colors.line, borderRadius: 13, backgroundColor: colors.white }, - achievementCategory: { color: colors.subtle, fontSize: 10, fontWeight: '300' }, - achievementTitle: { marginTop: 3, color: colors.ink, fontSize: 18, fontWeight: '600' }, + achievementCardHeader: { + minHeight: 70, + padding: 14, + flexDirection: 'row', + alignItems: 'center', + gap: 10, + borderBottomWidth: 1, + borderBottomColor: colors.line, + }, + achievementIcon: { + width: 42, + height: 42, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + borderColor: colors.line, + borderRadius: 13, + backgroundColor: colors.white, + }, + achievementCategory: { + color: colors.subtle, + fontSize: 10, + fontWeight: '300', + }, + achievementTitle: { + marginTop: 3, + color: colors.ink, + fontSize: 18, + fontWeight: '600', + }, achievementBody: { minHeight: 92, padding: 16, gap: 4 }, achievementLabel: { color: colors.subtle, fontSize: 10, fontWeight: '300' }, achievementLevel: { color: colors.muted, fontSize: 21, fontWeight: '600' }, achievementNote: { color: colors.muted, fontSize: 11, fontWeight: '300' }, - achievementProgress: { padding: 16, gap: 8, borderTopWidth: 1, borderTopColor: colors.line }, + achievementProgress: { + padding: 16, + gap: 8, + borderTopWidth: 1, + borderTopColor: colors.line, + }, achievementValue: { color: colors.ink, fontSize: 12, fontWeight: '500' }, achievementNext: { color: colors.muted, fontSize: 12, fontWeight: '500' }, - achievementFooter: { minHeight: 46, paddingHorizontal: 16, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', backgroundColor: colors.soft }, - achievementFooterText: { color: colors.muted, fontSize: 11, fontWeight: '500' }, - headerAction: { minHeight: 36, paddingHorizontal: 11, flexDirection: 'row', alignItems: 'center', gap: 6, borderWidth: 1, borderColor: colors.line, borderRadius: 18 }, + achievementFooter: { + minHeight: 46, + paddingHorizontal: 16, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + backgroundColor: colors.soft, + }, + achievementFooterText: { + color: colors.muted, + fontSize: 11, + fontWeight: '500', + }, + headerAction: { + minHeight: 36, + paddingHorizontal: 11, + flexDirection: 'row', + alignItems: 'center', + gap: 6, + borderWidth: 1, + borderColor: colors.line, + borderRadius: 18, + }, headerActionText: { color: colors.ink, fontSize: 11, fontWeight: '500' }, goalGrid: { marginTop: 24, gap: 12 }, goalCard: { minHeight: 190, gap: 15 }, goalHeader: { flexDirection: 'row', alignItems: 'center', gap: 10 }, - goalIcon: { width: 42, height: 42, alignItems: 'center', justifyContent: 'center', borderRadius: 12 }, + goalIcon: { + width: 42, + height: 42, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 12, + }, goalIconGreen: { backgroundColor: colors.greenSoft }, goalIconBlue: { backgroundColor: '#EEF4FC' }, goalLabel: { color: colors.muted, fontSize: 13, fontWeight: '300' }, - goalState: { marginTop: 3, color: colors.ink, fontSize: 17, fontWeight: '600' }, + goalState: { + marginTop: 3, + color: colors.ink, + fontSize: 17, + fontWeight: '600', + }, goalPercent: { color: colors.muted, fontSize: 12, fontWeight: '500' }, - goalValue: { color: colors.ink, fontSize: 39, lineHeight: 45, fontWeight: '600' }, + goalValue: { + color: colors.ink, + fontSize: 39, + lineHeight: 45, + fontWeight: '600', + }, goalSuffix: { color: colors.muted, fontSize: 15, fontWeight: '300' }, goalRemaining: { color: colors.muted, fontSize: 12, fontWeight: '300' }, goalFormGroup: { gap: 8 }, - goalInputRow: { minHeight: 54, paddingHorizontal: 14, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', borderWidth: 1, borderColor: colors.line, borderRadius: 13, backgroundColor: colors.white }, + goalInputRow: { + minHeight: 54, + paddingHorizontal: 14, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderWidth: 1, + borderColor: colors.line, + borderRadius: 13, + backgroundColor: colors.white, + }, goalInput: { flex: 1, color: colors.ink, fontSize: 18, fontWeight: '600' }, goalInputSuffix: { color: colors.muted, fontSize: 14, fontWeight: '500' }, - divider: { height: StyleSheet.hairlineWidth, marginVertical: 24, backgroundColor: colors.line }, - sectionTitleRow: { flexDirection: 'row', alignItems: 'flex-end', justifyContent: 'space-between', gap: 10 }, - emptyInsight: { minHeight: 200, alignItems: 'center', justifyContent: 'center', gap: 10 }, + divider: { + height: StyleSheet.hairlineWidth, + marginVertical: 24, + backgroundColor: colors.line, + }, + sectionTitleRow: { + flexDirection: 'row', + alignItems: 'flex-end', + justifyContent: 'space-between', + gap: 10, + }, + emptyInsight: { + minHeight: 200, + alignItems: 'center', + justifyContent: 'center', + gap: 10, + }, + emptyInsightCompact: { + minHeight: 92, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 20, + }, emptyInsightText: { color: colors.muted, fontSize: 14, fontWeight: '300' }, + loadingState: { + minHeight: 180, + alignItems: 'center', + justifyContent: 'center', + gap: 12, + }, + errorState: { + minHeight: 150, + alignItems: 'center', + justifyContent: 'center', + gap: 14, + paddingHorizontal: 20, + }, + insightList: { marginTop: 16, paddingVertical: 4 }, + insightRow: { + minHeight: 62, + flexDirection: 'row', + alignItems: 'center', + gap: 12, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.line, + }, + insightTitle: { + color: colors.ink, + fontSize: 15, + lineHeight: 21, + fontWeight: '600', + }, + insightNote: { + marginTop: 3, + color: colors.muted, + fontSize: 12, + lineHeight: 18, + fontWeight: '300', + }, + insightMetric: { color: colors.ink, fontSize: 17, fontWeight: '600' }, + insightStack: { marginTop: 14, gap: 10 }, + recommendationCard: { gap: 5 }, settingsList: { marginTop: 24, gap: 12 }, settingCard: { gap: 16 }, settingIntro: { gap: 4 }, - settingTitle: { color: colors.ink, fontSize: 18, lineHeight: 24, fontWeight: '600' }, - settingNote: { color: colors.muted, fontSize: 12, lineHeight: 18, fontWeight: '300' }, - settingRow: { paddingVertical: 7, flexDirection: 'row', alignItems: 'center', gap: 12 }, + settingTitle: { + color: colors.ink, + fontSize: 18, + lineHeight: 24, + fontWeight: '600', + }, + settingNote: { + color: colors.muted, + fontSize: 12, + lineHeight: 18, + fontWeight: '300', + }, + settingRow: { + paddingVertical: 7, + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, syncState: { flexDirection: 'row', alignItems: 'center', gap: 5 }, syncDot: { color: colors.green, fontSize: 16, fontWeight: '600' }, syncText: { color: colors.muted, fontSize: 11 }, planGrid: { marginTop: 24, gap: 14 }, planCard: { minHeight: 340, gap: 18 }, planCardSelected: { borderWidth: 1.5, borderColor: colors.ink }, - planName: { marginTop: 10, color: colors.ink, fontSize: 28, lineHeight: 33, fontWeight: '600' }, - planNote: { marginTop: 7, color: colors.muted, fontSize: 13, lineHeight: 20, fontWeight: '300' }, - planPrice: { color: colors.ink, fontSize: 50, lineHeight: 56, fontWeight: '600' }, + planName: { + marginTop: 10, + color: colors.ink, + fontSize: 28, + lineHeight: 33, + fontWeight: '600', + }, + planNote: { + marginTop: 7, + color: colors.muted, + fontSize: 13, + lineHeight: 20, + fontWeight: '300', + }, + planPrice: { + color: colors.ink, + fontSize: 50, + lineHeight: 56, + fontWeight: '600', + }, planCurrency: { fontSize: 16, fontWeight: '500' }, planCycle: { color: colors.muted, fontSize: 14, fontWeight: '300' }, planFeatures: { gap: 11 }, @@ -296,42 +2318,195 @@ const styles = StyleSheet.create({ checkMark: { color: colors.muted, fontSize: 17 }, planFeatureText: { color: colors.muted, fontSize: 13, fontWeight: '300' }, accountHero: { marginBottom: 22 }, - accountShield: { width: 52, height: 52, marginBottom: 18, alignItems: 'center', justifyContent: 'center', borderRadius: 14, backgroundColor: colors.greenSoft }, + accountShield: { + width: 52, + height: 52, + marginBottom: 18, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 14, + backgroundColor: colors.greenSoft, + }, accountCard: { paddingHorizontal: 16, paddingVertical: 2, marginBottom: 20 }, helpList: { marginTop: 24, gap: 10 }, - helpCard: { minHeight: 76, flexDirection: 'row', alignItems: 'center', gap: 12 }, + helpCard: { + minHeight: 76, + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, helpTitle: { color: colors.ink, fontSize: 17, fontWeight: '600' }, - helpNote: { marginTop: 4, color: colors.muted, fontSize: 12, lineHeight: 18, fontWeight: '300' }, - helpContact: { marginTop: 18, flexDirection: 'row', alignItems: 'center', gap: 12, backgroundColor: colors.soft }, - aboutBrand: { marginTop: 20, flexDirection: 'row', alignItems: 'center', gap: 10 }, + helpNote: { + marginTop: 4, + color: colors.muted, + fontSize: 12, + lineHeight: 18, + fontWeight: '300', + }, + helpCount: { + marginTop: 5, + color: colors.subtle, + fontSize: 10, + fontWeight: '300', + }, + helpArticleDate: { + marginTop: 12, + color: colors.subtle, + fontSize: 11, + fontWeight: '300', + }, + helpArticleBody: { marginTop: 24, gap: 10 }, + helpArticleHeading: { color: colors.ink, fontSize: 18, fontWeight: '600' }, + helpArticleText: { color: colors.muted, fontSize: 15, lineHeight: 25, fontWeight: '300' }, + helpContact: { + marginTop: 18, + flexDirection: 'row', + alignItems: 'center', + gap: 12, + backgroundColor: colors.soft, + }, + aboutBrand: { + marginTop: 20, + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, aboutMark: { width: 34, height: 34, borderRadius: 8 }, aboutWordmark: { width: 150, height: 32 }, - productInfo: { marginTop: 14, borderTopWidth: 1, borderTopColor: colors.line }, - productRow: { minHeight: 54, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', borderBottomWidth: 1, borderBottomColor: colors.line }, + productInfo: { + marginTop: 14, + borderTopWidth: 1, + borderTopColor: colors.line, + }, + productRow: { + minHeight: 54, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderBottomWidth: 1, + borderBottomColor: colors.line, + }, productLabel: { color: colors.muted, fontSize: 12, fontWeight: '300' }, productValue: { color: colors.ink, fontSize: 13, fontWeight: '600' }, modalRoot: { flex: 1, justifyContent: 'center', padding: 18 }, - modalBackdrop: { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, backgroundColor: 'rgba(20,20,19,0.38)' }, - editModal: { padding: 22, gap: 18, borderRadius: 22, backgroundColor: colors.white }, - goalsModal: { padding: 22, gap: 20, borderRadius: 22, backgroundColor: colors.white }, + modalBackdrop: { + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + backgroundColor: 'rgba(20,20,19,0.38)', + }, + editModal: { + padding: 22, + gap: 18, + borderRadius: 22, + backgroundColor: colors.white, + }, + goalsModal: { + padding: 22, + gap: 20, + borderRadius: 22, + backgroundColor: colors.white, + }, editModalTop: { flexDirection: 'row', alignItems: 'flex-start', gap: 12 }, - modalEyebrow: { color: colors.subtle, fontSize: 11, fontWeight: '600', letterSpacing: 1.7 }, - modalTitle: { marginTop: 9, color: colors.ink, fontSize: 27, lineHeight: 34, fontWeight: '600' }, - modalLead: { marginTop: 7, color: colors.muted, fontSize: 14, lineHeight: 21, fontWeight: '300' }, - closeButton: { width: 38, height: 38, alignItems: 'center', justifyContent: 'center', borderRadius: 19, backgroundColor: colors.soft }, - editAvatarCard: { padding: 14, flexDirection: 'row', alignItems: 'center', gap: 14, borderWidth: 1, borderColor: colors.line, borderRadius: 15, backgroundColor: colors.paper }, - editAvatarImage: { width: 76, height: 76, borderRadius: 15, backgroundColor: colors.soft }, + modalEyebrow: { + color: colors.subtle, + fontSize: 11, + fontWeight: '600', + letterSpacing: 1.7, + }, + modalTitle: { + marginTop: 9, + color: colors.ink, + fontSize: 27, + lineHeight: 34, + fontWeight: '600', + }, + modalLead: { + marginTop: 7, + color: colors.muted, + fontSize: 14, + lineHeight: 21, + fontWeight: '300', + }, + closeButton: { + width: 38, + height: 38, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 19, + backgroundColor: colors.soft, + }, + editAvatarCard: { + padding: 14, + flexDirection: 'row', + alignItems: 'center', + gap: 14, + borderWidth: 1, + borderColor: colors.line, + borderRadius: 15, + backgroundColor: colors.paper, + }, + editAvatarImage: { + width: 76, + height: 76, + borderRadius: 15, + backgroundColor: colors.soft, + }, editAvatarTitle: { color: colors.ink, fontSize: 16, fontWeight: '600' }, - editAvatarNote: { marginTop: 5, color: colors.muted, fontSize: 11, lineHeight: 16, fontWeight: '300' }, - avatarPicker: { alignSelf: 'flex-start', minHeight: 34, marginTop: 11, paddingHorizontal: 13, alignItems: 'center', justifyContent: 'center', borderWidth: 1, borderColor: colors.line, borderRadius: 18 }, + editAvatarNote: { + marginTop: 5, + color: colors.muted, + fontSize: 11, + lineHeight: 16, + fontWeight: '300', + }, + avatarPicker: { + alignSelf: 'flex-start', + minHeight: 34, + marginTop: 11, + paddingHorizontal: 13, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + borderColor: colors.line, + borderRadius: 18, + }, avatarPickerText: { color: colors.ink, fontSize: 11, fontWeight: '600' }, avatarChoices: { flexDirection: 'row', gap: 8 }, avatarChoice: { alignItems: 'center', gap: 4 }, - avatarChoiceImage: { width: 40, height: 40, borderRadius: 20, backgroundColor: colors.soft }, + avatarChoiceImage: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: colors.soft, + }, avatarChoiceLabel: { color: colors.muted, fontSize: 9 }, formGroup: { gap: 8 }, fieldLabel: { color: colors.ink, fontSize: 13, fontWeight: '600' }, - input: { minHeight: 52, paddingHorizontal: 14, color: colors.ink, fontSize: 15, fontWeight: '300', borderWidth: 1, borderColor: colors.line, borderRadius: 13, backgroundColor: colors.white }, - modalActions: { marginTop: 8, flexDirection: 'row', justifyContent: 'flex-end', gap: 10 }, + input: { + minHeight: 52, + paddingHorizontal: 14, + color: colors.ink, + fontSize: 15, + fontWeight: '300', + borderWidth: 1, + borderColor: colors.line, + borderRadius: 13, + backgroundColor: colors.white, + }, + formError: { + color: colors.red, + fontSize: 12, + lineHeight: 18, + textAlign: 'center', + }, + modalActions: { + marginTop: 8, + flexDirection: 'row', + justifyContent: 'flex-end', + gap: 10, + }, modalAction: { flex: 1 }, }); diff --git a/frontend/mobile/src/screens/SpecialtyFlows.tsx b/frontend/mobile/src/screens/SpecialtyFlows.tsx index 2b10caf7..054c6c8c 100644 --- a/frontend/mobile/src/screens/SpecialtyFlows.tsx +++ b/frontend/mobile/src/screens/SpecialtyFlows.tsx @@ -20,6 +20,7 @@ import { useIeltsFlowController } from '@/features/ielts/useIeltsFlowController' import { useIeltsSession } from '@/features/ielts/useIeltsSession'; import { ieltsExaminers, toApiPart, type MobileIeltsPartId } from '@/features/ielts/ieltsMappings'; import { resolvePart2CueCard } from '@/features/ielts/part2CueCard'; +import { IeltsPracticeScoreDialog } from '@/features/ielts/IeltsPracticeScoreDialog'; import type { IeltsTopicSummary } from '@/features/ielts/types'; import { compactPageNumbers } from '@/features/ielts/compactPagination'; import { createTranscriptTranslationApi } from '@/features/conversation/TranscriptTranslationApi'; @@ -1210,7 +1211,7 @@ export function IeltsFlow({ onExit, onViewDetails }: { onExit: () => void; onVie title: fullMock ? '完整口语模拟' : topic || ielts.generated?.title || 'IELTS 专项练习', date: '刚刚', duration: fullMock ? '14 分钟' : '4 分钟', - result: `预估 ${bandScore}`, + result: fullMock ? `预估 ${bandScore}` : '专项诊断', estimatedBand: evaluation.overallBandScore == null ? null : Number(evaluation.overallBandScore), scores: [ Math.round(((evaluation.fluencyCoherenceScore ?? 0) / 9) * 100), @@ -1242,6 +1243,32 @@ export function IeltsFlow({ onExit, onViewDetails }: { onExit: () => void; onVie return recordId; }; + const returnToIeltsHome = () => { + saveReport(); + setRoute('home'); + }; + const viewIeltsDetails = () => { + const recordId = saveReport(); + if (onViewDetails) { + setImmersiveLearning(false); + if (recordId) onViewDetails(recordId); + } else { + onExit(); + } + }; + + if (!fullMock) { + return ( + + + + ); + } + return ( @@ -1261,23 +1288,12 @@ export function IeltsFlow({ onExit, onViewDetails }: { onExit: () => void; onVie { - saveReport(); - setRoute('home'); - }} + onPress={returnToIeltsHome} style={styles.reportSecondaryButton} /> { - const recordId = saveReport(); - if (onViewDetails) { - setImmersiveLearning(false); - if (recordId) onViewDetails(recordId); - } else { - onExit(); - } - }} + onPress={viewIeltsDetails} style={styles.reportPrimaryButton} /> @@ -1740,6 +1756,7 @@ const styles = StyleSheet.create({ analysisTitle: { color: colors.ink, fontSize: 25, lineHeight: 34, fontWeight: '600', textAlign: 'center' }, progressText: { color: colors.subtle, fontSize: 12, fontWeight: '300', fontVariant: ['tabular-nums'] }, reportScreen: { paddingTop: 32, paddingBottom: 44, justifyContent: 'center', gap: 14 }, + practiceScoreScreen: { paddingHorizontal: 0, paddingTop: 0, paddingBottom: 0, backgroundColor: ieltsPalette.canvas }, bandHero: { minHeight: 204, paddingVertical: 25, alignItems: 'center', justifyContent: 'center', gap: 4, borderWidth: 1, borderColor: ieltsPalette.borderStrong, borderRadius: 24, backgroundColor: ieltsPalette.purple, shadowColor: ieltsPalette.purple, shadowOffset: { width: 0, height: 10 }, shadowOpacity: 0.2, shadowRadius: 22, elevation: 5, boxShadow: '0px 10px 24px rgba(128, 96, 232, 0.20)' }, bandEyebrow: { color: '#E8DEFF', fontSize: 12, fontWeight: '600', letterSpacing: 1.1 }, bandScore: { color: colors.white, fontSize: 82, lineHeight: 88, fontWeight: '600', letterSpacing: -5 },