feat(auth): 对接后端 open 模块认证接口并完善登录页

- 更新接口路径、字段和短信验证码场景,新增忘记密码/重置密码流程
- 增加 401 自动刷新 token 与统一错误处理
- 重构登录页 UI,支持退出确认、协议与法律文档入口
This commit is contained in:
2026-08-17 09:30:25 +08:00
parent 1396487af9
commit 8478a99eba
20 changed files with 1963 additions and 194 deletions

View File

@@ -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 = '家庭关怀';
}

View File

@@ -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: [

View File

@@ -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,
}

View File

@@ -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();
}

View File

@@ -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<dynamic> response) {
final body = response.data as Map<String, dynamic>?;
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<dynamic> get(
String path, {
Map<String, dynamic>? queryParameters,
}) =>
_request(() => instance.get<dynamic>(path, queryParameters: queryParameters));
/// 统一 POST 请求封装。
/// 统一 POST 请求,返回解析后的 data。
static Future<dynamic> post(
String path, {
Map<String, dynamic>? data,
}) async {
Map<String, dynamic>? queryParameters,
}) =>
_request(() => instance.post<dynamic>(
path,
data: data,
queryParameters: queryParameters,
));
/// 统一 PUT 请求,返回解析后的 data。
static Future<dynamic> put(
String path, {
Map<String, dynamic>? data,
}) =>
_request(() => instance.put<dynamic>(path, data: data));
/// 统一 DELETE 请求,返回解析后的 data。
static Future<dynamic> delete(String path) =>
_request(() => instance.delete<dynamic>(path));
/// 统一请求入口:发起请求,错误由拦截器链转换为 [ApiException],此处再包成
/// [AppError] 以便调用方按需分类处理。Repository 直接捕获 [ApiException] 即可。
static Future<dynamic> _request(
Future<Response<dynamic>> Function() call,
) async {
try {
final response = await instance.post<dynamic>(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<dynamic> response) {
final body = response.data as Map<String, dynamic>?;
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<dynamic> response, ResponseInterceptorHandler handler) {
// 解析在 _request 中统一进行,此处仅透传。
super.onResponse(response, handler);
}
}
/// 错误拦截器:将 [DioException] 统一转换为 [ApiException],并处理 401 刷新。
class _ErrorInterceptor extends Interceptor {
/// 标记当前是否正在刷新,避免并发 401 引发多次刷新。
static bool _isRefreshing = false;
@override
Future<void> 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<bool> _refreshToken() async {
final refreshToken = TokenStorage.refreshToken;
if (refreshToken == null || refreshToken.isEmpty) return false;
try {
final response = await DioClient.instance.post<dynamic>(
'/refresh-token',
queryParameters: {'refreshToken': refreshToken},
);
final data = response as Map<String, dynamic>?;
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<Response<dynamic>> _retry(RequestOptions options) {
return DioClient.instance.request<dynamic>(
options.path,
data: options.data,
queryParameters: options.queryParameters,
options: Options(
method: options.method,
headers: {
...options.headers,
'Authorization': 'Bearer ${TokenStorage.accessToken}',
},
),
);
}
}

View File

@@ -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;
}

View File

@@ -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<String, dynamic>);
@@ -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<String, dynamic>);
@@ -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<String, dynamic>);
@@ -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<void> 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<String, dynamic> && resp != null) {
throw const ApiException(code: -1, message: '短信接口返回异常');
}
}
}

View File

@@ -31,8 +31,10 @@ class LoginResult {
}
factory LoginResult.fromJson(Map<String, dynamic> 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,
}

View File

@@ -31,4 +31,11 @@ abstract class AuthRepository {
required String phone,
required SmsScene scene,
});
/// 重置密码(忘记密码流程:校验验证码后设置新密码)。
Future<void> resetPassword({
required String phone,
required String code,
required String password,
});
}

View File

@@ -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<AuthState> {
}
/// 发送短信验证码并启动 60s 倒计时。
///
/// 发送前先做本地校验:手机号为空或格式不正确时直接提示,不发起请求、
/// 不启动倒计时,避免出现「空号也能点但弹报错」的体验问题。
Future<void> 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<bool> 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<AuthState> {
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<AuthState> {
}
String _messageOf(Object e) {
if (e is Exception) {
return e.toString().replaceFirst('Exception: ', '');
}
return '操作失败,请稍后重试';
return userMessageOf(e);
}
}

View File

@@ -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<ForgotPasswordPage> createState() => _ForgotPasswordPageState();
}
class _ForgotPasswordPageState extends ConsumerState<ForgotPasswordPage> {
final _phoneController = TextEditingController();
final _codeController = TextEditingController();
final _passwordController = TextEditingController();
@override
void dispose() {
_phoneController.dispose();
_codeController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _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<String?>(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<void>(
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<void> 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),
),
),
],
);
}
}

View File

@@ -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<String, dynamic>?;
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,
),
),
),
),
),
);
}
}

View File

@@ -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<LoginPage> {
/// true: 验证码登录;false: 密码登录。
bool _isSmsLogin = false;
/// 密码是否明文显示。
bool _isPasswordVisible = false;
@override
void dispose() {
_phoneController.dispose();
@@ -32,6 +37,37 @@ class _LoginPageState extends ConsumerState<LoginPage> {
super.dispose();
}
/// 拦截系统返回手势:登录页为初始路由,返回时弹确认退出弹窗。
Future<bool> _onWillPop() async {
final shouldExit = await _showExitConfirm();
return shouldExit ?? false;
}
/// 弹出「确认退出应用」对话框,返回 true 表示用户确认退出。
Future<bool?> _showExitConfirm() {
final l10n = AppLocalizations.of(context);
debugPrint('[Dialog] LoginPage._showExitConfirm 即将弹出退出确认弹窗'
' title=${l10n.exitConfirmTitle} content=${l10n.exitConfirmContent}');
return showCupertinoDialog<bool>(
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<void> _submit() async {
final phone = _phoneController.text.trim();
final ok = _isSmsLogin
@@ -46,108 +82,10 @@ class _LoginPageState extends ConsumerState<LoginPage> {
if (ok && mounted) context.go('/home');
}
@override
Widget build(BuildContext context) {
ref.listen<String?>(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<bool>(
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<void>(
context: context,
builder: (_) => CupertinoAlertDialog(
@@ -162,9 +100,470 @@ class _LoginPageState extends ConsumerState<LoginPage> {
),
);
}
void _showPlaceholderAlert(String message) {
_showAlert(message);
}
@override
Widget build(BuildContext context) {
ref.listen<String?>(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<bool> 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<bool> 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),
),
),
],

View File

@@ -51,12 +51,22 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
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<RegisterPage> {
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<RegisterPage> {
),
),
),
),
);
}
void _showAlert(String message) {
final l10n = AppLocalizations.of(context);
debugPrint('[Dialog] RegisterPage._showAlert 即将弹出提示弹窗'
' title=${l10n.alertTitle} message=$message');
showCupertinoDialog<void>(
context: context,
builder: (_) => CupertinoAlertDialog(

View File

@@ -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

View File

@@ -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.';
}

View File

@@ -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如您对隐私保护有任何疑问,请联系我们的客服。';
}

View File

@@ -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."
}

View File

@@ -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": "隐私政策正文"
}
}