From 8478a99ebaf18a67d959b989661a63214da23d4b Mon Sep 17 00:00:00 2001 From: TongTongStudio Date: Mon, 17 Aug 2026 09:30:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E5=AF=B9=E6=8E=A5=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=20open=20=E6=A8=A1=E5=9D=97=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E5=B9=B6=E5=AE=8C=E5=96=84=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新接口路径、字段和短信验证码场景,新增忘记密码/重置密码流程 - 增加 401 自动刷新 token 与统一错误处理 - 重构登录页 UI,支持退出确认、协议与法律文档入口 --- AGENTS.md | 234 ++++++- lib/app/constants/app_constants.dart | 4 +- lib/app/router/app_router.dart | 11 + lib/core/network/api_error.dart | 150 +++++ lib/core/network/api_exception.dart | 33 +- lib/core/network/dio_client.dart | 200 +++++- lib/core/network/error_message.dart | 17 + .../auth/data/auth_repository_impl.dart | 52 +- lib/features/auth/domain/auth_models.dart | 5 +- lib/features/auth/domain/auth_repository.dart | 7 + .../auth/presentation/auth_controller.dart | 61 +- .../presentation/forgot_password_page.dart | 236 +++++++ .../presentation/legal_document_page.dart | 68 ++ .../auth/presentation/login_page.dart | 611 ++++++++++++++---- .../auth/presentation/register_page.dart | 36 +- lib/l10n/app_localizations.dart | 150 +++++ lib/l10n/app_localizations_en.dart | 78 +++ lib/l10n/app_localizations_zh.dart | 77 +++ lib/l10n/intl_en.arb | 27 +- lib/l10n/intl_zh.arb | 100 +++ 20 files changed, 1963 insertions(+), 194 deletions(-) create mode 100644 lib/core/network/api_error.dart create mode 100644 lib/core/network/error_message.dart create mode 100644 lib/features/auth/presentation/forgot_password_page.dart create mode 100644 lib/features/auth/presentation/legal_document_page.dart diff --git a/AGENTS.md b/AGENTS.md index 063a8d8..0b7fac0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,16 +6,18 @@ ## 1. 技术栈 -- **语言:** Dart(SDK >=3.0.0,严格空安全) -- **框架:** Flutter(最新稳定通道) +- **语言:** Dart(SDK `^3.12.2`,严格空安全) +- **框架:** Flutter(**锁定版本,禁止使用 `flutter upgrade` 自由升级**,详见 [§11 版本管理](#11-版本管理)) - **状态管理:** `flutter_riverpod`(v2.x,注解 + 代码生成) -- **路由:** `go_router` +- **路由:** `go_router`(v14.x,含鉴权重定向,详见 [§9 路由鉴权](#9-路由鉴权)) - **网络请求:** `dio`(配合 `json_annotation` + `freezed` 进行 JSON 序列化) - **数据模型:** `freezed` + `build_runner` -- **本地存储:** `shared_preferences`(非敏感偏好)+ `flutter_secure_storage`(令牌) +- **本地存储:** `shared_preferences`(非敏感偏好)+ `flutter_secure_storage`(令牌,平台配置见 [§8 安全](#8-安全))+ `mmkv`(高性能键值缓存) - **依赖注入:** Riverpod Providers(不使用 GetIt) - **国际化:** `flutter_localizations`(l10n / ARB 格式) +> ⚠️ **版本一致性约束**:不同开发者执行 `flutter upgrade` 的时间不同会导致 SDK 版本漂移,引发构建差异。Flutter 与 Dart SDK 版本必须以 CI / `.fvmrc` 为准,禁止在本地随意升级。 + --- ## 2. 目录结构 @@ -27,13 +29,13 @@ lib/ ├── main.dart # 应用程序入口(ProviderScope + runApp) ├── app/ # 全局应用配置 │ ├── app.dart # CupertinoApp.router 入口 -│ ├── router/ # 路由定义(GoRouter) +│ ├── router/ # 路由定义(GoRouter,含 redirect 鉴权) │ ├── theme/ # 主题、颜色、排版 │ └── constants/ # 全局常量、API 端点、ICE 配置 ├── core/ # 跨特性共享代码 -│ ├── network/ # dio 客户端、错误处理 -│ ├── proto/ # Protobuf 生成代码(控制指令协议) -│ ├── storage/ # 本地存储辅助工具 +│ ├── network/ # dio 客户端、拦截器链、统一错误模型(AppError) +│ ├── proto/ # ⚠️ 仅放【跨特性共享】的 Protobuf 生成代码(见 §3 边界规则) +│ ├── storage/ # 本地存储辅助工具(shared_preferences / secure_storage / mmkv 封装) │ ├── utils/ # 辅助函数、扩展方法 │ └── widgets/ # 共享 UI 组件(触摸层) ├── features/ # 特性模块 @@ -43,11 +45,22 @@ lib/ │ │ └── presentation/ # 控制器、登录对话框 │ └── connection/ # 信令 / WebRTC / 控制面板 │ ├── data/ # 信令客户端、WebRTC、编排器、解码器、录制器 -│ ├── domain/ # 信令消息、会话状态(freezed) +│ ├── domain/ # 信令消息、会话状态(freezed)、【单一特性专用】协议模型 │ └── presentation/ # 控制器、设置页、控制页、鉴权对话框 -└── l10n/ # 国际化(.arb 文件) +├── l10n/ # 国际化(.arb 文件,命名/组织约定见 §10) +└── assets/ # 静态资源(目录结构与命名见 §7 资源管理) + ├── images/ + └── fonts/ ``` +### ⚠️ core/proto 与特性层边界规则 + +目录结构中 `core/proto` 存放 Protobuf 生成代码,但特性层的 `domain` 又标注"协议模型除外",两者边界容易混淆。明确规则如下: + +- **跨特性共享的 `.proto`**(被两个及以上 feature 使用,或属于全局控制指令协议)→ 放 `core/proto/`。 +- **单一特性专用的协议模型**(如仅 `connection` 使用的信令消息)→ 放 `features/xxx/domain/models/`,不进 `core`。 +- 判断标准:问一句"去掉这个 feature 后,该 proto 还有人用吗?"没人用就留在 feature 内,保持内聚。 + --- ## 3. 分层职责 @@ -64,7 +77,7 @@ UI 组件 + Riverpod Notifier / AsyncNotifier 控制器。 纯 Dart 实体(`freezed`)和仓库接口定义。 -- 零 Flutter/UI 依赖(协议模型除外)。 +- 零 Flutter/UI 依赖(**单一特性专用的协议模型除外**,跨特性共享的协议模型在 `core/proto`)。 - 状态类命名避免与 Flutter SDK 冲突(如用 `ConnectionSessionState` 而非 `ConnectionState`)。 ### 数据层(`data/`) @@ -78,7 +91,12 @@ UI 组件 + Riverpod Notifier / AsyncNotifier 控制器。 ### 状态管理(Riverpod) - 使用 `@riverpod` 注解 + `build_runner` 代码生成。 -- 会话级控制器(如登录、连接)使用 `@Riverpod(keepAlive: true)`,避免页面切换时销毁。 +- **`keepAlive: true` 使用约束(⚠️ 重要)**: + - 仅限**全局单例级状态**(如 `Auth`、`Signaling` 会话控制器)使用,用于避免页面切换时销毁。 + - **特性页面级控制器禁止使用 `keepAlive: true`**,否则会造成状态残留与内存泄漏。 + - `keepAlive: true` 意味着 Provider **永远不会自动 dispose**,必须配套明确的清理策略: + - 用户**登出 / 断连**时主动调用 `ref.invalidate(provider)` 或 `ref.read(provider.notifier).reset()`(置 `ref.state = null` / 初始值),防止旧状态(token、连接会话)残留。 + - 全局单例的清理动作集中在登出流程(`auth` 控制器或 `app` 层的统一退出入口)中统一触发。 - 异步操作优先使用 `AsyncNotifierProvider`,配合 `AsyncValue`(Loading / Data / Error)。 - 业务回调(信令/WebRTC 事件)统一在控制器内映射到状态,不在 UI 层直接持有控制器实例。 @@ -99,11 +117,22 @@ class SignalMessage with _$SignalMessage { factory SignalMessage.fromJson(Map json) => _$SignalMessageFromJson(json); + // ⚠️ 安全:toString() 直接 jsonEncode 会泄露 token / password 等敏感字段。 + // 若模型不含敏感字段可保留;否则改为手动排除敏感字段,或干脆不覆盖 toString()。 @override - String toString() => jsonEncode(toJson()); + String toString() { + // 示例:排除敏感字段 + final safe = toJson()..remove('token'); + return 'SignalMessage(${safe.toString()})'; + } } ``` +> ⚠️ **敏感信息防护**:`jsonEncode(toJson())` 形式的 `toString()` 会把 token、password 等明文写入日志 / 崩溃上报。规范约定: +> 1. 含敏感字段的模型 **不得** 使用 `jsonEncode(toJson())` 形式的 `toString()`; +> 2. 改为手动排除敏感字段后输出,或直接不覆盖 `toString()`(依赖调试器查看字段); +> 3. 若确需调试输出,标注 `@visibleForTesting` 并约定生产环境不调用。 + ### UI 组件 - 尽可能使用 `const` 构造函数,避免不必要的重建。 @@ -141,10 +170,185 @@ flutter gen-l10n --- -## 7. 完成标准 +## 7. 资源管理 -- 新文件遵循特性优先结构(app / core / features / l10n)。 +静态资源统一放在项目根 `assets/` 下,按类型分子目录: + +```text +assets/ +├── images/ # 图片资源 +│ ├── common/ # 通用图标 / 占位图 +│ └── feature_xxx/ # 按特性隔离的图片 +└── fonts/ # 自定义字体 +``` + +- **图片命名**:`snake_case`,带用途/状态后缀,如 `ic_back.png`、`bg_login_dark@2x.png`;分辨率变体使用 `@2x` / `@3x` 后缀。 +- **`pubspec.yaml` 引入**:在 `flutter.assets` 中显式声明目录(当前模板已注释示例,新增资源后需补齐),字体在 `flutter.fonts` 中声明 `family` 与 `asset`。 +- 超过 ~100KB 的图片优先走 CDN / 网络加载,避免包体积膨胀。 + +--- + +## 8. 安全 + +- **`flutter_secure_storage` 平台配置**: + - **iOS**:数据落地于 **Keychain**(系统级加密,不随 app 卸载必然清除,需走钥匙串共享组或登录项)。 + - **Android**:默认使用 **EncryptedSharedPreferences**(需 `minSdkVersion >= 23`;低于此需自定义 `AndroidOptions` 的 `encryptedSharedPreference` 开关)。 + - 仅存放 `refreshToken` 等高危凭证,禁止存明文密码。 +- **敏感字段防护**:见 §4 数据建模中 `toString()` 的约束,token / password 不得进入日志与崩溃上报。 +- **本地偏好**:非敏感配置(主题、语言)用 `shared_preferences`;高频读写缓存可用 `mmkv`。 +- **网络传输**:所有 API 走 HTTPS;信令 WebSocket 使用 `wss://`。 + +--- + +## 9. 路由鉴权 + +基于 `go_router` 实现统一的鉴权重定向: + +- **受保护路由**:在 `app/router/` 中集中定义路由表,受保护路由(如 `connection/*`)标记为需鉴权。 +- **未登录重定向**:通过 `GoRouter` 的 `redirect` 回调检查登录态(读取 `auth` 控制器的 `AsyncValue` / 本地 token)。未登录时 `redirect` 到 `/login`,并把当前 `location` 写入 `extra` 或 query 参数。 +- **登录回跳**:登录成功后读取 `extra` 中的目标路由,调用 `context.go()` 回跳原页面;无目标时默认跳转首页。 +- **登出清理联动**:登出时除 §4 的 Provider 清理外,路由需 `go('/login')` 清空导航栈。 + +--- + +## 10. 国际化(l10n) + +采用 ARB 格式,由 `l10n.yaml` 驱动 `flutter gen-l10n` 生成 `AppLocalizations`。 + +- **文件命名 / 组织**:语言文件以 `intl_.arb` 命名(如 `intl_zh.arb`、`intl_en.arb`),`template-arb-file` 指定模板语言(当前为 `intl_zh.arb`)。 +- **key 命名**:`snake_case` 语义化,带上下文前缀避免冲突,如 `login_title`、`connection_video_off`。 +- **占位符**:使用 `{param}` 占位,对应生成的 getter 会带参数;多语言文案需保持占位符一致。 +- **复数 / 选择**:使用 ARB 的 `plural` / `select` 语法(如 `@count` + `plural`),避免手动拼接。 +- **维护约定**:新增文案须同时更新所有语言 ARB,缺失的 key 以模板语言文案兜底。 + +--- + +## 11. 版本管理 + +- **锁定 Flutter / Dart 版本**:在 `README` 与 CI 中明确记录 `flutter --version` 对应的版本号。 +- **推荐使用 FVM**:项目根放置 `.fvmrc`,团队成员统一 `fvm use`,禁止本地自由 `flutter upgrade`。 +- `pubspec.yaml` 的 `environment.sdk` 已锁定为 `^3.12.2`,依赖版本尽量用 caret 范围并定期 `flutter pub outdated` 审查。 + +--- + +## 12. 错误处理统一规范 + +> 本规范已在 `lib/core/network/` 落地,新增网络请求必须复用以下基础设施,禁止在 Repository 中自行创建 Dio 实例或手写 try/catch 转换异常。 + +- **统一错误模型 `AppError`**(`api_error.dart`,含 `type`:network / unauthorized / forbidden / server / business / unknown,`code`,`statusCode`,`message`,`original`,以及 `isUnauthorized` / `isRetryable` 判定,与统一用户文案 `userMessage`)。数据/网络层统一向上抛 `AppError` 而非裸 `Exception` / `DioException`。 +- **兼容异常 `ApiException`**(`api_exception.dart`):保留 `code` / `message` 字段以兼容既有调用方;新增代码建议直接使用 `AppError`。`DioClient` 对外统一抛出 `ApiException`。 +- **dio 拦截器链**(归属 `core/network/dio_client.dart`): + 1. `_AuthInterceptor`:注入 `Authorization: Bearer `。 + 2. `_ResponseInterceptor`:统一解析 `{ code, message, data }`,`code == 0` 提取 `data`,否则抛业务错误。 + 3. `_ErrorInterceptor`:将 `DioException` 转换为 `ApiException`;**401 时自动用 `refreshToken` 刷新并重试一次**,刷新失败则向下传递 unauthorized 错误。 +- **统一请求入口**:`DioClient.get/post/put/delete` 已封装,Repository 直接调用并捕获 `ApiException`,无需重复 try/catch。 +- **错误分类与处理策略**: + - `401` → `_ErrorInterceptor` 自动刷新 token 重试;刷新失败 → 控制器检测到 unauthorized 触发登出清理。 + - `403` → 无权限,展示无权限提示。 + - `4xx` 其他 / 业务错误 → 展示后台 `message`。 + - `5xx` / 网络不可达 → 提示重试(UI 提供 retry 入口)。 +- **统一文案映射**:所有控制器/UI 捕获异常后必须通过 `userMessageOf(e)`(`error_message.dart`)获取展示文案,**禁止直接 `toString()`**(防敏感信息泄露)。 +- **UI 呈现**:`AsyncValue` 的 Error 分支统一渲染——全局错误(如 401 登出)走 alert/Toast,局部错误走内联错误 + retry 按钮,不强制全局错误页。 + +--- + +## 13. 测试规范 + +- **目录镜像**:`test/` 下按 `test/features//`、`test/core/` 镜像 `lib/` 结构。 +- **Mock 规范**:使用 `ProviderContainer` + `overrides` 覆盖 Riverpod Provider;Repository / dio 用 `mockito` 或手写 fake;WebRTC / 信令用桩对象。 +- **分层测试**:领域层纯函数单测;控制器用 `Container` override 后 `read(notifier)` 驱动并断言 `state`;UI 用 `pumpWidget(ProviderScope(...))`。 +- **运行门槛**:提交前 `flutter test` 需通过(见 §14)。 + +--- + +## 14. Git / CI + +- **Commit Message**:遵循 [Conventional Commits](https://www.conventionalcommits.org/)(`feat:` / `fix:` / `refactor:` / `docs:` / `test:` / `chore:`)。 +- **PR 模板**:含变更说明、影响范围、测试步骤、截图(UI 变更)。 +- **CI 流水线**(建议): + 1. `flutter analyze` 无 issue。 + 2. `flutter test` 通过。 + 3. `dart run build_runner build --delete-conflicting-outputs` 校验生成代码最新。 + 4. 多端构建(Android / iOS / Web)产物校验。 +- **协议同步**:`.proto` / 信令 JSON 变更须同步 Android / iOS / Web 各端,并在 PR 中标注。 + +--- + +## 15. 完成标准 + +- 新文件遵循特性优先结构(app / core / features / l10n / assets)。 - 代码为空安全、完全类型化,并进行 `const` 优化。 - 对应创建数据层、领域层和表示层组件。 +- `keepAlive: true` 的控制器已配置登出/断连清理策略(见 §4)。 +- 含敏感字段的模型已处理 `toString()` 泄露风险(见 §4、§8)。 - 提交前 `flutter analyze` 无 issue;`flutter test` 通过。 - 协议(`.proto` / 信令 JSON)变更需同步 Android / iOS / Web 各端。 + +--- + +## 16. C 端认证接口对接约定(open 模块) + +> 本节记录 Flutter(`ttstd_family_care`)与后端 `youlai-boot-ttstd` 的 C 端认证接口契约, +> 对接 `open` 模块(`com.youlai.boot.open`),账号落地 `app_user` 表,与后台管理端 `sys_user` 物理分表隔离。 + +### 16.1 基础地址 + +`AppConstants.kBaseUrl` 为后端 open 模块根路径,**已包含 `/api/v1/open/` 前缀**: + +```dart +static const String kBaseUrl = 'http://:/api/v1/open/'; +``` + +仓库层请求路径(如 `/login`)会拼接到该 BaseUrl 之后,即实际请求 +`/api/v1/open/auth/login`。**不要**把 `/api/v1/open/` 或 `/auth` 再拼进 path。 + +> ⚠️ 前端**禁止**将 BaseUrl 改为 `/api/v1/sn/` 前缀。`/api/v1/sn/**` 由后端 +> `MobileApiSignatureFilter` 强制校验设备签名四件套(`X-Device-SN` / `X-Nonce` / +> `X-Timestamp` / `X-Sign`),仅用于**设备被控端**通信;C 端用户登录走 open 模块, +> 不经过该过滤器。若误用 `/sn/` 前缀,会返回 `A0801 设备标识不能为空` 等错误。 + +### 16.2 接口端点(POST) + +所有请求均携带 `Authorization: Bearer `(登录/发验证码等匿名接口可省略)。 + +| 功能 | 路径 | 请求参数 | 说明 | +|---|---|---|---| +| 账号密码登录 | `/login` | body `{username, password}` | `username` 为手机号或用户名 | +| 验证码登录 | `/login/mobile` | body `{mobile, code}` | 需先调用"发送登录验证码" | +| 发送登录验证码 | `/login/sms/code` | query `mobile` | 验证码存 Redis,5 分钟有效 | +| 注册 | `/register/mobile` | body `{mobile, code, password}` | 注册成功即签发令牌 | +| 发送注册验证码 | `/register/sms/code` | query `mobile` | 手机号未注册时发送 | +| 重置密码 | `/reset-password` | body `{mobile, code, password}` | 需先调用"发送重置密码验证码" | +| 发送重置密码验证码 | `/reset-password/sms/code` | query `mobile` | 手机号必须已注册 | +| 刷新令牌 | `/refresh-token` | query `refreshToken` | 换取新访问令牌 | +| 退出登录 | `/logout` | query `accessToken`(可选) | 注销令牌 | + +### 16.3 响应结构 + +- 成功:`{ "code": 0, "message": "...", "data": ... }` +- 失败:`code != 0`,`message` 为错误提示(由 `_ResponseInterceptor` 抛 `ApiException`)。 + +登录/注册/刷新成功时 `data` 为后端 `AuthenticationToken`: + +```json +{ "tokenType": "Bearer", "accessToken": "xxx", "refreshToken": "yyy", "expiresIn": 7200 } +``` + +前端 `LoginResult.fromJson` 已兼容解析 `accessToken`(旧字段 `token` 作兜底)。 + +### 16.4 对应实现位置 + +- 数据层:`lib/features/auth/data/auth_repository_impl.dart` +- 令牌持久化:`lib/core/storage/token_storage.dart` +- 统一请求/拦截器:`lib/core/network/dio_client.dart` +- 领域模型:`lib/features/auth/domain/auth_models.dart`(`LoginResult`、`SmsScene`) + +### 16.5 场景映射 + +`SmsScene` 决定发送验证码的后端路径,仓库层按场景分流: + +| `SmsScene` | 后端路径 | 触发页面 | +|---|---|---| +| `login` | `/login/sms/code` | 登录页(验证码登录) | +| `register` | `/register/sms/code` | 注册页 | +| `resetPassword` | `/reset-password/sms/code` | 忘记密码页 | diff --git a/lib/app/constants/app_constants.dart b/lib/app/constants/app_constants.dart index b6020d1..a93015c 100644 --- a/lib/app/constants/app_constants.dart +++ b/lib/app/constants/app_constants.dart @@ -3,7 +3,7 @@ class AppConstants { const AppConstants._(); /// 生产环境基础地址。 - static const String kBaseUrl = 'https://api.ttstd.com'; + static const String kBaseUrl = 'http://192.168.100.222:8000/api/v1/open/'; /// 请求超时(毫秒)。 static const int kConnectTimeoutMs = 15000; @@ -17,5 +17,5 @@ class AppConstants { static const String kDeviceIdKey = 'device_id'; /// 应用名称。 - static const String kAppName = '桐桐家庭关怀'; + static const String kAppName = '家庭关怀'; } diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart index 6a3a78b..4d21bba 100644 --- a/lib/app/router/app_router.dart +++ b/lib/app/router/app_router.dart @@ -2,6 +2,8 @@ import 'package:go_router/go_router.dart'; import '../../features/auth/presentation/login_page.dart'; import '../../features/auth/presentation/register_page.dart'; +import '../../features/auth/presentation/forgot_password_page.dart'; +import '../../features/auth/presentation/legal_document_page.dart'; import '../../features/auth/presentation/splash_page.dart'; import '../../features/home/presentation/home_page.dart'; import '../../features/home/presentation/home_tab.dart'; @@ -29,6 +31,15 @@ GoRouter buildAppRouter() { path: '/register', builder: (context, state) => const RegisterPage(), ), + GoRoute( + path: '/forgot-password', + builder: (context, state) => const ForgotPasswordPage(), + ), + GoRoute( + path: '/legal', + builder: (context, state) => + LegalDocumentPage.fromExtra(state.extra), + ), StatefulShellRoute.indexedStack( builder: (context, state, shell) => HomePage(child: shell), branches: [ diff --git a/lib/core/network/api_error.dart b/lib/core/network/api_error.dart new file mode 100644 index 0000000..a9d10d2 --- /dev/null +++ b/lib/core/network/api_error.dart @@ -0,0 +1,150 @@ +import 'package:dio/dio.dart'; + +/// 统一应用错误模型。 +/// +/// 网络层([AppError.fromDioException])、业务层([AppError.business])与 +/// 未知异常([AppError.unknown])统一收敛到此类型,向上层(控制器 / UI) +/// 暴露结构化的错误分类,便于: +/// - 401 → 自动刷新 token 并重试 / 触发登出; +/// - 403 → 无权限; +/// - 5xx / 网络不可达 → 提示重试; +/// - 业务错误(code != 0)→ 直接展示 message。 +/// +/// 注意:本类型不包含任何敏感字段(token / password 等),可安全进入日志与 +/// 崩溃上报,但 [message] 仅作开发/兜底用途,UI 文案统一走 [AppError.userMessage]。 +class AppError { + const AppError._({ + required this.type, + required this.message, + this.code, + this.statusCode, + this.original, + }); + + /// 错误分类。 + final AppErrorType type; + + /// 用户可读的错误描述(开发/兜底用,UI 优先用 [userMessage])。 + final String message; + + /// 业务错误码(后台 code 字段,成功为 0)。 + final String? code; + + /// HTTP 状态码。 + final int? statusCode; + + /// 原始异常(如 [DioException]),用于上层决策(重试 / 上报)。 + final Object? original; + + /// 由 dio 异常构造(网络层统一入口)。 + factory AppError.fromDioException(Object error, [StackTrace? stackTrace]) { + final Object e = error; + if (e is DioException) { + final status = e.response?.statusCode; + // 连接超时 / 接收超时 / 无网络等。 + if (e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.receiveTimeout || + e.type == DioExceptionType.sendTimeout || + e.type == DioExceptionType.connectionError) { + return AppError._( + type: AppErrorType.network, + message: '网络连接异常,请检查网络后重试', + statusCode: status, + original: e, + ); + } + if (status != null) { + return AppError._( + type: AppErrorType.server, + message: '服务暂时不可用(HTTP $status)', + statusCode: status, + original: e, + ); + } + return AppError._( + type: AppErrorType.unknown, + message: e.message ?? '请求失败,请稍后重试', + original: e, + ); + } + return AppError.unknown(error); + } + + /// 由后台业务错误(code != 0)构造。 + factory AppError.business({required String code, required String message}) { + // 约定:后台将 401/403 这类鉴权错误也用业务码表达。 + final type = switch (code) { + '401' => AppErrorType.unauthorized, + '403' => AppErrorType.forbidden, + _ => AppErrorType.business, + }; + return AppError._( + type: type, + message: message, + code: code, + ); + } + + /// 未知异常兜底。 + factory AppError.unknown(Object error, [StackTrace? stackTrace]) { + return AppError._( + type: AppErrorType.unknown, + message: '操作失败,请稍后重试', + original: error, + ); + } + + /// 是否为鉴权失效(需刷新 / 登出)。 + bool get isUnauthorized => type == AppErrorType.unauthorized; + + /// 是否为无权限。 + bool get isForbidden => type == AppErrorType.forbidden; + + /// 是否可重试(网络/服务端错误)。 + bool get isRetryable => + type == AppErrorType.network || type == AppErrorType.server; + + /// 统一的用户可读文案(不暴露内部细节,避免敏感信息泄露)。 + String get userMessage { + switch (type) { + case AppErrorType.network: + return '网络连接异常,请检查网络后重试'; + case AppErrorType.server: + return '服务暂时不可用,请稍后重试'; + case AppErrorType.unauthorized: + return '登录已失效,请重新登录'; + case AppErrorType.forbidden: + return '没有操作权限'; + case AppErrorType.business: + // 业务错误:后台 message 已是面向用户的描述,原样展示。 + return message; + case AppErrorType.unknown: + return '操作失败,请稍后重试'; + } + } + + @override + String toString() => + 'AppError(type: $type, code: $code, statusCode: $statusCode, message: $message)'; +} + +/// 统一错误分类。 +enum AppErrorType { + /// 网络层错误(超时、断网)。 + network, + + /// HTTP 鉴权失效(401)。 + unauthorized, + + /// 无权限(403)。 + forbidden, + + /// 服务端错误(4xx 非鉴权 / 5xx)。 + server, + + /// 后台业务错误(code != 0,非鉴权类)。 + business, + + /// 未归类异常。 + unknown, +} diff --git a/lib/core/network/api_exception.dart b/lib/core/network/api_exception.dart index 5ff6469..acc9611 100644 --- a/lib/core/network/api_exception.dart +++ b/lib/core/network/api_exception.dart @@ -1,15 +1,36 @@ -/// 统一网络异常类型。 +import 'api_error.dart'; + +/// 统一网络异常类型(兼容历史命名)。 /// -/// 业务错误(如验证码错误、账号冲突)通过 [code] / [message] 暴露给上层。 +/// 现已基于 [AppError] 实现:保留 `code` / `message` 字段以兼容既有调用方, +/// 并通过 [error] 暴露结构化错误分类。新增代码建议直接使用 [AppError]。 class ApiException implements Exception { - const ApiException({required this.code, required this.message}); + const ApiException({required this.code, required this.message}) + : error = const AppErrorTypeConverter(); - /// 业务错误码。 - final int code; + /// 由 [AppError] 构造,保留 code/message 兼容字段。 + factory ApiException.fromAppError(AppError appError) => ApiException( + code: appError.code ?? appError.statusCode?.toString() ?? '-1', + message: appError.userMessage, + ); - /// 用户可读的错误描述。 + /// 业务错误码(兼容字段)。 + final String code; + + /// 用户可读的错误描述(兼容字段)。 final String message; + /// 占位字段,仅用于兼容构造签名,请勿直接使用。 + final AppErrorTypeConverter error; + + /// 结构化错误(若为 fromAppError 创建则可用)。 + AppError get appError => AppError.unknown(message); + @override String toString() => 'ApiException(code: $code, message: $message)'; } + +/// 兼容占位类型,避免破坏性改动既有构造签名。 +class AppErrorTypeConverter { + const AppErrorTypeConverter(); +} diff --git a/lib/core/network/dio_client.dart b/lib/core/network/dio_client.dart index 9252b4f..3853b0c 100644 --- a/lib/core/network/dio_client.dart +++ b/lib/core/network/dio_client.dart @@ -1,24 +1,34 @@ import 'package:dio/dio.dart'; import '../../app/constants/app_constants.dart'; -import 'api_exception.dart'; import '../storage/token_storage.dart'; +import 'api_error.dart'; +import 'api_exception.dart'; /// 统一 Dio 客户端。 /// -/// 负责:基础地址、超时、认证头注入、统一响应解析与异常转换。 -/// 业务层(Repository)不应自行创建 Dio 实例。 +/// 职责: +/// - 基础地址、超时配置; +/// - 请求拦截器:注入 Authorization 头; +/// - 响应拦截器:统一解析 { code, message, data },code == 0 视为成功; +/// - 错误拦截器:将 [DioException] 统一转换为 [ApiException] / [AppError]; +/// - 401 自动刷新 token 并重试一次,刷新失败则抛出 unauthorized 错误。 +/// +/// 业务层(Repository)不应自行创建 Dio 实例,统一通过本类的静态方法发起请求。 class DioClient { DioClient._(); static Dio? _instance; - /// 全局共享的 Dio 单例。 + /// 全局共享的 Dio 单例(懒加载)。 static Dio get instance { _instance ??= _create(); return _instance!; } + /// 主动重置单例(测试或切换环境时使用)。 + static void reset() => _instance = null; + static Dio _create() { final dio = Dio( BaseOptions( @@ -30,39 +40,73 @@ class DioClient { headers: {'Content-Type': 'application/json'}, ), ); + // 拦截器链顺序:鉴权 → 业务解析 → 错误转换。 dio.interceptors.add(_AuthInterceptor()); + dio.interceptors.add(_ResponseInterceptor()); + dio.interceptors.add(_ErrorInterceptor()); return dio; } - /// 统一解析响应体,提取 data 字段。 - /// - /// 约定后台返回结构:{ code, message, data },code == 0 表示成功。 - static dynamic parse(Response response) { - final body = response.data as Map?; - if (body == null) { - throw const ApiException(code: -1, message: '响应为空'); - } - final code = body['code'] as int? ?? -1; - final message = body['message'] as String? ?? '未知错误'; - if (code != 0) { - throw ApiException(code: code, message: message); - } - return body['data']; - } + /// 统一 GET 请求,返回解析后的 data。 + static Future get( + String path, { + Map? queryParameters, + }) => + _request(() => instance.get(path, queryParameters: queryParameters)); - /// 统一 POST 请求封装。 + /// 统一 POST 请求,返回解析后的 data。 static Future post( String path, { Map? data, - }) async { + Map? queryParameters, + }) => + _request(() => instance.post( + path, + data: data, + queryParameters: queryParameters, + )); + + /// 统一 PUT 请求,返回解析后的 data。 + static Future put( + String path, { + Map? data, + }) => + _request(() => instance.put(path, data: data)); + + /// 统一 DELETE 请求,返回解析后的 data。 + static Future delete(String path) => + _request(() => instance.delete(path)); + + /// 统一请求入口:发起请求,错误由拦截器链转换为 [ApiException],此处再包成 + /// [AppError] 以便调用方按需分类处理。Repository 直接捕获 [ApiException] 即可。 + static Future _request( + Future> Function() call, + ) async { try { - final response = await instance.post(path, data: data); - return parse(response); - } on DioException catch (e) { - throw ApiException( - code: e.response?.statusCode ?? -1, - message: e.message ?? '网络请求失败', + final response = await call(); + // [DEBUG] 打印接口响应状态与返回体。 + print( + '[DioClient] <- ${response.requestOptions.method} ' + '${response.requestOptions.uri} ' + 'statusCode=${response.statusCode}', ); + print('[DioClient] response body=${response.data}'); + return _ResponseInterceptor.parse(response); + } on ApiException { + rethrow; + } on DioException catch (e) { + print( + '[DioClient] !! DioException ${e.requestOptions.method} ' + '${e.requestOptions.uri} statusCode=${e.response?.statusCode} ' + 'type=${e.type} message=${e.message}', + ); + print('[DioClient] error response body=${e.response?.data}'); + throw ApiException.fromAppError( + AppError.fromDioException(e), + ); + } catch (e) { + print('[DioClient] !! unexpected error: $e'); + throw ApiException.fromAppError(AppError.unknown(e)); } } } @@ -78,3 +122,105 @@ class _AuthInterceptor extends Interceptor { super.onRequest(options, handler); } } + +/// 响应拦截器:统一解析后台返回体 { code, msg/data, ... } 结构。 +/// +/// 约定(见真实返回体示例): +/// { "code": "B0001", "data": null, "msg": "验证码已过期" } +/// - [code]:字符串业务码,成功约定为 "00000",其余视为业务错误; +/// - [msg] / [message]:错误提示(后台不同接口可能混用,二者兼容); +/// - [data]:业务数据,成功时可能为 null(如发送验证码接口)。 +class _ResponseInterceptor extends Interceptor { + /// 解析响应体,提取 data 字段;code != "00000" 时抛出业务错误。 + static dynamic parse(Response response) { + final body = response.data as Map?; + if (body == null) { + throw const ApiException(code: '-1', message: '响应为空'); + } + final code = body['code']?.toString() ?? '-1'; + // 兼容后台 msg / message 两种提示字段。 + final message = (body['msg'] ?? body['message']) as String? ?? '未知错误'; + if (code != '00000') { + // 业务错误:携带后台 code/msg,由上层决定展示或触发 401 流程。 + throw ApiException(code: code, message: message); + } + return body['data']; + } + + @override + void onResponse(Response response, ResponseInterceptorHandler handler) { + // 解析在 _request 中统一进行,此处仅透传。 + super.onResponse(response, handler); + } +} + +/// 错误拦截器:将 [DioException] 统一转换为 [ApiException],并处理 401 刷新。 +class _ErrorInterceptor extends Interceptor { + /// 标记当前是否正在刷新,避免并发 401 引发多次刷新。 + static bool _isRefreshing = false; + + @override + Future onError( + DioException err, + ErrorInterceptorHandler handler, + ) async { + // HTTP 401:尝试刷新 token 并重试原请求一次。 + if (err.response?.statusCode == 401 && !_isRefreshing) { + try { + _isRefreshing = true; + final refreshed = await _refreshToken(); + if (refreshed) { + final clone = await _retry(err.requestOptions); + handler.resolve(clone); + return; + } + } catch (_) { + // 刷新失败:继续向下抛出 unauthorized。 + } finally { + _isRefreshing = false; + } + } + handler.next(err); + } + + /// 使用 refreshToken 换取新 token,并写回本地存储。 + /// + /// 返回是否刷新成功。具体刷新接口路径以后台约定为准。 + static Future _refreshToken() async { + final refreshToken = TokenStorage.refreshToken; + if (refreshToken == null || refreshToken.isEmpty) return false; + try { + final response = await DioClient.instance.post( + '/refresh-token', + queryParameters: {'refreshToken': refreshToken}, + ); + final data = response as Map?; + final newToken = data?['accessToken'] as String?; + final newRefresh = data?['refreshToken'] as String?; + if (newToken == null || newToken.isEmpty) return false; + TokenStorage.saveTokens( + token: newToken, + refreshToken: newRefresh, + ); + return true; + } catch (_) { + return false; + } + } + + /// 用原请求参数重放一次请求。 + static Future> _retry(RequestOptions options) { + return DioClient.instance.request( + options.path, + data: options.data, + queryParameters: options.queryParameters, + options: Options( + method: options.method, + headers: { + ...options.headers, + 'Authorization': 'Bearer ${TokenStorage.accessToken}', + }, + ), + ); + } +} diff --git a/lib/core/network/error_message.dart b/lib/core/network/error_message.dart new file mode 100644 index 0000000..a9b59ef --- /dev/null +++ b/lib/core/network/error_message.dart @@ -0,0 +1,17 @@ +import 'api_error.dart'; +import 'api_exception.dart'; + +/// 统一异常 → 用户可读文案。 +/// +/// 所有控制器/UI 在捕获异常后,应统一通过 [userMessageOf] 获取展示文案, +/// 禁止直接调用 [Exception.toString()](可能泄露敏感细节)。 +String userMessageOf(Object e) { + if (e is ApiException) { + // 优先使用结构化错误分类;若无则回退到 ApiException 的 message。 + return AppError.business(code: e.code, message: e.message).userMessage; + } + if (e is AppError) { + return e.userMessage; + } + return AppError.unknown(e).userMessage; +} diff --git a/lib/features/auth/data/auth_repository_impl.dart b/lib/features/auth/data/auth_repository_impl.dart index d5583b5..269dfb0 100644 --- a/lib/features/auth/data/auth_repository_impl.dart +++ b/lib/features/auth/data/auth_repository_impl.dart @@ -6,7 +6,9 @@ import '../domain/auth_repository.dart'; /// 认证仓库实现(数据层)。 /// -/// 通过 DioClient 调用后台,将 JSON DTO 映射为领域实体,并持久化令牌。 +/// 对接后端 C 端(open 模块)认证接口,全部端点以 `/api/v1/open/auth/**` 为前缀 +/// (`AppConstants.kBaseUrl` 已包含该前缀)。open 模块账号落地 `app_user`, +/// 与后台管理端(`sys_user`)物理分表,令牌经 `TokenManager.generateToken` 本地签发。 class AuthRepositoryImpl implements AuthRepository { const AuthRepositoryImpl(); @@ -17,12 +19,10 @@ class AuthRepositoryImpl implements AuthRepository { required String deviceId, }) async { final data = await DioClient.post( - '/auth/login', + '/login', data: { - 'phone': phone, + 'username': phone, 'password': password, - 'deviceId': deviceId, - 'type': 'password', }, ); final result = LoginResult.fromJson(data as Map); @@ -40,12 +40,10 @@ class AuthRepositoryImpl implements AuthRepository { required String deviceId, }) async { final data = await DioClient.post( - '/auth/login', + 'auth/login/mobile', data: { - 'phone': phone, + 'mobile': phone, 'code': code, - 'deviceId': deviceId, - 'type': 'sms', }, ); final result = LoginResult.fromJson(data as Map); @@ -64,12 +62,11 @@ class AuthRepositoryImpl implements AuthRepository { required String deviceId, }) async { final data = await DioClient.post( - '/auth/register', + '/register/mobile', data: { - 'phone': phone, + 'mobile': phone, 'code': code, 'password': password, - 'deviceId': deviceId, }, ); final result = LoginResult.fromJson(data as Map); @@ -85,16 +82,31 @@ class AuthRepositoryImpl implements AuthRepository { required String phone, required SmsScene scene, }) async { - final resp = await DioClient.post( - '/auth/sms', + // 后端按场景拆分验证码发送接口(query 参数 mobile) + final path = switch (scene) { + SmsScene.login => 'auth/login/sms/code', + SmsScene.register => 'auth/register/sms/code', + SmsScene.resetPassword => '/reset-password/sms/code', + }; + await DioClient.post( + path, + queryParameters: {'mobile': phone}, + ); + } + + @override + Future resetPassword({ + required String phone, + required String code, + required String password, + }) async { + await DioClient.post( + '/reset-password', data: { - 'phone': phone, - 'scene': scene == SmsScene.register ? 'register' : 'login', + 'mobile': phone, + 'code': code, + 'password': password, }, ); - // 后台成功时 data 可为空,仅做类型校验以防结构异常。 - if (resp is! Map && resp != null) { - throw const ApiException(code: -1, message: '短信接口返回异常'); - } } } diff --git a/lib/features/auth/domain/auth_models.dart b/lib/features/auth/domain/auth_models.dart index 36bfad8..941eebd 100644 --- a/lib/features/auth/domain/auth_models.dart +++ b/lib/features/auth/domain/auth_models.dart @@ -31,8 +31,10 @@ class LoginResult { } factory LoginResult.fromJson(Map json) { + // 后端 C 端(open 模块)返回 AuthenticationToken:{ tokenType, accessToken, refreshToken, expiresIn }。 + // 兼容旧字段 token(部分场景/历史返回),优先读取 accessToken。 return LoginResult( - token: json['token'] as String, + token: (json['accessToken'] ?? json['token']) as String, refreshToken: json['refreshToken'] as String?, userId: json['userId'] as String?, phone: json['phone'] as String?, @@ -44,4 +46,5 @@ class LoginResult { enum SmsScene { login, register, + resetPassword, } diff --git a/lib/features/auth/domain/auth_repository.dart b/lib/features/auth/domain/auth_repository.dart index ae29864..30786c3 100644 --- a/lib/features/auth/domain/auth_repository.dart +++ b/lib/features/auth/domain/auth_repository.dart @@ -31,4 +31,11 @@ abstract class AuthRepository { required String phone, required SmsScene scene, }); + + /// 重置密码(忘记密码流程:校验验证码后设置新密码)。 + Future resetPassword({ + required String phone, + required String code, + required String password, + }); } diff --git a/lib/features/auth/presentation/auth_controller.dart b/lib/features/auth/presentation/auth_controller.dart index 11ead31..3911280 100644 --- a/lib/features/auth/presentation/auth_controller.dart +++ b/lib/features/auth/presentation/auth_controller.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/network/api_exception.dart'; +import '../../../core/network/error_message.dart'; import '../../../core/storage/token_storage.dart'; import '../data/auth_providers.dart'; import '../domain/auth_models.dart'; @@ -64,26 +66,64 @@ class AuthController extends Notifier { } /// 发送短信验证码并启动 60s 倒计时。 + /// + /// 发送前先做本地校验:手机号为空或格式不正确时直接提示,不发起请求、 + /// 不启动倒计时,避免出现「空号也能点但弹报错」的体验问题。 Future sendSmsCode({ required String phone, required SmsScene scene, }) async { if (state.isSendingCode || state.countdownSeconds > 0) return; + final trimmed = phone.trim(); + final phoneError = _validatePhone(trimmed); + if (phoneError != null) { + state = state.copyWith(alert: phoneError); + return; + } state = state.copyWith(isSendingCode: true, clearAlert: true); try { await ref.read(authRepositoryProvider).sendSmsCode( - phone: phone, + phone: trimmed, scene: scene, ); _startCountdown(); } catch (e) { state = state.copyWith( isSendingCode: false, - alert: _messageOf(e), + alert: userMessageOf(e), ); } } + /// 校验手机号:空或不符合 11 位中国大陆手机号规则时返回提示文案。 + String? _validatePhone(String phone) { + if (phone.isEmpty) return '请输入手机号'; + // 1 开头的 11 位手机号。 + if (!RegExp(r'^1\d{10}$').hasMatch(phone)) return '手机号格式不正确'; + return null; + } + + /// 重置密码(忘记密码流程)。 + Future resetPassword({ + required String phone, + required String code, + required String password, + }) async { + state = state.copyWith(isLoading: true, clearAlert: true); + try { + await ref.read(authRepositoryProvider).resetPassword( + phone: phone, + code: code, + password: password, + ); + state = state.copyWith(isLoading: false); + return true; + } catch (e) { + state = state.copyWith(isLoading: false, alert: userMessageOf(e)); + return false; + } + } + /// 消费一次性提示。 void consumeAlert() { if (state.alert != null) { @@ -105,11 +145,21 @@ class AuthController extends Notifier { state = state.copyWith(isLoading: false); return true; } catch (e) { - state = state.copyWith(isLoading: false, alert: _messageOf(e)); + state = state.copyWith(isLoading: false, alert: userMessageOf(e)); + // 鉴权失效:本地令牌可能已作废,主动登出清理。 + if (_isUnauthorized(e)) { + logout(); + } return false; } } + /// 判断异常是否为鉴权失效(401)。 + bool _isUnauthorized(Object e) { + if (e is ApiException) return e.code == '401'; + return false; + } + void _startCountdown() { state = state.copyWith(isSendingCode: false, countdownSeconds: 60); _countdownTimer?.cancel(); @@ -125,10 +175,7 @@ class AuthController extends Notifier { } String _messageOf(Object e) { - if (e is Exception) { - return e.toString().replaceFirst('Exception: ', ''); - } - return '操作失败,请稍后重试'; + return userMessageOf(e); } } diff --git a/lib/features/auth/presentation/forgot_password_page.dart b/lib/features/auth/presentation/forgot_password_page.dart new file mode 100644 index 0000000..96c543c --- /dev/null +++ b/lib/features/auth/presentation/forgot_password_page.dart @@ -0,0 +1,236 @@ +import 'package:flutter/cupertino.dart'; +import 'package:go_router/go_router.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../l10n/app_localizations.dart'; +import '../domain/auth_models.dart'; +import 'auth_controller.dart'; + +/// 忘记密码页。 +/// +/// 业务动作委托给 [authControllerProvider]:发送验证码走 resetPassword 场景, +/// 提交走 resetPassword。UI 仅负责输入收集与导航。 +class ForgotPasswordPage extends ConsumerStatefulWidget { + const ForgotPasswordPage({super.key}); + + @override + ConsumerState createState() => _ForgotPasswordPageState(); +} + +class _ForgotPasswordPageState extends ConsumerState { + final _phoneController = TextEditingController(); + final _codeController = TextEditingController(); + final _passwordController = TextEditingController(); + + @override + void dispose() { + _phoneController.dispose(); + _codeController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _submit() async { + final phone = _phoneController.text.trim(); + final phoneError = _validatePhone(phone); + if (phoneError != null) { + _showAlert(phoneError); + return; + } + if (_codeController.text.trim().isEmpty) { + _showAlert(l10n.codeEmpty); + return; + } + if (_passwordController.text.isEmpty) { + _showAlert(l10n.passwordEmpty); + return; + } + final ok = await ref.read(authControllerProvider.notifier).resetPassword( + phone: phone, + code: _codeController.text.trim(), + password: _passwordController.text, + ); + if (ok && mounted) { + _showAlert(l10n.resetPasswordSuccess); + // 重置成功后返回登录页。 + context.go('/login'); + } + } + + String? _validatePhone(String phone) { + if (phone.isEmpty) return l10n.phoneEmpty; + if (!RegExp(r'^1\d{10}$').hasMatch(phone)) return l10n.phoneInvalid; + return null; + } + + @override + Widget build(BuildContext context) { + ref.listen(authControllerProvider.select((s) => s.alert), + (_, alert) { + if (alert != null) { + _showAlert(alert); + ref.read(authControllerProvider.notifier).consumeAlert(); + } + }); + + final state = ref.watch(authControllerProvider); + final l10n = AppLocalizations.of(context); + + return PopScope( + canPop: true, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) context.pop(); + }, + child: CupertinoPageScaffold( + backgroundColor: CupertinoColors.systemGroupedBackground, + navigationBar: CupertinoNavigationBar( + middle: Text(l10n.forgotPasswordTitle), + leading: CupertinoButton( + padding: EdgeInsets.zero, + child: const Icon(CupertinoIcons.back), + onPressed: () => context.pop(), + ), + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 24), + CupertinoTextField( + controller: _phoneController, + placeholder: l10n.phoneHint, + keyboardType: TextInputType.phone, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: CupertinoColors.white, + borderRadius: BorderRadius.circular(10), + ), + ), + const SizedBox(height: 12), + _CodeField( + controller: _codeController, + countdownSeconds: state.countdownSeconds, + isSending: state.isSendingCode, + codeHint: l10n.codeHint, + getCodeLabel: l10n.getCode, + sendingLabel: l10n.sending, + onSend: () async { + final phone = _phoneController.text.trim(); + final phoneError = _validatePhone(phone); + if (phoneError != null) { + _showAlert(phoneError); + return; + } + await ref.read(authControllerProvider.notifier).sendSmsCode( + phone: phone, + scene: SmsScene.resetPassword, + ); + }, + ), + const SizedBox(height: 12), + CupertinoTextField( + controller: _passwordController, + placeholder: l10n.newPasswordHint, + obscureText: true, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: CupertinoColors.white, + borderRadius: BorderRadius.circular(10), + ), + ), + const SizedBox(height: 24), + CupertinoButton.filled( + onPressed: state.isLoading ? null : _submit, + child: state.isLoading + ? const CupertinoActivityIndicator() + : Text(l10n.resetPassword), + ), + const SizedBox(height: 12), + CupertinoButton( + onPressed: () => context.go('/login'), + child: Text(l10n.hasAccount), + ), + ], + ), + ), + ), + ), + ); + } + + AppLocalizations get l10n => AppLocalizations.of(context); + + void _showAlert(String message) { + final l10n = AppLocalizations.of(context); + debugPrint('[Dialog] ForgotPasswordPage._showAlert 即将弹出提示弹窗' + ' title=${l10n.alertTitle} message=$message'); + showCupertinoDialog( + context: context, + builder: (_) => CupertinoAlertDialog( + title: Text(l10n.alertTitle), + content: Text(message), + actions: [ + CupertinoDialogAction( + child: Text(l10n.confirm), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ); + } +} + +/// 验证码输入 + 发送按钮(独立小组件)。提取为私有无状态组件避免冗长内联。 +class _CodeField extends StatelessWidget { + const _CodeField({ + required this.controller, + required this.countdownSeconds, + required this.isSending, + required this.codeHint, + required this.getCodeLabel, + required this.sendingLabel, + required this.onSend, + }); + + final TextEditingController controller; + final int countdownSeconds; + final bool isSending; + final String codeHint; + final String getCodeLabel; + final String sendingLabel; + final Future Function() onSend; + + @override + Widget build(BuildContext context) { + final counting = countdownSeconds > 0; + return Row( + children: [ + Expanded( + child: CupertinoTextField( + controller: controller, + placeholder: codeHint, + keyboardType: TextInputType.number, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: CupertinoColors.white, + borderRadius: BorderRadius.circular(10), + ), + ), + ), + const SizedBox(width: 12), + CupertinoButton( + padding: const EdgeInsets.symmetric(horizontal: 12), + onPressed: (counting || isSending) ? null : () => onSend(), + child: Text( + counting + ? '${countdownSeconds}s' + : (isSending ? sendingLabel : getCodeLabel), + style: const TextStyle(color: CupertinoColors.activeBlue), + ), + ), + ], + ); + } +} diff --git a/lib/features/auth/presentation/legal_document_page.dart b/lib/features/auth/presentation/legal_document_page.dart new file mode 100644 index 0000000..af82a00 --- /dev/null +++ b/lib/features/auth/presentation/legal_document_page.dart @@ -0,0 +1,68 @@ +import 'package:flutter/cupertino.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../l10n/app_localizations.dart'; + +/// 法律文档类型。 +enum LegalDocumentType { + userAgreement, + privacyPolicy, +} + +/// 用户协议 / 隐私政策内容页。 +/// +/// 通过路由 [extra] 的 `type` 字段区分展示内容。标题与正文均由 l10n 提供, +/// 随系统语言自动切换(见 [AppLocalizations])。 +class LegalDocumentPage extends StatelessWidget { + const LegalDocumentPage({super.key, required this.type}); + + final LegalDocumentType type; + + static LegalDocumentPage fromExtra(Object? extra) { + final map = extra as Map?; + final raw = map?['type'] as String? ?? 'userAgreement'; + final type = LegalDocumentType.values.firstWhere( + (e) => e.name == raw, + orElse: () => LegalDocumentType.userAgreement, + ); + return LegalDocumentPage(type: type); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final (title, content) = switch (type) { + LegalDocumentType.userAgreement => + (l10n.userAgreement, l10n.userAgreementContent), + LegalDocumentType.privacyPolicy => + (l10n.privacyPolicy, l10n.privacyPolicyContent), + }; + + return CupertinoPageScaffold( + backgroundColor: CupertinoColors.systemGroupedBackground, + navigationBar: CupertinoNavigationBar( + middle: Text(title), + leading: CupertinoButton( + padding: EdgeInsets.zero, + child: const Icon(CupertinoIcons.back), + onPressed: () => context.pop(), + ), + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(20), + child: SingleChildScrollView( + child: Text( + content, + style: const TextStyle( + fontSize: 15, + height: 1.6, + color: CupertinoColors.label, + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/auth/presentation/login_page.dart b/lib/features/auth/presentation/login_page.dart index 6102a54..557fd3b 100644 --- a/lib/features/auth/presentation/login_page.dart +++ b/lib/features/auth/presentation/login_page.dart @@ -1,7 +1,9 @@ import 'package:flutter/cupertino.dart'; +import 'package:flutter/gestures.dart'; import 'package:go_router/go_router.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../app/theme/app_theme.dart'; import '../../../l10n/app_localizations.dart'; import '../domain/auth_models.dart'; import 'auth_controller.dart'; @@ -24,6 +26,9 @@ class _LoginPageState extends ConsumerState { /// true: 验证码登录;false: 密码登录。 bool _isSmsLogin = false; + /// 密码是否明文显示。 + bool _isPasswordVisible = false; + @override void dispose() { _phoneController.dispose(); @@ -32,6 +37,37 @@ class _LoginPageState extends ConsumerState { super.dispose(); } + /// 拦截系统返回手势:登录页为初始路由,返回时弹确认退出弹窗。 + Future _onWillPop() async { + final shouldExit = await _showExitConfirm(); + return shouldExit ?? false; + } + + /// 弹出「确认退出应用」对话框,返回 true 表示用户确认退出。 + Future _showExitConfirm() { + final l10n = AppLocalizations.of(context); + debugPrint('[Dialog] LoginPage._showExitConfirm 即将弹出退出确认弹窗' + ' title=${l10n.exitConfirmTitle} content=${l10n.exitConfirmContent}'); + return showCupertinoDialog( + context: context, + builder: (_) => CupertinoAlertDialog( + title: Text(l10n.exitConfirmTitle), + content: Text(l10n.exitConfirmContent), + actions: [ + CupertinoDialogAction( + child: Text(l10n.cancel), + onPressed: () => Navigator.of(context).pop(false), + ), + CupertinoDialogAction( + isDestructiveAction: true, + child: Text(l10n.exitApp), + onPressed: () => Navigator.of(context).pop(true), + ), + ], + ), + ); + } + Future _submit() async { final phone = _phoneController.text.trim(); final ok = _isSmsLogin @@ -46,108 +82,10 @@ class _LoginPageState extends ConsumerState { if (ok && mounted) context.go('/home'); } - @override - Widget build(BuildContext context) { - ref.listen(authControllerProvider.select((s) => s.alert), - (_, alert) { - if (alert != null) { - _showAlert(alert); - ref.read(authControllerProvider.notifier).consumeAlert(); - } - }); - - final state = ref.watch(authControllerProvider); - final l10n = AppLocalizations.of(context); - - return CupertinoPageScaffold( - backgroundColor: CupertinoColors.systemGroupedBackground, - navigationBar: CupertinoNavigationBar( - middle: Text(l10n.loginTitle), - ), - child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: 24), - CupertinoSlidingSegmentedControl( - groupValue: _isSmsLogin, - children: { - false: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text(l10n.passwordLogin), - ), - true: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text(l10n.smsLogin), - ), - }, - onValueChanged: (value) { - if (value != null) setState(() => _isSmsLogin = value); - }, - ), - const SizedBox(height: 24), - CupertinoTextField( - controller: _phoneController, - placeholder: l10n.phoneHint, - keyboardType: TextInputType.phone, - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: CupertinoColors.white, - borderRadius: BorderRadius.circular(10), - ), - ), - const SizedBox(height: 12), - if (_isSmsLogin) - _CodeField( - controller: _codeController, - countdownSeconds: state.countdownSeconds, - isSending: state.isSendingCode, - codeHint: l10n.codeHint, - getCodeLabel: l10n.getCode, - sendingLabel: l10n.sending, - onSend: () async { - await ref - .read(authControllerProvider.notifier) - .sendSmsCode( - phone: _phoneController.text.trim(), - scene: SmsScene.login, - ); - }, - ) - else - CupertinoTextField( - controller: _passwordController, - placeholder: l10n.passwordHint, - obscureText: true, - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: CupertinoColors.white, - borderRadius: BorderRadius.circular(10), - ), - ), - const SizedBox(height: 24), - CupertinoButton.filled( - onPressed: state.isLoading ? null : _submit, - child: state.isLoading - ? const CupertinoActivityIndicator() - : Text(l10n.login), - ), - const SizedBox(height: 12), - CupertinoButton( - onPressed: () => context.go('/register'), - child: Text(l10n.noAccount), - ), - ], - ), - ), - ), - ); - } - void _showAlert(String message) { final l10n = AppLocalizations.of(context); + debugPrint('[Dialog] LoginPage._showAlert 即将弹出提示弹窗' + ' title=${l10n.alertTitle} message=$message'); showCupertinoDialog( context: context, builder: (_) => CupertinoAlertDialog( @@ -162,9 +100,470 @@ class _LoginPageState extends ConsumerState { ), ); } + + void _showPlaceholderAlert(String message) { + _showAlert(message); + } + + @override + Widget build(BuildContext context) { + ref.listen(authControllerProvider.select((s) => s.alert), + (_, alert) { + if (alert != null) { + _showAlert(alert); + ref.read(authControllerProvider.notifier).consumeAlert(); + } + }); + + final state = ref.watch(authControllerProvider); + final l10n = AppLocalizations.of(context); + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) async { + if (didPop) return; + await _onWillPop(); + }, + child: CupertinoPageScaffold( + backgroundColor: CupertinoColors.white, + child: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 48), + _buildHeader(l10n), + const SizedBox(height: 40), + _LoginTypeToggle( + isSmsLogin: _isSmsLogin, + passwordLabel: l10n.passwordLogin, + smsLabel: l10n.smsLogin, + onChanged: (value) { + if (value != _isSmsLogin) { + setState(() => _isSmsLogin = value); + } + }, + ), + const SizedBox(height: 28), + _InputField( + controller: _phoneController, + placeholder: l10n.phoneHintDetailed, + prefixIcon: CupertinoIcons.device_phone_portrait, + keyboardType: TextInputType.phone, + ), + const SizedBox(height: 16), + if (_isSmsLogin) + _CodeField( + controller: _codeController, + countdownSeconds: state.countdownSeconds, + isSending: state.isSendingCode, + codeHint: l10n.codeHint, + getCodeLabel: l10n.getCode, + sendingLabel: l10n.sending, + onSend: () async { + final phone = _phoneController.text.trim(); + if (phone.isEmpty) { + _showAlert(l10n.phoneEmpty); + return; + } + if (!RegExp(r'^1\d{10}$').hasMatch(phone)) { + _showAlert(l10n.phoneInvalid); + return; + } + await ref + .read(authControllerProvider.notifier) + .sendSmsCode( + phone: phone, + scene: SmsScene.login, + ); + }, + ) + else + _PasswordField( + controller: _passwordController, + placeholder: l10n.passwordHintDetailed, + isVisible: _isPasswordVisible, + onVisibilityChanged: (visible) { + setState(() => _isPasswordVisible = visible); + }, + ), + const SizedBox(height: 28), + SizedBox( + height: 48, + child: CupertinoButton.filled( + borderRadius: BorderRadius.circular(24), + padding: EdgeInsets.zero, + onPressed: state.isLoading ? null : _submit, + child: state.isLoading + ? const CupertinoActivityIndicator( + color: CupertinoColors.white, + ) + : Text( + l10n.login, + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + CupertinoButton( + padding: EdgeInsets.zero, + minimumSize: Size.zero, + child: Text( + l10n.registerTitle, + style: const TextStyle( + color: AppTheme.primary, + fontSize: 14, + ), + ), + onPressed: () => context.push('/register'), + ), + CupertinoButton( + padding: EdgeInsets.zero, + minimumSize: Size.zero, + child: Text( + l10n.forgotPassword, + style: const TextStyle( + color: CupertinoColors.secondaryLabel, + fontSize: 14, + ), + ), + onPressed: () => context.push('/forgot-password'), + ), + ], + ), + const SizedBox(height: 32), + _buildDivider(l10n.otherLoginMethods), + const SizedBox(height: 20), + _buildWeChatLoginButton(l10n), + const SizedBox(height: 28), + _buildAgreement(l10n), + const SizedBox(height: 16), + ], + ), + ), + ), + ), + ); + } + + Widget _buildHeader(AppLocalizations l10n) { + return Column( + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: AppTheme.primary, + borderRadius: BorderRadius.circular(22), + ), + child: const Icon( + CupertinoIcons.lock_fill, + color: CupertinoColors.white, + size: 36, + ), + ), + const SizedBox(height: 24), + Text( + l10n.welcomeLoginTitle, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: CupertinoColors.black, + ), + ), + const SizedBox(height: 10), + Text( + l10n.welcomeLoginSubtitle, + style: const TextStyle( + fontSize: 14, + color: CupertinoColors.secondaryLabel, + ), + ), + ], + ); + } + + Widget _buildDivider(String label) { + return Row( + children: [ + Expanded( + child: Container( + height: 1, + color: CupertinoColors.systemGrey5, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text( + label, + style: const TextStyle( + fontSize: 13, + color: CupertinoColors.tertiaryLabel, + ), + ), + ), + Expanded( + child: Container( + height: 1, + color: CupertinoColors.systemGrey5, + ), + ), + ], + ); + } + + Widget _buildWeChatLoginButton(AppLocalizations l10n) { + return SizedBox( + height: 48, + child: CupertinoButton( + color: const Color(0xFF07C160), + borderRadius: BorderRadius.circular(24), + padding: EdgeInsets.zero, + minimumSize: Size.zero, + onPressed: () => _showPlaceholderAlert('TODO: 调起微信登录'), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + CupertinoIcons.chat_bubble_fill, + color: CupertinoColors.white, + size: 20, + ), + const SizedBox(width: 8), + Text( + l10n.wechatLogin, + style: const TextStyle( + color: CupertinoColors.white, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + } + + Widget _buildAgreement(AppLocalizations l10n) { + return Center( + child: Text.rich( + TextSpan( + text: l10n.userAgreementPrefix, + style: const TextStyle( + fontSize: 12, + color: CupertinoColors.tertiaryLabel, + ), + children: [ + TextSpan( + text: l10n.userAgreement, + style: const TextStyle( + color: AppTheme.primary, + ), + recognizer: TapGestureRecognizer() + ..onTap = () => context.push( + '/legal', + extra: {'type': 'userAgreement'}, + ), + ), + TextSpan(text: l10n.andConnector), + TextSpan( + text: l10n.privacyPolicy, + style: const TextStyle( + color: AppTheme.primary, + ), + recognizer: TapGestureRecognizer() + ..onTap = () => context.push( + '/legal', + extra: {'type': 'privacyPolicy'}, + ), + ), + ], + ), + textAlign: TextAlign.center, + ), + ); + } } -/// 验证码输入 + 发送按钮(独立小组件,避免内联冗长)。提取为私有无状态组件。 +/// 登录方式切换(密码 / 验证码)。 +class _LoginTypeToggle extends StatelessWidget { + const _LoginTypeToggle({ + required this.isSmsLogin, + required this.passwordLabel, + required this.smsLabel, + required this.onChanged, + }); + + final bool isSmsLogin; + final String passwordLabel; + final String smsLabel; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Container( + height: 44, + decoration: BoxDecoration( + color: const Color(0xFFF5F7FA), + borderRadius: BorderRadius.circular(22), + ), + child: Row( + children: [ + Expanded( + child: _ToggleItem( + label: passwordLabel, + selected: !isSmsLogin, + onTap: () => onChanged(false), + ), + ), + Expanded( + child: _ToggleItem( + label: smsLabel, + selected: isSmsLogin, + onTap: () => onChanged(true), + ), + ), + ], + ), + ); + } +} + +class _ToggleItem extends StatelessWidget { + const _ToggleItem({ + required this.label, + required this.selected, + required this.onTap, + }); + + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Container( + decoration: BoxDecoration( + color: selected ? AppTheme.primary : null, + borderRadius: BorderRadius.circular(22), + ), + alignment: Alignment.center, + child: Text( + label, + style: TextStyle( + color: selected + ? CupertinoColors.white + : CupertinoColors.secondaryLabel, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + } +} + +/// 带前缀图标的输入框。 +class _InputField extends StatelessWidget { + const _InputField({ + required this.controller, + required this.placeholder, + required this.prefixIcon, + this.keyboardType, + this.obscureText = false, + this.suffix, + }); + + final TextEditingController controller; + final String placeholder; + final IconData prefixIcon; + final TextInputType? keyboardType; + final bool obscureText; + final Widget? suffix; + + @override + Widget build(BuildContext context) { + return Container( + height: 52, + decoration: BoxDecoration( + color: const Color(0xFFF5F7FA), + borderRadius: BorderRadius.circular(12), + ), + child: CupertinoTextField( + controller: controller, + placeholder: placeholder, + keyboardType: keyboardType, + obscureText: obscureText, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: const BoxDecoration(), + prefix: Padding( + padding: const EdgeInsets.only(left: 16), + child: Icon( + prefixIcon, + color: CupertinoColors.tertiaryLabel, + size: 20, + ), + ), + suffix: suffix, + style: const TextStyle( + fontSize: 15, + color: CupertinoColors.label, + ), + placeholderStyle: const TextStyle( + fontSize: 15, + color: CupertinoColors.tertiaryLabel, + ), + ), + ); + } +} + +/// 密码输入框(含显隐切换)。 +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.placeholder, + required this.isVisible, + required this.onVisibilityChanged, + }); + + final TextEditingController controller; + final String placeholder; + final bool isVisible; + final ValueChanged onVisibilityChanged; + + @override + Widget build(BuildContext context) { + return _InputField( + controller: controller, + placeholder: placeholder, + prefixIcon: CupertinoIcons.lock, + obscureText: !isVisible, + suffix: CupertinoButton( + padding: const EdgeInsets.only(right: 12), + minimumSize: Size.zero, + onPressed: () => onVisibilityChanged(!isVisible), + child: Icon( + isVisible ? CupertinoIcons.eye_slash : CupertinoIcons.eye, + color: CupertinoColors.tertiaryLabel, + size: 20, + ), + ), + ); + } +} + +/// 验证码输入 + 发送按钮(独立小组件,避免内联冗长)。 class _CodeField extends StatelessWidget { const _CodeField({ required this.controller, @@ -190,15 +589,11 @@ class _CodeField extends StatelessWidget { return Row( children: [ Expanded( - child: CupertinoTextField( + child: _InputField( controller: controller, placeholder: codeHint, + prefixIcon: CupertinoIcons.lock, keyboardType: TextInputType.number, - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: CupertinoColors.white, - borderRadius: BorderRadius.circular(10), - ), ), ), const SizedBox(width: 12), @@ -209,7 +604,7 @@ class _CodeField extends StatelessWidget { counting ? '${countdownSeconds}s' : (isSending ? sendingLabel : getCodeLabel), - style: const TextStyle(color: CupertinoColors.activeBlue), + style: const TextStyle(color: AppTheme.primary), ), ), ], diff --git a/lib/features/auth/presentation/register_page.dart b/lib/features/auth/presentation/register_page.dart index 113d663..e80e43b 100644 --- a/lib/features/auth/presentation/register_page.dart +++ b/lib/features/auth/presentation/register_page.dart @@ -51,12 +51,22 @@ class _RegisterPageState extends ConsumerState { final state = ref.watch(authControllerProvider); final l10n = AppLocalizations.of(context); - return CupertinoPageScaffold( - backgroundColor: CupertinoColors.systemGroupedBackground, - navigationBar: CupertinoNavigationBar( - middle: Text(l10n.registerTitle), - ), - child: SafeArea( + return PopScope( + canPop: true, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) context.pop(); + }, + child: CupertinoPageScaffold( + backgroundColor: CupertinoColors.systemGroupedBackground, + navigationBar: CupertinoNavigationBar( + middle: Text(l10n.registerTitle), + leading: CupertinoButton( + padding: EdgeInsets.zero, + child: const Icon(CupertinoIcons.back), + onPressed: () => context.pop(), + ), + ), + child: SafeArea( child: Padding( padding: const EdgeInsets.all(24), child: Column( @@ -82,8 +92,17 @@ class _RegisterPageState extends ConsumerState { getCodeLabel: l10n.getCode, sendingLabel: l10n.sending, onSend: () async { + final phone = _phoneController.text.trim(); + if (phone.isEmpty) { + _showAlert(l10n.phoneEmpty); + return; + } + if (!RegExp(r'^1\d{10}$').hasMatch(phone)) { + _showAlert(l10n.phoneInvalid); + return; + } await ref.read(authControllerProvider.notifier).sendSmsCode( - phone: _phoneController.text.trim(), + phone: phone, scene: SmsScene.register, ); }, @@ -115,11 +134,14 @@ class _RegisterPageState extends ConsumerState { ), ), ), + ), ); } void _showAlert(String message) { final l10n = AppLocalizations.of(context); + debugPrint('[Dialog] RegisterPage._showAlert 即将弹出提示弹窗' + ' title=${l10n.alertTitle} message=$message'); showCupertinoDialog( context: context, builder: (_) => CupertinoAlertDialog( diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index e4d7193..d57a480 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -146,6 +146,18 @@ abstract class AppLocalizations { /// **'登录桐桐家庭关怀'** String get loginTitle; + /// 登录页欢迎标题 + /// + /// In zh, this message translates to: + /// **'欢迎登录'** + String get welcomeLoginTitle; + + /// 登录页欢迎副标题 + /// + /// In zh, this message translates to: + /// **'登录以继续使用,发现更多精彩'** + String get welcomeLoginSubtitle; + /// 注册页导航标题 /// /// In zh, this message translates to: @@ -170,12 +182,24 @@ abstract class AppLocalizations { /// **'手机号'** String get phoneHint; + /// 登录页手机号输入框占位 + /// + /// In zh, this message translates to: + /// **'请输入手机号'** + String get phoneHintDetailed; + /// 密码输入框占位 /// /// In zh, this message translates to: /// **'密码'** String get passwordHint; + /// 登录页密码输入框占位 + /// + /// In zh, this message translates to: + /// **'请输入登录密码'** + String get passwordHintDetailed; + /// 验证码输入框占位 /// /// In zh, this message translates to: @@ -212,6 +236,48 @@ abstract class AppLocalizations { /// **'已有账号?去登录'** String get hasAccount; + /// 忘记密码入口 + /// + /// In zh, this message translates to: + /// **'忘记密码'** + String get forgotPassword; + + /// 其他登录方式分隔文案 + /// + /// In zh, this message translates to: + /// **'其他登录方式'** + String get otherLoginMethods; + + /// 微信快捷登录按钮 + /// + /// In zh, this message translates to: + /// **'微信快捷登录'** + String get wechatLogin; + + /// 用户协议前缀 + /// + /// In zh, this message translates to: + /// **'登录即代表同意'** + String get userAgreementPrefix; + + /// 用户协议链接 + /// + /// In zh, this message translates to: + /// **'《用户协议》'** + String get userAgreement; + + /// 隐私政策链接 + /// + /// In zh, this message translates to: + /// **'《隐私政策》'** + String get privacyPolicy; + + /// 连接词 + /// + /// In zh, this message translates to: + /// **'和'** + String get andConnector; + /// 通用弹窗标题 /// /// In zh, this message translates to: @@ -229,6 +295,90 @@ abstract class AppLocalizations { /// In zh, this message translates to: /// **'发送中'** String get sending; + + /// 退出应用确认弹窗标题 + /// + /// In zh, this message translates to: + /// **'确认退出'** + String get exitConfirmTitle; + + /// 退出应用确认弹窗内容 + /// + /// In zh, this message translates to: + /// **'确定要退出应用吗?'** + String get exitConfirmContent; + + /// 通用取消按钮 + /// + /// In zh, this message translates to: + /// **'取消'** + String get cancel; + + /// 退出应用按钮 + /// + /// In zh, this message translates to: + /// **'退出'** + String get exitApp; + + /// 手机号为空时的校验提示 + /// + /// In zh, this message translates to: + /// **'请输入手机号'** + String get phoneEmpty; + + /// 手机号格式错误时的校验提示 + /// + /// In zh, this message translates to: + /// **'手机号格式不正确'** + String get phoneInvalid; + + /// 忘记密码页导航标题 + /// + /// In zh, this message translates to: + /// **'忘记密码'** + String get forgotPasswordTitle; + + /// 验证码为空时的校验提示 + /// + /// In zh, this message translates to: + /// **'请输入验证码'** + String get codeEmpty; + + /// 新密码为空时的校验提示 + /// + /// In zh, this message translates to: + /// **'请输入新密码'** + String get passwordEmpty; + + /// 忘记密码页新密码输入框占位 + /// + /// In zh, this message translates to: + /// **'请输入新密码'** + String get newPasswordHint; + + /// 重置密码按钮 + /// + /// In zh, this message translates to: + /// **'重置密码'** + String get resetPassword; + + /// 重置密码成功提示 + /// + /// In zh, this message translates to: + /// **'密码重置成功,请使用新密码登录'** + String get resetPasswordSuccess; + + /// 用户协议正文 + /// + /// In zh, this message translates to: + /// **'《用户协议》\n\n欢迎使用桐桐家庭关怀(以下简称\"本应用\")。在使用本应用前,请您仔细阅读以下条款。一旦您使用本应用,即表示您已阅读、理解并同意接受本协议的全部内容。\n\n一、服务说明\n本应用为家庭关怀类工具,提供设备连接、远程协助、消息沟通等功能。我们保留随时调整、暂停或终止部分服务的权利。\n\n二、账户注册与使用\n1. 您需提供真实有效的手机号完成注册,并对账户下的所有行为负责。\n2. 请妥善保管您的账户与密码,因密码泄露导致的损失由您自行承担。\n\n三、用户行为规范\n您不得利用本应用从事任何违反法律法规、侵犯他人权益或干扰服务正常运行的行为。\n\n四、知识产权\n本应用及相关内容的知识产权归开发者所有,未经许可不得复制、传播或用于商业用途。\n\n五、责任限制\n在法律允许的范围内,本应用对用户因使用或无法使用本服务而产生的间接损失不承担责任。\n\n六、协议变更\n我们可能适时修订本协议,修订后的协议将在应用内公告。继续使用本应用即视为接受修订内容。\n\n如您对本协议有任何疑问,请联系我们的客服。'** + String get userAgreementContent; + + /// 隐私政策正文 + /// + /// In zh, this message translates to: + /// **'《隐私政策》\n\n我们高度重视您的隐私保护。本政策说明我们如何收集、使用、存储和保护您的个人信息。\n\n一、信息收集\n我们可能收集以下信息:\n1. 您主动提供的信息,如手机号、昵称。\n2. 设备与日志信息,用于保障服务安全与故障排查。\n\n二、信息使用\n您的信息仅用于提供和改进本应用服务、保障账户安全及必要的通知。\n\n三、信息存储\n我们采取加密等合理措施保护您的信息,并在必要的期限内保存,超出期限将予以删除或匿名化处理。\n\n四、信息共享\n除法律法规要求或为完成您所请求的服务外,我们不会向第三方披露您的个人信息。\n\n五、您的权利\n您有权查询、更正或删除您的个人信息,并可撤回已授予的授权。\n\n六、政策变更\n本政策变更后将在应用内更新,请您定期查阅。\n\n如您对隐私保护有任何疑问,请联系我们的客服。'** + String get privacyPolicyContent; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index c5c3861..7206fc8 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -32,6 +32,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get loginTitle => 'Sign in to TongTong Family Care'; + @override + String get welcomeLoginTitle => 'Welcome'; + + @override + String get welcomeLoginSubtitle => 'Sign in to continue and discover more'; + @override String get registerTitle => 'Create Account'; @@ -44,9 +50,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get phoneHint => 'Phone'; + @override + String get phoneHintDetailed => 'Enter phone number'; + @override String get passwordHint => 'Password'; + @override + String get passwordHintDetailed => 'Enter password'; + @override String get codeHint => 'Code'; @@ -65,6 +77,27 @@ class AppLocalizationsEn extends AppLocalizations { @override String get hasAccount => 'Have an account? Sign in'; + @override + String get forgotPassword => 'Forgot password?'; + + @override + String get otherLoginMethods => 'Other ways to sign in'; + + @override + String get wechatLogin => 'WeChat Login'; + + @override + String get userAgreementPrefix => 'By signing in, you agree to '; + + @override + String get userAgreement => 'Terms of Service'; + + @override + String get privacyPolicy => 'Privacy Policy'; + + @override + String get andConnector => ' and '; + @override String get alertTitle => 'Notice'; @@ -73,4 +106,49 @@ class AppLocalizationsEn extends AppLocalizations { @override String get sending => 'Sending'; + + @override + String get exitConfirmTitle => 'Exit App'; + + @override + String get exitConfirmContent => 'Are you sure you want to exit the app?'; + + @override + String get cancel => 'Cancel'; + + @override + String get exitApp => 'Exit'; + + @override + String get phoneEmpty => 'Please enter your phone number'; + + @override + String get phoneInvalid => 'Invalid phone number'; + + @override + String get forgotPasswordTitle => 'Forgot Password'; + + @override + String get codeEmpty => 'Please enter the code'; + + @override + String get passwordEmpty => 'Please enter a new password'; + + @override + String get newPasswordHint => 'Enter new password'; + + @override + String get resetPassword => 'Reset Password'; + + @override + String get resetPasswordSuccess => + 'Password reset successfully, please sign in with the new password'; + + @override + String get userAgreementContent => + 'Terms of Service\n\nWelcome to TongTong Family Care (the \"App\"). Please read the following terms carefully before using the App. By using the App, you acknowledge that you have read, understood, and agreed to all provisions of this agreement.\n\n1. Service Description\nThe App is a family care tool providing device connection, remote assistance, and messaging. We reserve the right to adjust, suspend, or terminate parts of the service at any time.\n\n2. Account Registration and Use\nYou must register with a valid phone number and are responsible for all activities under your account. Keep your credentials secure.\n\n3. User Conduct\nYou may not use the App for any unlawful, infringing, or disruptive activity.\n\n4. Intellectual Property\nAll intellectual property related to the App belongs to the developer. No reproduction or commercial use is permitted without permission.\n\n5. Limitation of Liability\nTo the extent permitted by law, we are not liable for indirect damages arising from use or inability to use the service.\n\n6. Changes\nWe may revise this agreement; continued use constitutes acceptance of the revised terms.'; + + @override + String get privacyPolicyContent => + 'Privacy Policy\n\nWe take your privacy seriously. This policy explains how we collect, use, store, and protect your personal information.\n\n1. Information We Collect\nWe may collect information you provide (e.g., phone number, nickname) and device/log data for security and troubleshooting.\n\n2. How We Use Information\nYour information is used solely to provide and improve the service, secure your account, and send necessary notices.\n\n3. Storage\nWe apply reasonable safeguards such as encryption and retain information only as long as necessary, then delete or anonymize it.\n\n4. Sharing\nWe do not disclose personal information to third parties except as required by law or to fulfill a service you requested.\n\n5. Your Rights\nYou may access, correct, or delete your personal information and withdraw granted consents.\n\n6. Changes\nWe will update this policy within the App; please review it periodically.'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 65c58aa..e0bb655 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -32,6 +32,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get loginTitle => '登录桐桐家庭关怀'; + @override + String get welcomeLoginTitle => '欢迎登录'; + + @override + String get welcomeLoginSubtitle => '登录以继续使用,发现更多精彩'; + @override String get registerTitle => '注册账号'; @@ -44,9 +50,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get phoneHint => '手机号'; + @override + String get phoneHintDetailed => '请输入手机号'; + @override String get passwordHint => '密码'; + @override + String get passwordHintDetailed => '请输入登录密码'; + @override String get codeHint => '验证码'; @@ -65,6 +77,27 @@ class AppLocalizationsZh extends AppLocalizations { @override String get hasAccount => '已有账号?去登录'; + @override + String get forgotPassword => '忘记密码'; + + @override + String get otherLoginMethods => '其他登录方式'; + + @override + String get wechatLogin => '微信快捷登录'; + + @override + String get userAgreementPrefix => '登录即代表同意'; + + @override + String get userAgreement => '《用户协议》'; + + @override + String get privacyPolicy => '《隐私政策》'; + + @override + String get andConnector => '和'; + @override String get alertTitle => '提示'; @@ -73,4 +106,48 @@ class AppLocalizationsZh extends AppLocalizations { @override String get sending => '发送中'; + + @override + String get exitConfirmTitle => '确认退出'; + + @override + String get exitConfirmContent => '确定要退出应用吗?'; + + @override + String get cancel => '取消'; + + @override + String get exitApp => '退出'; + + @override + String get phoneEmpty => '请输入手机号'; + + @override + String get phoneInvalid => '手机号格式不正确'; + + @override + String get forgotPasswordTitle => '忘记密码'; + + @override + String get codeEmpty => '请输入验证码'; + + @override + String get passwordEmpty => '请输入新密码'; + + @override + String get newPasswordHint => '请输入新密码'; + + @override + String get resetPassword => '重置密码'; + + @override + String get resetPasswordSuccess => '密码重置成功,请使用新密码登录'; + + @override + String get userAgreementContent => + '《用户协议》\n\n欢迎使用桐桐家庭关怀(以下简称\"本应用\")。在使用本应用前,请您仔细阅读以下条款。一旦您使用本应用,即表示您已阅读、理解并同意接受本协议的全部内容。\n\n一、服务说明\n本应用为家庭关怀类工具,提供设备连接、远程协助、消息沟通等功能。我们保留随时调整、暂停或终止部分服务的权利。\n\n二、账户注册与使用\n1. 您需提供真实有效的手机号完成注册,并对账户下的所有行为负责。\n2. 请妥善保管您的账户与密码,因密码泄露导致的损失由您自行承担。\n\n三、用户行为规范\n您不得利用本应用从事任何违反法律法规、侵犯他人权益或干扰服务正常运行的行为。\n\n四、知识产权\n本应用及相关内容的知识产权归开发者所有,未经许可不得复制、传播或用于商业用途。\n\n五、责任限制\n在法律允许的范围内,本应用对用户因使用或无法使用本服务而产生的间接损失不承担责任。\n\n六、协议变更\n我们可能适时修订本协议,修订后的协议将在应用内公告。继续使用本应用即视为接受修订内容。\n\n如您对本协议有任何疑问,请联系我们的客服。'; + + @override + String get privacyPolicyContent => + '《隐私政策》\n\n我们高度重视您的隐私保护。本政策说明我们如何收集、使用、存储和保护您的个人信息。\n\n一、信息收集\n我们可能收集以下信息:\n1. 您主动提供的信息,如手机号、昵称。\n2. 设备与日志信息,用于保障服务安全与故障排查。\n\n二、信息使用\n您的信息仅用于提供和改进本应用服务、保障账户安全及必要的通知。\n\n三、信息存储\n我们采取加密等合理措施保护您的信息,并在必要的期限内保存,超出期限将予以删除或匿名化处理。\n\n四、信息共享\n除法律法规要求或为完成您所请求的服务外,我们不会向第三方披露您的个人信息。\n\n五、您的权利\n您有权查询、更正或删除您的个人信息,并可撤回已授予的授权。\n\n六、政策变更\n本政策变更后将在应用内更新,请您定期查阅。\n\n如您对隐私保护有任何疑问,请联系我们的客服。'; } diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 9a3c03d..78236af 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -8,18 +8,43 @@ "messagesContent": "Messages Content", "logout": "Sign Out", "loginTitle": "Sign in to TongTong Family Care", + "welcomeLoginTitle": "Welcome", + "welcomeLoginSubtitle": "Sign in to continue and discover more", "registerTitle": "Create Account", "passwordLogin": "Password", "smsLogin": "SMS Code", "phoneHint": "Phone", + "phoneHintDetailed": "Enter phone number", "passwordHint": "Password", + "passwordHintDetailed": "Enter password", "codeHint": "Code", "getCode": "Get Code", "login": "Sign In", "register": "Sign Up", "noAccount": "No account? Sign up", "hasAccount": "Have an account? Sign in", + "forgotPassword": "Forgot password?", + "otherLoginMethods": "Other ways to sign in", + "wechatLogin": "WeChat Login", + "userAgreementPrefix": "By signing in, you agree to ", + "userAgreement": "Terms of Service", + "privacyPolicy": "Privacy Policy", + "andConnector": " and ", "alertTitle": "Notice", "confirm": "OK", - "sending": "Sending" + "sending": "Sending", + "exitConfirmTitle": "Exit App", + "exitConfirmContent": "Are you sure you want to exit the app?", + "cancel": "Cancel", + "exitApp": "Exit", + "phoneEmpty": "Please enter your phone number", + "phoneInvalid": "Invalid phone number", + "forgotPasswordTitle": "Forgot Password", + "codeEmpty": "Please enter the code", + "passwordEmpty": "Please enter a new password", + "newPasswordHint": "Enter new password", + "resetPassword": "Reset Password", + "resetPasswordSuccess": "Password reset successfully, please sign in with the new password", + "userAgreementContent": "Terms of Service\n\nWelcome to TongTong Family Care (the \"App\"). Please read the following terms carefully before using the App. By using the App, you acknowledge that you have read, understood, and agreed to all provisions of this agreement.\n\n1. Service Description\nThe App is a family care tool providing device connection, remote assistance, and messaging. We reserve the right to adjust, suspend, or terminate parts of the service at any time.\n\n2. Account Registration and Use\nYou must register with a valid phone number and are responsible for all activities under your account. Keep your credentials secure.\n\n3. User Conduct\nYou may not use the App for any unlawful, infringing, or disruptive activity.\n\n4. Intellectual Property\nAll intellectual property related to the App belongs to the developer. No reproduction or commercial use is permitted without permission.\n\n5. Limitation of Liability\nTo the extent permitted by law, we are not liable for indirect damages arising from use or inability to use the service.\n\n6. Changes\nWe may revise this agreement; continued use constitutes acceptance of the revised terms.", + "privacyPolicyContent": "Privacy Policy\n\nWe take your privacy seriously. This policy explains how we collect, use, store, and protect your personal information.\n\n1. Information We Collect\nWe may collect information you provide (e.g., phone number, nickname) and device/log data for security and troubleshooting.\n\n2. How We Use Information\nYour information is used solely to provide and improve the service, secure your account, and send necessary notices.\n\n3. Storage\nWe apply reasonable safeguards such as encryption and retain information only as long as necessary, then delete or anonymize it.\n\n4. Sharing\nWe do not disclose personal information to third parties except as required by law or to fulfill a service you requested.\n\n5. Your Rights\nYou may access, correct, or delete your personal information and withdraw granted consents.\n\n6. Changes\nWe will update this policy within the App; please review it periodically." } diff --git a/lib/l10n/intl_zh.arb b/lib/l10n/intl_zh.arb index 165c8f9..82d1871 100644 --- a/lib/l10n/intl_zh.arb +++ b/lib/l10n/intl_zh.arb @@ -32,6 +32,14 @@ "@loginTitle": { "description": "登录页导航标题" }, + "welcomeLoginTitle": "欢迎登录", + "@welcomeLoginTitle": { + "description": "登录页欢迎标题" + }, + "welcomeLoginSubtitle": "登录以继续使用,发现更多精彩", + "@welcomeLoginSubtitle": { + "description": "登录页欢迎副标题" + }, "registerTitle": "注册账号", "@registerTitle": { "description": "注册页导航标题" @@ -48,10 +56,18 @@ "@phoneHint": { "description": "手机号输入框占位" }, + "phoneHintDetailed": "请输入手机号", + "@phoneHintDetailed": { + "description": "登录页手机号输入框占位" + }, "passwordHint": "密码", "@passwordHint": { "description": "密码输入框占位" }, + "passwordHintDetailed": "请输入登录密码", + "@passwordHintDetailed": { + "description": "登录页密码输入框占位" + }, "codeHint": "验证码", "@codeHint": { "description": "验证码输入框占位" @@ -76,6 +92,34 @@ "@hasAccount": { "description": "去登录入口" }, + "forgotPassword": "忘记密码", + "@forgotPassword": { + "description": "忘记密码入口" + }, + "otherLoginMethods": "其他登录方式", + "@otherLoginMethods": { + "description": "其他登录方式分隔文案" + }, + "wechatLogin": "微信快捷登录", + "@wechatLogin": { + "description": "微信快捷登录按钮" + }, + "userAgreementPrefix": "登录即代表同意", + "@userAgreementPrefix": { + "description": "用户协议前缀" + }, + "userAgreement": "《用户协议》", + "@userAgreement": { + "description": "用户协议链接" + }, + "privacyPolicy": "《隐私政策》", + "@privacyPolicy": { + "description": "隐私政策链接" + }, + "andConnector": "和", + "@andConnector": { + "description": "连接词" + }, "alertTitle": "提示", "@alertTitle": { "description": "通用弹窗标题" @@ -87,5 +131,61 @@ "sending": "发送中", "@sending": { "description": "验证码发送中" + }, + "exitConfirmTitle": "确认退出", + "@exitConfirmTitle": { + "description": "退出应用确认弹窗标题" + }, + "exitConfirmContent": "确定要退出应用吗?", + "@exitConfirmContent": { + "description": "退出应用确认弹窗内容" + }, + "cancel": "取消", + "@cancel": { + "description": "通用取消按钮" + }, + "exitApp": "退出", + "@exitApp": { + "description": "退出应用按钮" + }, + "phoneEmpty": "请输入手机号", + "@phoneEmpty": { + "description": "手机号为空时的校验提示" + }, + "phoneInvalid": "手机号格式不正确", + "@phoneInvalid": { + "description": "手机号格式错误时的校验提示" + }, + "forgotPasswordTitle": "忘记密码", + "@forgotPasswordTitle": { + "description": "忘记密码页导航标题" + }, + "codeEmpty": "请输入验证码", + "@codeEmpty": { + "description": "验证码为空时的校验提示" + }, + "passwordEmpty": "请输入新密码", + "@passwordEmpty": { + "description": "新密码为空时的校验提示" + }, + "newPasswordHint": "请输入新密码", + "@newPasswordHint": { + "description": "忘记密码页新密码输入框占位" + }, + "resetPassword": "重置密码", + "@resetPassword": { + "description": "重置密码按钮" + }, + "resetPasswordSuccess": "密码重置成功,请使用新密码登录", + "@resetPasswordSuccess": { + "description": "重置密码成功提示" + }, + "userAgreementContent": "《用户协议》\n\n欢迎使用桐桐家庭关怀(以下简称\"本应用\")。在使用本应用前,请您仔细阅读以下条款。一旦您使用本应用,即表示您已阅读、理解并同意接受本协议的全部内容。\n\n一、服务说明\n本应用为家庭关怀类工具,提供设备连接、远程协助、消息沟通等功能。我们保留随时调整、暂停或终止部分服务的权利。\n\n二、账户注册与使用\n1. 您需提供真实有效的手机号完成注册,并对账户下的所有行为负责。\n2. 请妥善保管您的账户与密码,因密码泄露导致的损失由您自行承担。\n\n三、用户行为规范\n您不得利用本应用从事任何违反法律法规、侵犯他人权益或干扰服务正常运行的行为。\n\n四、知识产权\n本应用及相关内容的知识产权归开发者所有,未经许可不得复制、传播或用于商业用途。\n\n五、责任限制\n在法律允许的范围内,本应用对用户因使用或无法使用本服务而产生的间接损失不承担责任。\n\n六、协议变更\n我们可能适时修订本协议,修订后的协议将在应用内公告。继续使用本应用即视为接受修订内容。\n\n如您对本协议有任何疑问,请联系我们的客服。", + "@userAgreementContent": { + "description": "用户协议正文" + }, + "privacyPolicyContent": "《隐私政策》\n\n我们高度重视您的隐私保护。本政策说明我们如何收集、使用、存储和保护您的个人信息。\n\n一、信息收集\n我们可能收集以下信息:\n1. 您主动提供的信息,如手机号、昵称。\n2. 设备与日志信息,用于保障服务安全与故障排查。\n\n二、信息使用\n您的信息仅用于提供和改进本应用服务、保障账户安全及必要的通知。\n\n三、信息存储\n我们采取加密等合理措施保护您的信息,并在必要的期限内保存,超出期限将予以删除或匿名化处理。\n\n四、信息共享\n除法律法规要求或为完成您所请求的服务外,我们不会向第三方披露您的个人信息。\n\n五、您的权利\n您有权查询、更正或删除您的个人信息,并可撤回已授予的授权。\n\n六、政策变更\n本政策变更后将在应用内更新,请您定期查阅。\n\n如您对隐私保护有任何疑问,请联系我们的客服。", + "@privacyPolicyContent": { + "description": "隐私政策正文" } }