feat(android): 支持沉浸式状态栏、地图定位与自定义签名

- 配置 edge-to-edge 沉浸式状态栏与刘海屏适配
- 新增百度地图权限、AK 配置及定位权限说明
- 接入 MethodChannel 实现返回键退后台热启动优化
- 配置自定义签名并更新构建逻辑
- 完善 token 刷新与鉴权失效统一处理
- 新增地图页与截图页路由
- 修复返回键拦截导致的 PopScope 失效问题
This commit is contained in:
TongTongStudio
2026-08-19 03:43:12 +08:00
parent 41d815336a
commit 06cdc77172
51 changed files with 3988 additions and 634 deletions

View File

@@ -0,0 +1,95 @@
import 'package:ttstd_family_care/core/network/dio_client.dart';
import 'package:ttstd_family_care/features/auth/domain/auth_models.dart';
/// 客户端(家属端)认证接口访问层。
///
/// 对接后端 C 端open 模块)认证接口。`AppConstants.kBaseUrl` 仅包含 `/api/`
/// 前缀,各端点在此显式追加 `v1/open/`(即 `/api/v1/open/**`。open 模块账号
/// 落地 `app_user`,与后台管理端(`sys_user`)物理分表。
///
/// [DioClient.get] / [DioClient.post] 已统一解析为后台返回体中的 `data` 字段;
/// 业务失败时抛出 [ApiException]由上层Repository按需处理。
class AuthApi {
const AuthApi();
/// 账号密码登录。
Future<LoginResult> loginByPassword({
required String username,
required String password,
}) async {
final data = await DioClient.post(
'v1/open/login',
data: {
'username': username,
'password': password,
},
);
return LoginResult.fromJson(data as Map<String, dynamic>);
}
/// 短信验证码登录。
Future<LoginResult> loginBySms({
required String phone,
required String code,
}) async {
final data = await DioClient.post(
'v1/open/auth/login/mobile',
data: {
'mobile': phone,
'code': code,
},
);
return LoginResult.fromJson(data as Map<String, dynamic>);
}
/// 短信注册。
Future<LoginResult> register({
required String phone,
required String code,
required String password,
}) async {
final data = await DioClient.post(
'v1/open/register/mobile',
data: {
'mobile': phone,
'code': code,
'password': password,
},
);
return LoginResult.fromJson(data as Map<String, dynamic>);
}
/// 发送短信验证码。
///
/// 后端按场景拆分验证码发送接口query 参数 mobile
Future<void> sendSmsCode({
required String phone,
required SmsScene scene,
}) async {
final path = switch (scene) {
SmsScene.login => 'v1/open/auth/login/sms/code',
SmsScene.register => 'v1/open/auth/register/sms/code',
SmsScene.resetPassword => 'v1/open/reset-password/sms/code',
};
await DioClient.post(
path,
queryParameters: {'mobile': phone},
);
}
/// 重置密码(忘记密码流程:校验验证码后设置新密码)。
Future<void> resetPassword({
required String phone,
required String code,
required String password,
}) async {
await DioClient.post(
'v1/open/auth/reset-password',
data: {
'mobile': phone,
'code': code,
'password': password,
},
);
}
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../domain/auth_repository.dart';
import 'auth_api.dart';
import 'auth_repository_impl.dart';
/// 认证仓库 Provider。
@@ -8,5 +9,6 @@ import 'auth_repository_impl.dart';
/// 会话级依赖,通过 ref.keepAlive() 避免页面切换时被销毁重建。
final authRepositoryProvider = Provider<AuthRepository>((ref) {
ref.keepAlive();
return const AuthRepositoryImpl();
const api = AuthApi();
return AuthRepositoryImpl(api);
});

View File

@@ -1,15 +1,16 @@
import '../../../core/network/dio_client.dart';
import '../../../core/storage/token_storage.dart';
import '../domain/auth_models.dart';
import '../domain/auth_repository.dart';
import 'auth_api.dart';
/// 认证仓库实现(数据层)
/// [AuthRepository] 的默认实现,委托给 [AuthApi]
///
/// 对接后端 C 端open 模块)认证接口,全部端点以 `/api/v1/open/auth/**` 为前缀
/// `AppConstants.kBaseUrl` 已包含该前缀。open 模块账号落地 `app_user`
/// 与后台管理端(`sys_user`)物理分表,令牌经 `TokenManager.generateToken` 本地签发。
/// 负责网络层([DioClient])解耦之外的业务编排:登录/注册成功后将令牌落库
/// [TokenStorage]UI 仅依赖领域层抽象。
class AuthRepositoryImpl implements AuthRepository {
const AuthRepositoryImpl();
const AuthRepositoryImpl(this._api);
final AuthApi _api;
@override
Future<LoginResult> loginByPassword({
@@ -17,14 +18,10 @@ class AuthRepositoryImpl implements AuthRepository {
required String password,
required String deviceId,
}) async {
final data = await DioClient.post(
'/login',
data: {
'username': phone,
'password': password,
},
final result = await _api.loginByPassword(
username: phone,
password: password,
);
final result = LoginResult.fromJson(data as Map<String, dynamic>);
TokenStorage.saveTokens(
token: result.token,
refreshToken: result.refreshToken,
@@ -38,14 +35,10 @@ class AuthRepositoryImpl implements AuthRepository {
required String code,
required String deviceId,
}) async {
final data = await DioClient.post(
'auth/login/mobile',
data: {
'mobile': phone,
'code': code,
},
final result = await _api.loginBySms(
phone: phone,
code: code,
);
final result = LoginResult.fromJson(data as Map<String, dynamic>);
TokenStorage.saveTokens(
token: result.token,
refreshToken: result.refreshToken,
@@ -60,15 +53,11 @@ class AuthRepositoryImpl implements AuthRepository {
required String password,
required String deviceId,
}) async {
final data = await DioClient.post(
'/register/mobile',
data: {
'mobile': phone,
'code': code,
'password': password,
},
final result = await _api.register(
phone: phone,
code: code,
password: password,
);
final result = LoginResult.fromJson(data as Map<String, dynamic>);
TokenStorage.saveTokens(
token: result.token,
refreshToken: result.refreshToken,
@@ -80,32 +69,14 @@ class AuthRepositoryImpl implements AuthRepository {
Future<void> sendSmsCode({
required String phone,
required SmsScene scene,
}) async {
// 后端按场景拆分验证码发送接口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},
);
}
}) =>
_api.sendSmsCode(phone: phone, scene: scene);
@override
Future<void> resetPassword({
required String phone,
required String code,
required String password,
}) async {
await DioClient.post(
'/reset-password',
data: {
'mobile': phone,
'code': code,
'password': password,
},
);
}
}) =>
_api.resetPassword(phone: phone, code: code, password: password);
}

View File

@@ -77,9 +77,15 @@ class _ForgotPasswordPageState extends ConsumerState<ForgotPasswordPage> {
final l10n = AppLocalizations.of(context);
return PopScope(
canPop: true,
// 拦截系统返回键:优先出栈;若无法出栈则跳回登录页。
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) context.pop();
if (didPop) return;
if (context.canPop()) {
context.pop();
} else {
context.go('/login');
}
},
child: CupertinoPageScaffold(
backgroundColor: CupertinoColors.systemGroupedBackground,
@@ -168,13 +174,13 @@ class _ForgotPasswordPageState extends ConsumerState<ForgotPasswordPage> {
' title=${l10n.alertTitle} message=$message');
showCupertinoDialog<void>(
context: context,
builder: (_) => CupertinoAlertDialog(
builder: (dialogContext) => CupertinoAlertDialog(
title: Text(l10n.alertTitle),
content: Text(message),
actions: [
CupertinoDialogAction(
child: Text(l10n.confirm),
onPressed: () => Navigator.of(context).pop(),
onPressed: () => Navigator.of(dialogContext).pop(),
),
],
),

View File

@@ -3,6 +3,7 @@ import 'package:flutter/gestures.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/utils/system_ui_util.dart';
import '../../../app/theme/app_theme.dart';
import '../../../l10n/app_localizations.dart';
import '../domain/auth_models.dart';
@@ -50,18 +51,18 @@ class _LoginPageState extends ConsumerState<LoginPage> {
' title=${l10n.exitConfirmTitle} content=${l10n.exitConfirmContent}');
return showCupertinoDialog<bool>(
context: context,
builder: (_) => CupertinoAlertDialog(
builder: (dialogContext) => CupertinoAlertDialog(
title: Text(l10n.exitConfirmTitle),
content: Text(l10n.exitConfirmContent),
actions: [
CupertinoDialogAction(
child: Text(l10n.cancel),
onPressed: () => Navigator.of(context).pop(false),
onPressed: () => Navigator.of(dialogContext).pop(false),
),
CupertinoDialogAction(
isDestructiveAction: true,
child: Text(l10n.exitApp),
onPressed: () => Navigator.of(context).pop(true),
onPressed: () => Navigator.of(dialogContext).pop(true),
),
],
),
@@ -88,13 +89,13 @@ class _LoginPageState extends ConsumerState<LoginPage> {
' title=${l10n.alertTitle} message=$message');
showCupertinoDialog<void>(
context: context,
builder: (_) => CupertinoAlertDialog(
builder: (dialogContext) => CupertinoAlertDialog(
title: Text(l10n.alertTitle),
content: Text(message),
actions: [
CupertinoDialogAction(
child: Text(l10n.confirm),
onPressed: () => Navigator.of(context).pop(),
onPressed: () => Navigator.of(dialogContext).pop(),
),
],
),
@@ -122,7 +123,10 @@ class _LoginPageState extends ConsumerState<LoginPage> {
canPop: false,
onPopInvokedWithResult: (didPop, _) async {
if (didPop) return;
await _onWillPop();
final shouldExit = await _showExitConfirm();
if (shouldExit == true) {
await SystemUiUtil.moveToBack();
}
},
child: CupertinoPageScaffold(
backgroundColor: CupertinoColors.white,

View File

@@ -52,9 +52,15 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
final l10n = AppLocalizations.of(context);
return PopScope(
canPop: true,
// 拦截系统返回键:优先出栈;若无法出栈则跳回登录页。
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) context.pop();
if (didPop) return;
if (context.canPop()) {
context.pop();
} else {
context.go('/login');
}
},
child: CupertinoPageScaffold(
backgroundColor: CupertinoColors.systemGroupedBackground,
@@ -144,13 +150,13 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
' title=${l10n.alertTitle} message=$message');
showCupertinoDialog<void>(
context: context,
builder: (_) => CupertinoAlertDialog(
builder: (dialogContext) => CupertinoAlertDialog(
title: Text(l10n.alertTitle),
content: Text(message),
actions: [
CupertinoDialogAction(
child: Text(l10n.confirm),
onPressed: () => Navigator.of(context).pop(),
onPressed: () => Navigator.of(dialogContext).pop(),
),
],
),