feat: 新增设备绑定、扫码与联系人管理功能
This commit is contained in:
@@ -22,6 +22,13 @@
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<!-- 扫码绑定设备:相机实时扫码 -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<!-- Android 13+ 从相册选取二维码图片 -->
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<!-- 相机为可选硬件,无摄像头设备仍可从相册识别二维码 -->
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
|
||||
|
||||
|
||||
<application
|
||||
|
||||
@@ -77,6 +77,10 @@
|
||||
<string>用于在地图上展示设备位置</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>用于在地图上展示设备位置</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>用于扫描设备二维码完成绑定</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>用于从相册选取二维码图片完成设备绑定</string>
|
||||
<key>io.flutter.embedded_views_preview</key>
|
||||
<true/>
|
||||
</dict>
|
||||
|
||||
@@ -16,9 +16,20 @@ import '../../features/profile/presentation/profile_tab.dart';
|
||||
import '../../features/album/presentation/album_page.dart';
|
||||
import '../../features/alarm/presentation/alarm_page.dart';
|
||||
import '../../features/contacts/presentation/contacts_page.dart';
|
||||
import '../../features/contacts/presentation/contact_detail_page.dart';
|
||||
import '../../features/contacts/presentation/contact_edit_page.dart';
|
||||
import '../../features/contacts/domain/contact_models.dart';
|
||||
import '../../features/apps/presentation/apps_page.dart';
|
||||
import '../../features/account/presentation/change_mobile_page.dart';
|
||||
import '../../features/account/presentation/change_password_page.dart';
|
||||
import '../../features/account/presentation/login_devices_page.dart';
|
||||
import '../../features/account/presentation/security_page.dart';
|
||||
import '../../features/account/presentation/wechat_binding_page.dart';
|
||||
import '../../features/device/data/device_providers.dart';
|
||||
import '../../features/device/domain/device_models.dart';
|
||||
import '../../features/device/presentation/device_bind_page.dart';
|
||||
import '../../features/device/presentation/device_manage_page.dart';
|
||||
import '../../features/device/presentation/device_scan_page.dart';
|
||||
|
||||
/// 应用路由定义(GoRouter)。
|
||||
///
|
||||
@@ -26,6 +37,8 @@ import '../../features/device/domain/device_models.dart';
|
||||
/// - /login /register 认证流程
|
||||
/// - /home Shell 路由,承载底部 Tab(首页 / 管理 / 我的)
|
||||
/// - /album /alarm /contacts /apps 管理页横版菜单进入的子页面(无底部 Tab)
|
||||
/// - /device/manage /device/bind /device/scan 设备管理、绑定与扫码
|
||||
/// - /security/* 账号与安全(登录设备 / 修改密码 / 更换手机 / 微信绑定)
|
||||
GoRouter buildAppRouter() {
|
||||
return GoRouter(
|
||||
initialLocation: '/splash',
|
||||
@@ -63,10 +76,60 @@ GoRouter buildAppRouter() {
|
||||
path: '/contacts',
|
||||
builder: (context, state) => const ContactsPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/contacts/detail',
|
||||
builder: (context, state) => ContactDetailPage(
|
||||
contact: state.extra is Contact
|
||||
? state.extra as Contact
|
||||
: const Contact(id: 0, name: '', phoneNumber: ''),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/contacts/edit',
|
||||
builder: (context, state) => ContactEditPage(
|
||||
contact: state.extra is Contact ? state.extra as Contact : null,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/apps',
|
||||
builder: (context, state) => const AppsPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/device/manage',
|
||||
builder: (context, state) => const DeviceManagePage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/device/bind',
|
||||
builder: (context, state) => DeviceBindPage(
|
||||
initialSn: state.extra is String ? state.extra as String : null,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/device/scan',
|
||||
builder: (context, state) => const DeviceScanPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/security',
|
||||
builder: (context, state) => const SecurityPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/security/login-devices',
|
||||
builder: (context, state) => const LoginDevicesPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/security/password',
|
||||
builder: (context, state) => const ChangePasswordPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/security/mobile',
|
||||
builder: (context, state) => ChangeMobilePage(
|
||||
currentMobile: state.extra is String ? state.extra as String : null,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/security/wechat',
|
||||
builder: (context, state) => const WechatBindingPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/map',
|
||||
builder: (context, state) => _MapRoute(),
|
||||
@@ -137,6 +200,6 @@ class _ScreenshotsRoute extends ConsumerWidget {
|
||||
// 越界保护:初始索引落在列表内,否则回退到 0。
|
||||
final index =
|
||||
(list.isNotEmpty && initialIndex < list.length) ? initialIndex : 0;
|
||||
return ScreenshotPage(screenshots: list, initialIndex: index);
|
||||
return ScreenshotPage(screenshots: list, sn: sn ?? '', initialIndex: index);
|
||||
}
|
||||
}
|
||||
|
||||
322
lib/core/widgets/settings_ui.dart
Normal file
322
lib/core/widgets/settings_ui.dart
Normal file
@@ -0,0 +1,322 @@
|
||||
import 'package:cupertino_ui/cupertino_ui.dart';
|
||||
|
||||
import 'feature_ui.dart';
|
||||
|
||||
/// 设置类页面通用组件(设备管理、账号与安全等子页共用)。
|
||||
///
|
||||
/// 统一「圆角图标块 + 标题/副标题 + 尾部控件」的行样式,以及带标签的输入框、
|
||||
/// 主按钮与次按钮,避免各页面重复书写冗长的内联样式。
|
||||
|
||||
/// 可点击的设置列表行。
|
||||
///
|
||||
/// [emoji] + [color] 组成左侧圆角图标块;[trailing] 为空时展示默认的 `›` 箭头。
|
||||
class SettingsRow extends StatelessWidget {
|
||||
const SettingsRow({
|
||||
required this.emoji,
|
||||
required this.color,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.value,
|
||||
this.trailing,
|
||||
this.onTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String emoji;
|
||||
final Color color;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
|
||||
/// 右侧只读文案(如当前手机号、绑定状态)。
|
||||
final String? value;
|
||||
|
||||
/// 自定义尾部控件,优先级高于 [value] 与默认箭头。
|
||||
final Widget? trailing;
|
||||
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final row = Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
),
|
||||
child: Center(child: Text(emoji, style: const TextStyle(fontSize: 17))),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 14.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.ink,
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.sub),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null)
|
||||
trailing!
|
||||
else ...[
|
||||
if (value != null)
|
||||
Text(
|
||||
value!,
|
||||
style: const TextStyle(fontSize: 13, color: AppColors.sub),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const Text('›', style: TextStyle(fontSize: 16, color: Color(0xFFCBD2DC))),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
if (onTap == null) return row;
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: row,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 带标签的输入框(标签在上、输入框在下的表单样式)。
|
||||
class LabeledField extends StatelessWidget {
|
||||
const LabeledField({
|
||||
required this.label,
|
||||
required this.controller,
|
||||
this.placeholder,
|
||||
this.obscureText = false,
|
||||
this.keyboardType,
|
||||
this.maxLength,
|
||||
this.enabled = true,
|
||||
this.suffix,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final TextEditingController controller;
|
||||
final String? placeholder;
|
||||
final bool obscureText;
|
||||
final TextInputType? keyboardType;
|
||||
final int? maxLength;
|
||||
final bool enabled;
|
||||
|
||||
/// 输入框右侧控件(如「获取验证码」按钮)。
|
||||
final Widget? suffix;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.sub,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CupertinoTextField(
|
||||
controller: controller,
|
||||
placeholder: placeholder,
|
||||
obscureText: obscureText,
|
||||
keyboardType: keyboardType,
|
||||
maxLength: maxLength,
|
||||
enabled: enabled,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
style: const TextStyle(fontSize: 15, color: AppColors.ink),
|
||||
placeholderStyle: const TextStyle(fontSize: 14, color: AppColors.sub),
|
||||
decoration: BoxDecoration(
|
||||
color: enabled ? CupertinoColors.white : AppColors.track,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFFE6EAF0)),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (suffix != null) ...[
|
||||
const SizedBox(width: 10),
|
||||
suffix!,
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 实心主按钮(品牌绿),[loading] 时展示指示器并禁用点击。
|
||||
class PrimaryButton extends StatelessWidget {
|
||||
const PrimaryButton({
|
||||
required this.text,
|
||||
this.onPressed,
|
||||
this.loading = false,
|
||||
this.color = AppColors.green,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String text;
|
||||
final VoidCallback? onPressed;
|
||||
final bool loading;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final disabled = loading || onPressed == null;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: GestureDetector(
|
||||
onTap: disabled ? null : onPressed,
|
||||
child: Container(
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: disabled ? color.withValues(alpha: 0.5) : color,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Center(
|
||||
child: loading
|
||||
? const CupertinoActivityIndicator(color: CupertinoColors.white)
|
||||
: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: CupertinoColors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 描边次按钮(用于「扫码绑定」「从相册选取」等辅助操作)。
|
||||
class SecondaryButton extends StatelessWidget {
|
||||
const SecondaryButton({
|
||||
required this.text,
|
||||
this.onPressed,
|
||||
this.color = AppColors.green,
|
||||
this.icon,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String text;
|
||||
final VoidCallback? onPressed;
|
||||
final Color color;
|
||||
final IconData? icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: GestureDetector(
|
||||
onTap: onPressed,
|
||||
child: Container(
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: CupertinoColors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: color, width: 1.2),
|
||||
),
|
||||
child: Center(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 18, color: color),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 15.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 统一的提示弹窗与确认弹窗工具。
|
||||
class AppDialogs {
|
||||
const AppDialogs._();
|
||||
|
||||
/// 单按钮提示弹窗。
|
||||
static Future<void> alert(
|
||||
BuildContext context, {
|
||||
String? title,
|
||||
required String message,
|
||||
required String confirmText,
|
||||
}) =>
|
||||
showCupertinoDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => CupertinoAlertDialog(
|
||||
title: title == null ? null : Text(title),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
CupertinoDialogAction(
|
||||
isDefaultAction: true,
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: Text(confirmText),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
/// 二次确认弹窗,返回用户是否确认。
|
||||
static Future<bool> confirm(
|
||||
BuildContext context, {
|
||||
String? title,
|
||||
required String message,
|
||||
required String confirmText,
|
||||
required String cancelText,
|
||||
bool destructive = true,
|
||||
}) async {
|
||||
final result = await showCupertinoDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => CupertinoAlertDialog(
|
||||
title: title == null ? null : Text(title),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
CupertinoDialogAction(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: Text(cancelText),
|
||||
),
|
||||
CupertinoDialogAction(
|
||||
isDestructiveAction: destructive,
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: Text(confirmText),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
}
|
||||
113
lib/features/account/data/account_api.dart
Normal file
113
lib/features/account/data/account_api.dart
Normal file
@@ -0,0 +1,113 @@
|
||||
import 'package:cupertino_ui/cupertino_ui.dart';
|
||||
import 'package:ttstd_family_care/core/network/api_exception.dart';
|
||||
import 'package:ttstd_family_care/core/network/dio_client.dart';
|
||||
import 'package:ttstd_family_care/features/account/domain/account_models.dart';
|
||||
|
||||
/// 账号与安全接口访问层。
|
||||
///
|
||||
/// 后端接口前缀为 `/api/v1/open/account`(与设备接口所在的 `/api/v1/client` 不同)。
|
||||
/// [DioClient] 已统一解析后台返回体的 `data` 字段,业务失败时抛出 [ApiException];
|
||||
/// 本层将查询类接口的异常降级为空数据,写操作则把错误提示回传给控制器。
|
||||
class AccountApi {
|
||||
const AccountApi();
|
||||
|
||||
/// 登录设备(会话)列表。
|
||||
Future<List<LoginDevice>> getLoginDevices() async {
|
||||
try {
|
||||
final data = await DioClient.get('account/login-devices');
|
||||
return _parseList(data, LoginDevice.fromJson);
|
||||
} on ApiException catch (e) {
|
||||
debugPrint('[AccountApi] getLoginDevices failed: ${e.message}');
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// 将指定登录设备下线。
|
||||
Future<String?> revokeLoginDevice(String id) =>
|
||||
_write(() => DioClient.post(
|
||||
'account/login-devices/revoke',
|
||||
queryParameters: {'id': id},
|
||||
));
|
||||
|
||||
/// 修改密码。
|
||||
Future<String?> changePassword({
|
||||
required String oldPassword,
|
||||
required String newPassword,
|
||||
}) =>
|
||||
_write(() => DioClient.post(
|
||||
'account/password',
|
||||
data: {'oldPassword': oldPassword, 'newPassword': newPassword},
|
||||
));
|
||||
|
||||
/// 发送更换手机号验证码到新手机号。
|
||||
Future<String?> sendChangeMobileCode(String newMobile) =>
|
||||
_write(() => DioClient.post(
|
||||
'account/mobile/sms/code',
|
||||
queryParameters: {'mobile': newMobile},
|
||||
));
|
||||
|
||||
/// 更换绑定手机号。
|
||||
Future<String?> changeMobile({
|
||||
required String newMobile,
|
||||
required String code,
|
||||
required String password,
|
||||
}) =>
|
||||
_write(() => DioClient.post(
|
||||
'account/mobile',
|
||||
data: {
|
||||
'mobile': newMobile,
|
||||
'code': code,
|
||||
'password': password,
|
||||
},
|
||||
));
|
||||
|
||||
/// 查询微信绑定状态。
|
||||
Future<WechatBinding> getWechatBinding() async {
|
||||
try {
|
||||
final data = await DioClient.get('account/wechat');
|
||||
if (data is Map<String, dynamic>) return WechatBinding.fromJson(data);
|
||||
return WechatBinding.unbound;
|
||||
} on ApiException catch (e) {
|
||||
debugPrint('[AccountApi] getWechatBinding failed: ${e.message}');
|
||||
return WechatBinding.unbound;
|
||||
}
|
||||
}
|
||||
|
||||
/// 绑定微信。
|
||||
Future<String?> bindWechat(String authCode) => _write(() => DioClient.post(
|
||||
'account/wechat/bind',
|
||||
data: {'code': authCode},
|
||||
));
|
||||
|
||||
/// 解绑微信。
|
||||
Future<String?> unbindWechat() =>
|
||||
_write(() => DioClient.post('account/wechat/unbind'));
|
||||
|
||||
/// 统一处理写操作:成功返回 null,业务失败返回后台提示文案。
|
||||
Future<String?> _write(Future<dynamic> Function() call) async {
|
||||
try {
|
||||
await call();
|
||||
return null;
|
||||
} on ApiException catch (e) {
|
||||
return e.message;
|
||||
}
|
||||
}
|
||||
|
||||
/// 兼容后台可能返回「数组」或「分页对象(含 list/records)」两种结构。
|
||||
List<T> _parseList<T>(
|
||||
dynamic data,
|
||||
T Function(Map<String, dynamic>) fromJson,
|
||||
) {
|
||||
Iterable<dynamic>? raw;
|
||||
if (data is List) {
|
||||
raw = data;
|
||||
} else if (data is Map<String, dynamic>) {
|
||||
raw = (data['list'] ?? data['records']) as Iterable<dynamic>?;
|
||||
}
|
||||
if (raw == null) return const [];
|
||||
return raw
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(fromJson)
|
||||
.toList(growable: false);
|
||||
}
|
||||
}
|
||||
23
lib/features/account/data/account_providers.dart
Normal file
23
lib/features/account/data/account_providers.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:ttstd_family_care/features/account/data/account_api.dart';
|
||||
import 'package:ttstd_family_care/features/account/data/account_repository_impl.dart';
|
||||
import 'package:ttstd_family_care/features/account/domain/account_models.dart';
|
||||
import 'package:ttstd_family_care/features/account/domain/account_repository.dart';
|
||||
|
||||
/// 账号与安全仓储 Provider。
|
||||
final accountRepositoryProvider = Provider<AccountRepository>((ref) {
|
||||
const api = AccountApi();
|
||||
return AccountRepositoryImpl(api);
|
||||
});
|
||||
|
||||
/// 登录设备(会话)列表。
|
||||
final loginDevicesProvider = FutureProvider<List<LoginDevice>>((ref) async {
|
||||
final repo = ref.watch(accountRepositoryProvider);
|
||||
return repo.getLoginDevices();
|
||||
});
|
||||
|
||||
/// 微信绑定状态。
|
||||
final wechatBindingProvider = FutureProvider<WechatBinding>((ref) async {
|
||||
final repo = ref.watch(accountRepositoryProvider);
|
||||
return repo.getWechatBinding();
|
||||
});
|
||||
44
lib/features/account/data/account_repository_impl.dart
Normal file
44
lib/features/account/data/account_repository_impl.dart
Normal file
@@ -0,0 +1,44 @@
|
||||
import 'package:ttstd_family_care/features/account/data/account_api.dart';
|
||||
import 'package:ttstd_family_care/features/account/domain/account_models.dart';
|
||||
import 'package:ttstd_family_care/features/account/domain/account_repository.dart';
|
||||
|
||||
/// [AccountRepository] 的默认实现,委托给 [AccountApi]。
|
||||
class AccountRepositoryImpl implements AccountRepository {
|
||||
const AccountRepositoryImpl(this._api);
|
||||
|
||||
final AccountApi _api;
|
||||
|
||||
@override
|
||||
Future<List<LoginDevice>> getLoginDevices() => _api.getLoginDevices();
|
||||
|
||||
@override
|
||||
Future<String?> revokeLoginDevice(String id) => _api.revokeLoginDevice(id);
|
||||
|
||||
@override
|
||||
Future<String?> changePassword({
|
||||
required String oldPassword,
|
||||
required String newPassword,
|
||||
}) =>
|
||||
_api.changePassword(oldPassword: oldPassword, newPassword: newPassword);
|
||||
|
||||
@override
|
||||
Future<String?> sendChangeMobileCode(String newMobile) =>
|
||||
_api.sendChangeMobileCode(newMobile);
|
||||
|
||||
@override
|
||||
Future<String?> changeMobile({
|
||||
required String newMobile,
|
||||
required String code,
|
||||
required String password,
|
||||
}) =>
|
||||
_api.changeMobile(newMobile: newMobile, code: code, password: password);
|
||||
|
||||
@override
|
||||
Future<WechatBinding> getWechatBinding() => _api.getWechatBinding();
|
||||
|
||||
@override
|
||||
Future<String?> bindWechat(String authCode) => _api.bindWechat(authCode);
|
||||
|
||||
@override
|
||||
Future<String?> unbindWechat() => _api.unbindWechat();
|
||||
}
|
||||
82
lib/features/account/domain/account_models.dart
Normal file
82
lib/features/account/domain/account_models.dart
Normal file
@@ -0,0 +1,82 @@
|
||||
/// 账号与安全相关领域模型。
|
||||
///
|
||||
/// 纯 Dart 实体,零 Flutter/UI 依赖(见 AGENTS.md §3)。
|
||||
library;
|
||||
|
||||
/// 登录设备(会话)记录。
|
||||
///
|
||||
/// 对应「账号与安全 → 登录设备」列表项,展示登录过本账号的终端信息。
|
||||
class LoginDevice {
|
||||
const LoginDevice({
|
||||
required this.id,
|
||||
this.deviceName,
|
||||
this.deviceModel,
|
||||
this.osName,
|
||||
this.location,
|
||||
this.ip,
|
||||
this.lastActiveTime,
|
||||
this.current = false,
|
||||
});
|
||||
|
||||
/// 会话唯一标识,用于「下线」操作。
|
||||
final String id;
|
||||
|
||||
final String? deviceName;
|
||||
final String? deviceModel;
|
||||
final String? osName;
|
||||
|
||||
/// 登录地点(后台按 IP 解析)。
|
||||
final String? location;
|
||||
final String? ip;
|
||||
|
||||
/// 最近活跃时间。
|
||||
final String? lastActiveTime;
|
||||
|
||||
/// 是否为当前设备(当前设备不允许下线)。
|
||||
final bool current;
|
||||
|
||||
/// 列表主标题:优先设备名,其次型号,最后兜底会话 ID。
|
||||
String get displayName {
|
||||
if (deviceName != null && deviceName!.isNotEmpty) return deviceName!;
|
||||
if (deviceModel != null && deviceModel!.isNotEmpty) return deviceModel!;
|
||||
return id;
|
||||
}
|
||||
|
||||
factory LoginDevice.fromJson(Map<String, dynamic> json) => LoginDevice(
|
||||
id: (json['id'] ?? json['sessionId'] ?? '').toString(),
|
||||
deviceName: json['deviceName'] as String?,
|
||||
deviceModel: json['deviceModel'] as String?,
|
||||
osName: json['osName'] as String?,
|
||||
location: json['location'] as String?,
|
||||
ip: json['ip'] as String?,
|
||||
lastActiveTime: json['lastActiveTime']?.toString(),
|
||||
current: json['current'] == true,
|
||||
);
|
||||
}
|
||||
|
||||
/// 微信绑定状态。
|
||||
class WechatBinding {
|
||||
const WechatBinding({
|
||||
required this.bound,
|
||||
this.nickname,
|
||||
this.avatarUrl,
|
||||
this.bindTime,
|
||||
});
|
||||
|
||||
/// 是否已绑定微信。
|
||||
final bool bound;
|
||||
|
||||
final String? nickname;
|
||||
final String? avatarUrl;
|
||||
final String? bindTime;
|
||||
|
||||
factory WechatBinding.fromJson(Map<String, dynamic> json) => WechatBinding(
|
||||
bound: json['bound'] == true,
|
||||
nickname: json['nickname'] as String?,
|
||||
avatarUrl: json['avatarUrl'] as String?,
|
||||
bindTime: json['bindTime']?.toString(),
|
||||
);
|
||||
|
||||
/// 未绑定态的默认值(接口无数据时兜底)。
|
||||
static const WechatBinding unbound = WechatBinding(bound: false);
|
||||
}
|
||||
47
lib/features/account/domain/account_repository.dart
Normal file
47
lib/features/account/domain/account_repository.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'package:ttstd_family_care/features/account/domain/account_models.dart';
|
||||
|
||||
/// 账号与安全仓储:登录设备管理、修改密码、更换手机、微信绑定。
|
||||
abstract class AccountRepository {
|
||||
/// 登录过本账号的设备(会话)列表。
|
||||
Future<List<LoginDevice>> getLoginDevices();
|
||||
|
||||
/// 将指定登录设备(会话)下线。
|
||||
///
|
||||
/// 成功返回 null;失败返回错误提示。
|
||||
Future<String?> revokeLoginDevice(String id);
|
||||
|
||||
/// 修改密码(校验原密码后设置新密码)。
|
||||
///
|
||||
/// 成功返回 null;失败返回错误提示。
|
||||
Future<String?> changePassword({
|
||||
required String oldPassword,
|
||||
required String newPassword,
|
||||
});
|
||||
|
||||
/// 发送更换手机号验证码到新手机号。
|
||||
///
|
||||
/// 成功返回 null;失败返回错误提示。
|
||||
Future<String?> sendChangeMobileCode(String newMobile);
|
||||
|
||||
/// 更换绑定手机号(需新手机号验证码 + 账号密码校验)。
|
||||
///
|
||||
/// 成功返回 null;失败返回错误提示。
|
||||
Future<String?> changeMobile({
|
||||
required String newMobile,
|
||||
required String code,
|
||||
required String password,
|
||||
});
|
||||
|
||||
/// 查询微信绑定状态。
|
||||
Future<WechatBinding> getWechatBinding();
|
||||
|
||||
/// 绑定微信。[authCode] 为微信授权登录返回的 code。
|
||||
///
|
||||
/// 成功返回 null;失败返回错误提示。
|
||||
Future<String?> bindWechat(String authCode);
|
||||
|
||||
/// 解绑微信。
|
||||
///
|
||||
/// 成功返回 null;失败返回错误提示。
|
||||
Future<String?> unbindWechat();
|
||||
}
|
||||
161
lib/features/account/presentation/account_controller.dart
Normal file
161
lib/features/account/presentation/account_controller.dart
Normal file
@@ -0,0 +1,161 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:ttstd_family_care/core/network/error_message.dart';
|
||||
import 'package:ttstd_family_care/features/account/data/account_providers.dart';
|
||||
|
||||
/// 账号与安全写操作状态。
|
||||
///
|
||||
/// [alert] 为一次性提示,UI 展示后需调用 [AccountController.consumeAlert] 消费;
|
||||
/// [countdown] 为更换手机号场景的验证码倒计时秒数(0 表示可再次发送)。
|
||||
class AccountActionState {
|
||||
const AccountActionState({
|
||||
this.submitting = false,
|
||||
this.sendingCode = false,
|
||||
this.countdown = 0,
|
||||
this.alert,
|
||||
});
|
||||
|
||||
final bool submitting;
|
||||
final bool sendingCode;
|
||||
final int countdown;
|
||||
final String? alert;
|
||||
|
||||
AccountActionState copyWith({
|
||||
bool? submitting,
|
||||
bool? sendingCode,
|
||||
int? countdown,
|
||||
String? alert,
|
||||
}) =>
|
||||
AccountActionState(
|
||||
submitting: submitting ?? this.submitting,
|
||||
sendingCode: sendingCode ?? this.sendingCode,
|
||||
countdown: countdown ?? this.countdown,
|
||||
alert: alert,
|
||||
);
|
||||
}
|
||||
|
||||
/// 账号与安全控制器:修改密码、更换手机、微信绑定、登录设备下线。
|
||||
///
|
||||
/// 页面级控制器,**不使用** keepAlive;页面销毁时自动取消倒计时定时器。
|
||||
class AccountController extends Notifier<AccountActionState> {
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
AccountActionState build() {
|
||||
ref.onDispose(() => _timer?.cancel());
|
||||
return const AccountActionState();
|
||||
}
|
||||
|
||||
/// 消费一次性提示,避免重复弹窗。
|
||||
void consumeAlert() {
|
||||
if (state.alert != null) state = state.copyWith(alert: null);
|
||||
}
|
||||
|
||||
/// 修改密码,成功返回 true。
|
||||
Future<bool> changePassword({
|
||||
required String oldPassword,
|
||||
required String newPassword,
|
||||
}) =>
|
||||
_run(() => ref.read(accountRepositoryProvider).changePassword(
|
||||
oldPassword: oldPassword,
|
||||
newPassword: newPassword,
|
||||
));
|
||||
|
||||
/// 发送更换手机号验证码,成功后启动 60 秒倒计时。
|
||||
Future<bool> sendChangeMobileCode(String newMobile) async {
|
||||
if (state.sendingCode || state.countdown > 0) return false;
|
||||
state = state.copyWith(sendingCode: true, alert: null);
|
||||
try {
|
||||
final error =
|
||||
await ref.read(accountRepositoryProvider).sendChangeMobileCode(
|
||||
newMobile.trim(),
|
||||
);
|
||||
if (error != null) {
|
||||
state = state.copyWith(sendingCode: false, alert: error);
|
||||
return false;
|
||||
}
|
||||
state = state.copyWith(sendingCode: false, countdown: 60);
|
||||
_startCountdown();
|
||||
return true;
|
||||
} catch (e) {
|
||||
state = state.copyWith(sendingCode: false, alert: userMessageOf(e));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 更换绑定手机号,成功返回 true。
|
||||
Future<bool> changeMobile({
|
||||
required String newMobile,
|
||||
required String code,
|
||||
required String password,
|
||||
}) =>
|
||||
_run(() => ref.read(accountRepositoryProvider).changeMobile(
|
||||
newMobile: newMobile.trim(),
|
||||
code: code.trim(),
|
||||
password: password,
|
||||
));
|
||||
|
||||
/// 绑定微信([authCode] 由微信 SDK 授权回调提供),成功返回 true。
|
||||
Future<bool> bindWechat(String authCode) => _run(
|
||||
() => ref.read(accountRepositoryProvider).bindWechat(authCode),
|
||||
invalidateWechat: true,
|
||||
);
|
||||
|
||||
/// 解绑微信,成功返回 true。
|
||||
Future<bool> unbindWechat() => _run(
|
||||
() => ref.read(accountRepositoryProvider).unbindWechat(),
|
||||
invalidateWechat: true,
|
||||
);
|
||||
|
||||
/// 将指定登录设备下线,成功返回 true。
|
||||
Future<bool> revokeLoginDevice(String id) => _run(
|
||||
() => ref.read(accountRepositoryProvider).revokeLoginDevice(id),
|
||||
invalidateLoginDevices: true,
|
||||
);
|
||||
|
||||
/// 每秒递减倒计时,归零后自动停止。
|
||||
void _startCountdown() {
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
final next = state.countdown - 1;
|
||||
if (next <= 0) {
|
||||
timer.cancel();
|
||||
state = state.copyWith(countdown: 0, alert: state.alert);
|
||||
} else {
|
||||
state = state.copyWith(countdown: next, alert: state.alert);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 统一执行写操作:管理 submitting 开关、错误文案与相关列表刷新。
|
||||
Future<bool> _run(
|
||||
Future<String?> Function() action, {
|
||||
bool invalidateWechat = false,
|
||||
bool invalidateLoginDevices = false,
|
||||
}) async {
|
||||
if (state.submitting) return false;
|
||||
state = state.copyWith(submitting: true, alert: null);
|
||||
try {
|
||||
final error = await action();
|
||||
if (error != null) {
|
||||
state = state.copyWith(submitting: false, alert: error);
|
||||
return false;
|
||||
}
|
||||
if (invalidateWechat) ref.invalidate(wechatBindingProvider);
|
||||
if (invalidateLoginDevices) ref.invalidate(loginDevicesProvider);
|
||||
state = state.copyWith(submitting: false);
|
||||
return true;
|
||||
} catch (e) {
|
||||
// 统一文案映射,禁止直接 toString()(见 AGENTS.md §12)。
|
||||
state = state.copyWith(submitting: false, alert: userMessageOf(e));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 账号与安全控制器 Provider(页面级,不 keepAlive)。
|
||||
final accountControllerProvider =
|
||||
NotifierProvider<AccountController, AccountActionState>(
|
||||
AccountController.new,
|
||||
);
|
||||
219
lib/features/account/presentation/change_mobile_page.dart
Normal file
219
lib/features/account/presentation/change_mobile_page.dart
Normal file
@@ -0,0 +1,219 @@
|
||||
import 'package:cupertino_ui/cupertino_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/widgets/feature_ui.dart';
|
||||
import '../../../core/widgets/settings_ui.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import 'account_controller.dart';
|
||||
|
||||
/// 更换手机页(账号与安全 → 更换手机)。
|
||||
///
|
||||
/// 流程:输入新手机号 → 获取验证码(60s 倒计时)→ 输入验证码与账号密码 → 提交。
|
||||
class ChangeMobilePage extends ConsumerStatefulWidget {
|
||||
const ChangeMobilePage({super.key, this.currentMobile});
|
||||
|
||||
/// 当前绑定手机号,用于顶部脱敏展示(可选)。
|
||||
final String? currentMobile;
|
||||
|
||||
@override
|
||||
ConsumerState<ChangeMobilePage> createState() => _ChangeMobilePageState();
|
||||
}
|
||||
|
||||
class _ChangeMobilePageState extends ConsumerState<ChangeMobilePage> {
|
||||
final TextEditingController _mobileController = TextEditingController();
|
||||
final TextEditingController _codeController = TextEditingController();
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
|
||||
/// 中国大陆手机号校验。
|
||||
static final RegExp _mobileRule = RegExp(r'^1[3-9]\d{9}$');
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_mobileController.dispose();
|
||||
_codeController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final action = ref.watch(accountControllerProvider);
|
||||
|
||||
ref.listen<AccountActionState>(accountControllerProvider, (prev, next) {
|
||||
final alert = next.alert;
|
||||
if (alert == null) return;
|
||||
ref.read(accountControllerProvider.notifier).consumeAlert();
|
||||
AppDialogs.alert(context, message: alert, confirmText: l10n.commonConfirm);
|
||||
});
|
||||
|
||||
final counting = action.countdown > 0;
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
navigationBar: CupertinoNavigationBar(
|
||||
middle: Text(l10n.changeMobileTitle),
|
||||
leading: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
child: const Icon(CupertinoIcons.back),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 24),
|
||||
children: [
|
||||
if (widget.currentMobile?.isNotEmpty == true)
|
||||
CardBox(
|
||||
padding: EdgeInsets.zero,
|
||||
child: SettingsRow(
|
||||
emoji: '📞',
|
||||
color: AppColors.gray,
|
||||
title: l10n.changeMobileCurrent,
|
||||
value: _mask(widget.currentMobile!),
|
||||
trailing: Text(
|
||||
_mask(widget.currentMobile!),
|
||||
style: const TextStyle(fontSize: 13, color: AppColors.sub),
|
||||
),
|
||||
),
|
||||
),
|
||||
CardBox(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
children: [
|
||||
LabeledField(
|
||||
label: l10n.changeMobileNew,
|
||||
controller: _mobileController,
|
||||
keyboardType: TextInputType.phone,
|
||||
maxLength: 11,
|
||||
),
|
||||
LabeledField(
|
||||
label: l10n.changeMobileCode,
|
||||
controller: _codeController,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 6,
|
||||
suffix: _CodeButton(
|
||||
text: counting
|
||||
? l10n.changeMobileResendIn(action.countdown)
|
||||
: l10n.changeMobileSendCode,
|
||||
enabled: !counting && !action.sendingCode,
|
||||
loading: action.sendingCode,
|
||||
onPressed: () => _sendCode(l10n),
|
||||
),
|
||||
),
|
||||
LabeledField(
|
||||
label: l10n.changeMobilePassword,
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
PrimaryButton(
|
||||
text: l10n.commonSave,
|
||||
loading: action.submitting,
|
||||
onPressed: () => _submit(l10n),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 校验新手机号后发送验证码。
|
||||
Future<void> _sendCode(AppLocalizations l10n) async {
|
||||
final mobile = _mobileController.text.trim();
|
||||
if (!_mobileRule.hasMatch(mobile)) {
|
||||
await AppDialogs.alert(
|
||||
context,
|
||||
message: l10n.changeMobileInvalid,
|
||||
confirmText: l10n.commonConfirm,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await ref.read(accountControllerProvider.notifier).sendChangeMobileCode(mobile);
|
||||
}
|
||||
|
||||
/// 校验三项输入后提交更换请求。
|
||||
Future<void> _submit(AppLocalizations l10n) async {
|
||||
final mobile = _mobileController.text.trim();
|
||||
final code = _codeController.text.trim();
|
||||
final password = _passwordController.text;
|
||||
|
||||
final error = !_mobileRule.hasMatch(mobile)
|
||||
? l10n.changeMobileInvalid
|
||||
: code.isEmpty
|
||||
? l10n.changeMobileCodeEmpty
|
||||
: password.isEmpty
|
||||
? l10n.changeMobilePasswordEmpty
|
||||
: null;
|
||||
if (error != null) {
|
||||
await AppDialogs.alert(
|
||||
context,
|
||||
message: error,
|
||||
confirmText: l10n.commonConfirm,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final ok = await ref.read(accountControllerProvider.notifier).changeMobile(
|
||||
newMobile: mobile,
|
||||
code: code,
|
||||
password: password,
|
||||
);
|
||||
if (!ok || !mounted) return;
|
||||
await AppDialogs.alert(
|
||||
context,
|
||||
message: l10n.changeMobileSuccess,
|
||||
confirmText: l10n.commonConfirm,
|
||||
);
|
||||
if (mounted) context.pop();
|
||||
}
|
||||
|
||||
/// 手机号脱敏:保留前 3 后 4 位。
|
||||
String _mask(String mobile) {
|
||||
if (mobile.length < 7) return mobile;
|
||||
return '${mobile.substring(0, 3)}****${mobile.substring(mobile.length - 4)}';
|
||||
}
|
||||
}
|
||||
|
||||
/// 「获取验证码 / 倒计时」按钮。
|
||||
class _CodeButton extends StatelessWidget {
|
||||
const _CodeButton({
|
||||
required this.text,
|
||||
required this.enabled,
|
||||
required this.loading,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final String text;
|
||||
final bool enabled;
|
||||
final bool loading;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => GestureDetector(
|
||||
onTap: enabled ? onPressed : null,
|
||||
child: Container(
|
||||
height: 44,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: enabled ? AppColors.green : AppColors.track,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Center(
|
||||
child: loading
|
||||
? const CupertinoActivityIndicator(color: CupertinoColors.white)
|
||||
: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: enabled ? CupertinoColors.white : AppColors.sub,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
138
lib/features/account/presentation/change_password_page.dart
Normal file
138
lib/features/account/presentation/change_password_page.dart
Normal file
@@ -0,0 +1,138 @@
|
||||
import 'package:cupertino_ui/cupertino_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/widgets/feature_ui.dart';
|
||||
import '../../../core/widgets/settings_ui.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import 'account_controller.dart';
|
||||
|
||||
/// 修改密码页(账号与安全 → 修改密码)。
|
||||
///
|
||||
/// 校验规则:新密码 8-20 位且同时包含字母与数字,两次输入需一致。
|
||||
class ChangePasswordPage extends ConsumerStatefulWidget {
|
||||
const ChangePasswordPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ChangePasswordPage> createState() => _ChangePasswordPageState();
|
||||
}
|
||||
|
||||
class _ChangePasswordPageState extends ConsumerState<ChangePasswordPage> {
|
||||
final TextEditingController _oldController = TextEditingController();
|
||||
final TextEditingController _newController = TextEditingController();
|
||||
final TextEditingController _confirmController = TextEditingController();
|
||||
|
||||
/// 新密码规则:8-20 位,至少各含一个字母与数字。
|
||||
static final RegExp _passwordRule =
|
||||
RegExp(r'^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d!@#$%^&*()_+\-=]{8,20}$');
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_oldController.dispose();
|
||||
_newController.dispose();
|
||||
_confirmController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final action = ref.watch(accountControllerProvider);
|
||||
|
||||
ref.listen<AccountActionState>(accountControllerProvider, (prev, next) {
|
||||
final alert = next.alert;
|
||||
if (alert == null) return;
|
||||
ref.read(accountControllerProvider.notifier).consumeAlert();
|
||||
AppDialogs.alert(context, message: alert, confirmText: l10n.commonConfirm);
|
||||
});
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
navigationBar: CupertinoNavigationBar(
|
||||
middle: Text(l10n.changePasswordTitle),
|
||||
leading: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
child: const Icon(CupertinoIcons.back),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 24),
|
||||
children: [
|
||||
CardBox(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
children: [
|
||||
LabeledField(
|
||||
label: l10n.changePasswordOld,
|
||||
controller: _oldController,
|
||||
obscureText: true,
|
||||
),
|
||||
LabeledField(
|
||||
label: l10n.changePasswordNew,
|
||||
controller: _newController,
|
||||
obscureText: true,
|
||||
),
|
||||
LabeledField(
|
||||
label: l10n.changePasswordConfirmField,
|
||||
controller: _confirmController,
|
||||
obscureText: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(22, 6, 22, 2),
|
||||
child: Text(
|
||||
l10n.changePasswordRule,
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.sub, height: 1.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
PrimaryButton(
|
||||
text: l10n.commonSave,
|
||||
loading: action.submitting,
|
||||
onPressed: () => _submit(l10n),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 前端校验通过后提交修改,成功则提示并返回。
|
||||
Future<void> _submit(AppLocalizations l10n) async {
|
||||
final oldPwd = _oldController.text;
|
||||
final newPwd = _newController.text;
|
||||
final confirm = _confirmController.text;
|
||||
|
||||
if (!_passwordRule.hasMatch(newPwd)) {
|
||||
await AppDialogs.alert(
|
||||
context,
|
||||
message: l10n.changePasswordInvalid,
|
||||
confirmText: l10n.commonConfirm,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (newPwd != confirm) {
|
||||
await AppDialogs.alert(
|
||||
context,
|
||||
message: l10n.changePasswordMismatch,
|
||||
confirmText: l10n.commonConfirm,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final ok = await ref.read(accountControllerProvider.notifier).changePassword(
|
||||
oldPassword: oldPwd,
|
||||
newPassword: newPwd,
|
||||
);
|
||||
if (!ok || !mounted) return;
|
||||
await AppDialogs.alert(
|
||||
context,
|
||||
message: l10n.changePasswordSuccess,
|
||||
confirmText: l10n.commonConfirm,
|
||||
);
|
||||
if (mounted) context.pop();
|
||||
}
|
||||
}
|
||||
201
lib/features/account/presentation/login_devices_page.dart
Normal file
201
lib/features/account/presentation/login_devices_page.dart
Normal file
@@ -0,0 +1,201 @@
|
||||
import 'package:cupertino_ui/cupertino_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/widgets/feature_ui.dart';
|
||||
import '../../../core/widgets/settings_ui.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../data/account_providers.dart';
|
||||
import '../domain/account_models.dart';
|
||||
import 'account_controller.dart';
|
||||
|
||||
/// 登录设备页(账号与安全 → 登录设备)。
|
||||
///
|
||||
/// 列出登录过本账号的终端,非当前设备可执行「下线」操作。
|
||||
class LoginDevicesPage extends ConsumerWidget {
|
||||
const LoginDevicesPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final devicesAsync = ref.watch(loginDevicesProvider);
|
||||
final action = ref.watch(accountControllerProvider);
|
||||
|
||||
ref.listen<AccountActionState>(accountControllerProvider, (prev, next) {
|
||||
final alert = next.alert;
|
||||
if (alert == null) return;
|
||||
ref.read(accountControllerProvider.notifier).consumeAlert();
|
||||
AppDialogs.alert(context, message: alert, confirmText: l10n.commonConfirm);
|
||||
});
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
navigationBar: CupertinoNavigationBar(
|
||||
middle: Text(l10n.loginDevicesTitle),
|
||||
leading: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
child: const Icon(CupertinoIcons.back),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
ListView(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
children: [
|
||||
SectionTitle(l10n.securityLoginDevices),
|
||||
devicesAsync.when(
|
||||
loading: () => const CardBox(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 20),
|
||||
child: Center(child: CupertinoActivityIndicator()),
|
||||
),
|
||||
),
|
||||
error: (e, _) => CardBox(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.commonLoadFailed,
|
||||
style: const TextStyle(fontSize: 13, color: AppColors.sub),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
GestureDetector(
|
||||
onTap: () => ref.invalidate(loginDevicesProvider),
|
||||
child: Text(
|
||||
l10n.commonRetry,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.green,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (devices) => devices.isEmpty
|
||||
? CardBox(
|
||||
child: Text(
|
||||
l10n.loginDevicesEmpty,
|
||||
style: const TextStyle(fontSize: 13, color: AppColors.sub),
|
||||
),
|
||||
)
|
||||
: CardBox(
|
||||
padding: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < devices.length; i++) ...[
|
||||
if (i > 0) const AppDivider(),
|
||||
_LoginDeviceRow(
|
||||
device: devices[i],
|
||||
onRevoke: () =>
|
||||
_confirmRevoke(context, ref, devices[i], l10n),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (action.submitting)
|
||||
const Positioned.fill(
|
||||
child: ColoredBox(
|
||||
color: Color(0x33000000),
|
||||
child: Center(child: CupertinoActivityIndicator(radius: 14)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 下线二次确认。
|
||||
Future<void> _confirmRevoke(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
LoginDevice device,
|
||||
AppLocalizations l10n,
|
||||
) async {
|
||||
final confirmed = await AppDialogs.confirm(
|
||||
context,
|
||||
title: l10n.loginDevicesRevoke,
|
||||
message: l10n.loginDevicesRevokeConfirm,
|
||||
confirmText: l10n.loginDevicesRevoke,
|
||||
cancelText: l10n.commonCancel,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
final ok = await ref
|
||||
.read(accountControllerProvider.notifier)
|
||||
.revokeLoginDevice(device.id);
|
||||
if (!ok || !context.mounted) return;
|
||||
await AppDialogs.alert(
|
||||
context,
|
||||
message: l10n.loginDevicesRevokeSuccess,
|
||||
confirmText: l10n.commonConfirm,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录设备列表项:当前设备展示「本机」标记,其余展示「下线」按钮。
|
||||
class _LoginDeviceRow extends StatelessWidget {
|
||||
const _LoginDeviceRow({required this.device, required this.onRevoke});
|
||||
|
||||
final LoginDevice device;
|
||||
final VoidCallback onRevoke;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final parts = <String>[
|
||||
if (device.osName?.isNotEmpty == true) device.osName!,
|
||||
if (device.location?.isNotEmpty == true) device.location!,
|
||||
if (device.lastActiveTime?.isNotEmpty == true) device.lastActiveTime!,
|
||||
];
|
||||
|
||||
return SettingsRow(
|
||||
emoji: device.current ? '📲' : '💻',
|
||||
color: device.current ? AppColors.green : AppColors.blue,
|
||||
title: device.displayName,
|
||||
subtitle: parts.isEmpty ? device.ip : parts.join(' · '),
|
||||
trailing: device.current
|
||||
? Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFECFDF3),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Text(
|
||||
l10n.loginDevicesCurrent,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.green,
|
||||
),
|
||||
),
|
||||
)
|
||||
: GestureDetector(
|
||||
onTap: onRevoke,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFEF2F2),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: AppColors.red.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Text(
|
||||
l10n.loginDevicesRevoke,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.red,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
95
lib/features/account/presentation/security_page.dart
Normal file
95
lib/features/account/presentation/security_page.dart
Normal file
@@ -0,0 +1,95 @@
|
||||
import 'package:cupertino_ui/cupertino_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/widgets/feature_ui.dart';
|
||||
import '../../../core/widgets/settings_ui.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../data/account_providers.dart';
|
||||
|
||||
/// 账号与安全页(我的 → 设置 → 账号与安全)。
|
||||
///
|
||||
/// 汇总入口:登录设备、修改密码、更换手机、微信绑定;
|
||||
/// 每个入口对应一个独立子页面。
|
||||
class SecurityPage extends ConsumerWidget {
|
||||
const SecurityPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final wechatAsync = ref.watch(wechatBindingProvider);
|
||||
final wechatValue = wechatAsync.maybeWhen(
|
||||
data: (b) => b.bound ? l10n.wechatBound : l10n.wechatUnbound,
|
||||
orElse: () => null,
|
||||
);
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (didPop) return;
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.go('/profile');
|
||||
}
|
||||
},
|
||||
child: CupertinoPageScaffold(
|
||||
navigationBar: CupertinoNavigationBar(
|
||||
middle: Text(l10n.securityTitle),
|
||||
leading: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
child: const Icon(CupertinoIcons.back),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
children: [
|
||||
SectionTitle(l10n.securityTitle),
|
||||
CardBox(
|
||||
padding: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
SettingsRow(
|
||||
emoji: '💻',
|
||||
color: AppColors.blue,
|
||||
title: l10n.securityLoginDevices,
|
||||
subtitle: l10n.securityLoginDevicesSub,
|
||||
onTap: () => context.push('/security/login-devices'),
|
||||
),
|
||||
const AppDivider(),
|
||||
SettingsRow(
|
||||
emoji: '🔑',
|
||||
color: AppColors.orange,
|
||||
title: l10n.securityChangePassword,
|
||||
subtitle: l10n.securityChangePasswordSub,
|
||||
onTap: () => context.push('/security/password'),
|
||||
),
|
||||
const AppDivider(),
|
||||
SettingsRow(
|
||||
emoji: '📱',
|
||||
color: AppColors.teal,
|
||||
title: l10n.securityChangeMobile,
|
||||
subtitle: l10n.securityChangeMobileSub,
|
||||
onTap: () => context.push('/security/mobile'),
|
||||
),
|
||||
const AppDivider(),
|
||||
SettingsRow(
|
||||
emoji: '💬',
|
||||
color: AppColors.green,
|
||||
title: l10n.securityWechat,
|
||||
subtitle: l10n.securityWechatSub,
|
||||
value: wechatValue,
|
||||
onTap: () => context.push('/security/wechat'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
184
lib/features/account/presentation/wechat_binding_page.dart
Normal file
184
lib/features/account/presentation/wechat_binding_page.dart
Normal file
@@ -0,0 +1,184 @@
|
||||
import 'package:cupertino_ui/cupertino_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/widgets/feature_ui.dart';
|
||||
import '../../../core/widgets/settings_ui.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../data/account_providers.dart';
|
||||
import 'account_controller.dart';
|
||||
|
||||
/// 微信绑定页(账号与安全 → 微信绑定)。
|
||||
///
|
||||
/// 展示当前绑定状态:已绑定时提供「解除绑定」,未绑定时提供「绑定微信」。
|
||||
///
|
||||
/// 注意:绑定动作需要微信开放平台 SDK 返回的授权 `code`。当前项目尚未接入
|
||||
/// 微信 SDK,[_bind] 中的 `authCode` 获取处以 TODO 标注,接入后替换即可。
|
||||
class WechatBindingPage extends ConsumerWidget {
|
||||
const WechatBindingPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final bindingAsync = ref.watch(wechatBindingProvider);
|
||||
final action = ref.watch(accountControllerProvider);
|
||||
|
||||
ref.listen<AccountActionState>(accountControllerProvider, (prev, next) {
|
||||
final alert = next.alert;
|
||||
if (alert == null) return;
|
||||
ref.read(accountControllerProvider.notifier).consumeAlert();
|
||||
AppDialogs.alert(context, message: alert, confirmText: l10n.commonConfirm);
|
||||
});
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
navigationBar: CupertinoNavigationBar(
|
||||
middle: Text(l10n.wechatTitle),
|
||||
leading: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
child: const Icon(CupertinoIcons.back),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: bindingAsync.when(
|
||||
loading: () => const Center(child: CupertinoActivityIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
l10n.commonLoadFailed,
|
||||
style: const TextStyle(fontSize: 13, color: AppColors.sub),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
GestureDetector(
|
||||
onTap: () => ref.invalidate(wechatBindingProvider),
|
||||
child: Text(
|
||||
l10n.commonRetry,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.green,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (binding) => ListView(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
children: [
|
||||
SectionTitle(l10n.securityWechat),
|
||||
CardBox(
|
||||
padding: EdgeInsets.zero,
|
||||
child: SettingsRow(
|
||||
emoji: '💬',
|
||||
color: AppColors.green,
|
||||
title: binding.bound
|
||||
? (binding.nickname?.isNotEmpty == true
|
||||
? binding.nickname!
|
||||
: l10n.wechatBound)
|
||||
: l10n.wechatUnbound,
|
||||
subtitle: binding.bound
|
||||
? binding.bindTime
|
||||
: l10n.securityWechatSub,
|
||||
trailing: _StatusBadge(bound: binding.bound, l10n: l10n),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (binding.bound)
|
||||
PrimaryButton(
|
||||
text: l10n.wechatUnbind,
|
||||
color: AppColors.red,
|
||||
loading: action.submitting,
|
||||
onPressed: () => _confirmUnbind(context, ref, l10n),
|
||||
)
|
||||
else
|
||||
PrimaryButton(
|
||||
text: l10n.wechatBind,
|
||||
loading: action.submitting,
|
||||
onPressed: () => _bind(context, ref, l10n),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 绑定微信:需先通过微信 SDK 获取授权 code。
|
||||
Future<void> _bind(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
AppLocalizations l10n,
|
||||
) async {
|
||||
// TODO(wechat): 接入微信开放平台 SDK(如 fluwx),
|
||||
// 调用 sendWeChatAuth 获取授权 code 后传入 bindWechat。
|
||||
const String? authCode = null;
|
||||
if (authCode == null) {
|
||||
await AppDialogs.alert(
|
||||
context,
|
||||
message: l10n.wechatSdkUnavailable,
|
||||
confirmText: l10n.commonConfirm,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final ok = await ref.read(accountControllerProvider.notifier).bindWechat(authCode);
|
||||
if (!ok || !context.mounted) return;
|
||||
await AppDialogs.alert(
|
||||
context,
|
||||
message: l10n.wechatBindSuccess,
|
||||
confirmText: l10n.commonConfirm,
|
||||
);
|
||||
}
|
||||
|
||||
/// 解绑微信(二次确认)。
|
||||
Future<void> _confirmUnbind(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
AppLocalizations l10n,
|
||||
) async {
|
||||
final confirmed = await AppDialogs.confirm(
|
||||
context,
|
||||
title: l10n.wechatUnbind,
|
||||
message: l10n.wechatUnbindConfirm,
|
||||
confirmText: l10n.wechatUnbind,
|
||||
cancelText: l10n.commonCancel,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
final ok = await ref.read(accountControllerProvider.notifier).unbindWechat();
|
||||
if (!ok || !context.mounted) return;
|
||||
await AppDialogs.alert(
|
||||
context,
|
||||
message: l10n.wechatUnbindSuccess,
|
||||
confirmText: l10n.commonConfirm,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 绑定状态徽标。
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
const _StatusBadge({required this.bound, required this.l10n});
|
||||
|
||||
final bool bound;
|
||||
final AppLocalizations l10n;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: bound ? const Color(0xFFECFDF3) : AppColors.track,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Text(
|
||||
bound ? l10n.wechatBound : l10n.wechatUnbound,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: bound ? AppColors.green : AppColors.sub,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
91
lib/features/contacts/data/contact_api.dart
Normal file
91
lib/features/contacts/data/contact_api.dart
Normal file
@@ -0,0 +1,91 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:ttstd_family_care/core/network/api_exception.dart';
|
||||
import 'package:ttstd_family_care/core/network/dio_client.dart';
|
||||
import 'package:ttstd_family_care/features/contacts/domain/contact_models.dart';
|
||||
|
||||
/// 客户端(家属端)联系人接口访问层。
|
||||
///
|
||||
/// 后端接口前缀为 `/api/v1/client/sn/contact`,Dio 在 path 不以 `/` 开头时会
|
||||
/// 直接拼接 baseUrl 的 path 段,故此处使用相对路径 `v1/client/sn/contact/...`。
|
||||
/// [DioClient.get/post/put/delete] 已统一解析为后台返回体中的 `data` 字段;
|
||||
/// 业务失败时抛出 [ApiException],本层将其降级为「空数据/错误提示」以适配家属端展示。
|
||||
class ContactApi {
|
||||
const ContactApi();
|
||||
|
||||
/// 联系人列表(家属端)。
|
||||
Future<List<Contact>> getContacts(String sn) async {
|
||||
try {
|
||||
final data = await DioClient.get(
|
||||
'v1/client/sn/contact/list',
|
||||
queryParameters: {'sn': sn},
|
||||
);
|
||||
return _parseList(data, Contact.fromJson);
|
||||
} on ApiException {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// 新增联系人(家属端)。
|
||||
///
|
||||
/// 成功返回 null;失败返回后台错误提示。
|
||||
Future<String?> addContact(String sn, Contact contact) async {
|
||||
try {
|
||||
await DioClient.post(
|
||||
'v1/client/sn/contact/insert',
|
||||
queryParameters: {'sn': sn},
|
||||
data: contact.toFormJson(),
|
||||
);
|
||||
debugPrint('[ContactApi] addContact sn=$sn name=${contact.name} success');
|
||||
return null;
|
||||
} on ApiException catch (e) {
|
||||
debugPrint('[ContactApi] addContact sn=$sn name=${contact.name} failed: ${e.message}');
|
||||
return e.message;
|
||||
}
|
||||
}
|
||||
|
||||
/// 修改联系人(家属端)。
|
||||
///
|
||||
/// 成功返回 null;失败返回后台错误提示。
|
||||
/// 注意:[DioClient.put] 无 queryParameters 参数,故 id/sn 直接拼入路径。
|
||||
Future<String?> updateContact(String sn, Contact contact) async {
|
||||
try {
|
||||
final path =
|
||||
'v1/client/sn/contact/update?id=${contact.id}&sn=${Uri.encodeQueryComponent(sn)}';
|
||||
await DioClient.put(path, data: contact.toFormJson());
|
||||
debugPrint('[ContactApi] updateContact sn=$sn id=${contact.id} success');
|
||||
return null;
|
||||
} on ApiException catch (e) {
|
||||
debugPrint('[ContactApi] updateContact sn=$sn id=${contact.id} failed: ${e.message}');
|
||||
return e.message;
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除联系人(家属端)。
|
||||
///
|
||||
/// 成功返回 null;失败返回后台错误提示。
|
||||
Future<String?> deleteContact(String sn, int id) async {
|
||||
try {
|
||||
await DioClient.delete(
|
||||
'v1/client/sn/contact/delete?sn=${Uri.encodeQueryComponent(sn)}&id=$id',
|
||||
);
|
||||
debugPrint('[ContactApi] deleteContact sn=$sn id=$id success');
|
||||
return null;
|
||||
} on ApiException catch (e) {
|
||||
debugPrint('[ContactApi] deleteContact sn=$sn id=$id failed: ${e.message}');
|
||||
return e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static List<T> _parseList<T>(
|
||||
dynamic data,
|
||||
T Function(Map<String, dynamic>) fromJson,
|
||||
) {
|
||||
if (data is! List) return const [];
|
||||
return data
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(fromJson)
|
||||
.toList(growable: false);
|
||||
}
|
||||
}
|
||||
24
lib/features/contacts/data/contact_providers.dart
Normal file
24
lib/features/contacts/data/contact_providers.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:ttstd_family_care/features/contacts/data/contact_api.dart';
|
||||
import 'package:ttstd_family_care/features/contacts/data/contact_repository_impl.dart';
|
||||
import 'package:ttstd_family_care/features/contacts/domain/contact_models.dart';
|
||||
import 'package:ttstd_family_care/features/contacts/domain/contact_repository.dart';
|
||||
import 'package:ttstd_family_care/features/device/data/device_providers.dart';
|
||||
|
||||
/// 联系人仓储 Provider。
|
||||
final contactRepositoryProvider = Provider<ContactRepository>((ref) {
|
||||
const api = ContactApi();
|
||||
return ContactRepositoryImpl(api);
|
||||
});
|
||||
|
||||
/// 当前选中设备的联系人列表。
|
||||
///
|
||||
/// 依赖 [selectedDeviceSnProvider] 选择设备;未绑定设备时返回空列表。
|
||||
/// 通过 [autoDispose] 避免切换设备后残留旧数据。
|
||||
final contactsProvider =
|
||||
FutureProvider.autoDispose<List<Contact>>((ref) async {
|
||||
final sn = ref.watch(selectedDeviceSnProvider);
|
||||
if (sn == null || sn.isEmpty) return const [];
|
||||
final repo = ref.watch(contactRepositoryProvider);
|
||||
return repo.getContacts(sn);
|
||||
});
|
||||
25
lib/features/contacts/data/contact_repository_impl.dart
Normal file
25
lib/features/contacts/data/contact_repository_impl.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'package:ttstd_family_care/features/contacts/data/contact_api.dart';
|
||||
import 'package:ttstd_family_care/features/contacts/domain/contact_models.dart';
|
||||
import 'package:ttstd_family_care/features/contacts/domain/contact_repository.dart';
|
||||
|
||||
/// [ContactRepository] 的默认实现,委托给 [ContactApi]。
|
||||
class ContactRepositoryImpl implements ContactRepository {
|
||||
const ContactRepositoryImpl(this._api);
|
||||
|
||||
final ContactApi _api;
|
||||
|
||||
@override
|
||||
Future<List<Contact>> getContacts(String sn) => _api.getContacts(sn);
|
||||
|
||||
@override
|
||||
Future<String?> addContact(String sn, Contact contact) =>
|
||||
_api.addContact(sn, contact);
|
||||
|
||||
@override
|
||||
Future<String?> updateContact(String sn, Contact contact) =>
|
||||
_api.updateContact(sn, contact);
|
||||
|
||||
@override
|
||||
Future<String?> deleteContact(String sn, int id) =>
|
||||
_api.deleteContact(sn, id);
|
||||
}
|
||||
98
lib/features/contacts/domain/contact_models.dart
Normal file
98
lib/features/contacts/domain/contact_models.dart
Normal file
@@ -0,0 +1,98 @@
|
||||
/// 联系人相关领域实体(家属端对接 client 接口)。
|
||||
///
|
||||
/// 以不可变类表达,避免 UI / 仓库直接依赖后台 DTO 结构。
|
||||
/// 因未运行 build_runner(避免改动依赖与生成产物),此处手写等价实现:
|
||||
/// 所有字段 final、提供 const 构造,并手动实现 JSON 转换。
|
||||
|
||||
/// 联系人视图对象(与后台 ContactVO 对齐)。
|
||||
class Contact {
|
||||
const Contact({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.nickName,
|
||||
required this.phoneNumber,
|
||||
this.avatar,
|
||||
this.position = 0,
|
||||
this.emergency = false,
|
||||
this.showDesktop = true,
|
||||
this.bindPhone,
|
||||
this.bindSn,
|
||||
this.createTime,
|
||||
this.updateTime,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
final String? nickName;
|
||||
final String phoneNumber;
|
||||
final String? avatar;
|
||||
final int position;
|
||||
|
||||
/// 是否紧急联系人(对应 UI 中的「常用」分组)。
|
||||
final bool emergency;
|
||||
|
||||
/// 是否显示。
|
||||
final bool showDesktop;
|
||||
|
||||
final String? bindPhone;
|
||||
final String? bindSn;
|
||||
final int? createTime;
|
||||
final int? updateTime;
|
||||
|
||||
/// 列表展示用标题:昵称优先,缺省回退姓名。
|
||||
String get displayName => (nickName != null && nickName!.isNotEmpty) ? nickName! : name;
|
||||
|
||||
/// 复制并覆盖 id(用于编辑场景,保留其余字段)。
|
||||
Contact copyWithId(int id) => Contact(
|
||||
id: id,
|
||||
name: name,
|
||||
nickName: nickName,
|
||||
phoneNumber: phoneNumber,
|
||||
avatar: avatar,
|
||||
position: position,
|
||||
emergency: emergency,
|
||||
showDesktop: showDesktop,
|
||||
bindPhone: bindPhone,
|
||||
bindSn: bindSn,
|
||||
createTime: createTime,
|
||||
updateTime: updateTime,
|
||||
);
|
||||
|
||||
factory Contact.fromJson(Map<String, dynamic> json) => Contact(
|
||||
id: _toInt(json['id']) ?? 0,
|
||||
name: json['name'] as String? ?? '',
|
||||
nickName: json['nickName'] as String?,
|
||||
phoneNumber: json['phoneNumber'] as String? ?? '',
|
||||
avatar: json['avatar'] as String?,
|
||||
position: _toInt(json['position']) ?? 0,
|
||||
emergency: json['emergency'] as bool? ?? false,
|
||||
showDesktop: json['showDesktop'] as bool? ?? true,
|
||||
bindPhone: json['bindPhone'] as String?,
|
||||
bindSn: json['bindSn'] as String?,
|
||||
createTime: _toInt(json['createTime']),
|
||||
updateTime: _toInt(json['updateTime']),
|
||||
);
|
||||
|
||||
/// 转回后台表单结构(新增/修改复用)。
|
||||
Map<String, dynamic> toFormJson() => {
|
||||
'name': name,
|
||||
'nickName': nickName,
|
||||
'phoneNumber': phoneNumber,
|
||||
'avatar': avatar,
|
||||
'position': position,
|
||||
'emergency': emergency,
|
||||
'showDesktop': showDesktop,
|
||||
'bindPhone': bindPhone,
|
||||
'bindSn': bindSn,
|
||||
};
|
||||
}
|
||||
|
||||
/// 容错整型解析:后台可能以字符串(如 "123")或数字返回整型字段,
|
||||
/// 直接用 `as int?` 在字符串场景下会抛出类型错误,故统一在此转换。
|
||||
int? _toInt(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
if (value is String) return int.tryParse(value);
|
||||
return null;
|
||||
}
|
||||
22
lib/features/contacts/domain/contact_repository.dart
Normal file
22
lib/features/contacts/domain/contact_repository.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import 'package:ttstd_family_care/features/contacts/domain/contact_models.dart';
|
||||
|
||||
/// 联系人仓储,封装家属端联系人列表/新增/修改/删除/表单获取。
|
||||
abstract class ContactRepository {
|
||||
/// 获取指定设备的联系人列表(按 position 升序,由后台保证)。
|
||||
Future<List<Contact>> getContacts(String sn);
|
||||
|
||||
/// 新增联系人。
|
||||
///
|
||||
/// 成功返回 null;失败返回后台错误提示(如手机号重复)。
|
||||
Future<String?> addContact(String sn, Contact contact);
|
||||
|
||||
/// 修改联系人。
|
||||
///
|
||||
/// 成功返回 null;失败返回后台错误提示。
|
||||
Future<String?> updateContact(String sn, Contact contact);
|
||||
|
||||
/// 删除联系人。
|
||||
///
|
||||
/// 成功返回 null;失败返回后台错误提示。
|
||||
Future<String?> deleteContact(String sn, int id);
|
||||
}
|
||||
202
lib/features/contacts/presentation/contact_detail_page.dart
Normal file
202
lib/features/contacts/presentation/contact_detail_page.dart
Normal file
@@ -0,0 +1,202 @@
|
||||
import 'package:cupertino_ui/cupertino_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import 'package:ttstd_family_care/core/widgets/feature_ui.dart';
|
||||
import 'package:ttstd_family_care/core/widgets/settings_ui.dart';
|
||||
import 'package:ttstd_family_care/features/contacts/domain/contact_models.dart';
|
||||
import 'package:ttstd_family_care/features/contacts/data/contact_providers.dart';
|
||||
import 'package:ttstd_family_care/features/contacts/presentation/contact_edit_page.dart';
|
||||
import 'package:ttstd_family_care/l10n/app_localizations.dart';
|
||||
|
||||
import '../../device/data/device_providers.dart';
|
||||
|
||||
/// 联系人详情页。
|
||||
///
|
||||
/// 顶部「编辑」进入 [ContactEditPage];底部「删除」按钮位于页面内(非弹窗),
|
||||
/// 点击直接删除并回退到列表。
|
||||
class ContactDetailPage extends ConsumerStatefulWidget {
|
||||
const ContactDetailPage({required this.contact, super.key});
|
||||
|
||||
final Contact contact;
|
||||
|
||||
@override
|
||||
ConsumerState<ContactDetailPage> createState() => _ContactDetailPageState();
|
||||
}
|
||||
|
||||
class _ContactDetailPageState extends ConsumerState<ContactDetailPage> {
|
||||
late Contact _contact;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_contact = widget.contact;
|
||||
}
|
||||
|
||||
Future<void> _openEdit() async {
|
||||
final updated = await context.push<Contact?>(
|
||||
'/contacts/edit',
|
||||
extra: _contact,
|
||||
);
|
||||
if (updated == null || !mounted) return;
|
||||
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final sn = ref.read(selectedDeviceSnProvider);
|
||||
if (sn == null || sn.isEmpty) return;
|
||||
final repo = ref.read(contactRepositoryProvider);
|
||||
final err = await repo.updateContact(
|
||||
sn,
|
||||
updated.copyWithId(_contact.id),
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (err != null) {
|
||||
AppDialogs.alert(
|
||||
context,
|
||||
message: err,
|
||||
confirmText: l10n.commonConfirm,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() => _contact = updated.copyWithId(_contact.id));
|
||||
ref.invalidate(contactsProvider);
|
||||
}
|
||||
|
||||
Future<void> _delete() async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final sn = ref.read(selectedDeviceSnProvider);
|
||||
if (sn == null || sn.isEmpty) return;
|
||||
final repo = ref.read(contactRepositoryProvider);
|
||||
final err = await repo.deleteContact(sn, _contact.id);
|
||||
if (!mounted) return;
|
||||
if (err != null) {
|
||||
AppDialogs.alert(
|
||||
context,
|
||||
message: err,
|
||||
confirmText: l10n.commonConfirm,
|
||||
);
|
||||
return;
|
||||
}
|
||||
context.pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final c = _contact;
|
||||
final url = c.avatar;
|
||||
final hasAvatar = url != null && url.isNotEmpty;
|
||||
|
||||
final avatar = hasAvatar
|
||||
? CircleAvatar(
|
||||
radius: 40,
|
||||
backgroundImage: NetworkImage(url),
|
||||
onBackgroundImageError: (_, _) {},
|
||||
)
|
||||
: CircleAvatar(
|
||||
radius: 40,
|
||||
backgroundColor: AppColors.green.withValues(alpha: 0.15),
|
||||
child: Text(
|
||||
c.displayName.isNotEmpty ? c.displayName.characters.first : '?',
|
||||
style: const TextStyle(color: AppColors.green, fontSize: 28),
|
||||
),
|
||||
);
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
backgroundColor: AppColors.surface,
|
||||
navigationBar: CupertinoNavigationBar(
|
||||
middle: Text(l10n.contactDetailTitle),
|
||||
trailing: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
onPressed: _openEdit,
|
||||
child: Text(l10n.commonEdit),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
children: [
|
||||
Center(child: avatar),
|
||||
const SizedBox(height: 12),
|
||||
Center(
|
||||
child: Text(
|
||||
c.displayName,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_DetailRow(label: l10n.contactName, value: c.name),
|
||||
_DetailRow(
|
||||
label: l10n.contactNick,
|
||||
value: c.nickName ?? '-',
|
||||
),
|
||||
_DetailRow(
|
||||
label: l10n.contactPhone,
|
||||
value: c.phoneNumber,
|
||||
),
|
||||
_DetailRow(
|
||||
label: l10n.contactIsEmergency,
|
||||
value: c.emergency
|
||||
? l10n.contactEmergencyYes
|
||||
: l10n.contactEmergencyNo,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: PrimaryButton(
|
||||
text: l10n.commonDelete,
|
||||
color: AppColors.red,
|
||||
onPressed: _delete,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DetailRow extends StatelessWidget {
|
||||
const _DetailRow({required this.label, required this.value});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: const Border(
|
||||
bottom: BorderSide(color: Color(0xFFE6EAF0), width: 0.5),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 15, color: AppColors.sub),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(fontSize: 15, color: AppColors.ink),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
159
lib/features/contacts/presentation/contact_edit_page.dart
Normal file
159
lib/features/contacts/presentation/contact_edit_page.dart
Normal file
@@ -0,0 +1,159 @@
|
||||
import 'package:cupertino_ui/cupertino_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:ttstd_family_care/core/widgets/feature_ui.dart';
|
||||
import 'package:ttstd_family_care/core/widgets/settings_ui.dart';
|
||||
import 'package:ttstd_family_care/features/contacts/domain/contact_models.dart';
|
||||
import 'package:ttstd_family_care/l10n/app_localizations.dart';
|
||||
|
||||
/// 新增/编辑联系人整页。
|
||||
///
|
||||
/// 表单收集结果后以 [context.pop] 返回 [Contact],由调用方(列表页 / 详情页)
|
||||
/// 决定新增还是更新。
|
||||
class ContactEditPage extends ConsumerStatefulWidget {
|
||||
const ContactEditPage({this.contact, super.key});
|
||||
|
||||
final Contact? contact;
|
||||
|
||||
@override
|
||||
ConsumerState<ContactEditPage> createState() => _ContactEditPageState();
|
||||
}
|
||||
|
||||
class _ContactEditPageState extends ConsumerState<ContactEditPage> {
|
||||
final _nameController = TextEditingController();
|
||||
final _nickController = TextEditingController();
|
||||
final _phoneController = TextEditingController();
|
||||
late bool _emergency;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final c = widget.contact;
|
||||
_nameController.text = c?.name ?? '';
|
||||
_nickController.text = c?.nickName ?? '';
|
||||
_phoneController.text = c?.phoneNumber ?? '';
|
||||
_emergency = c?.emergency ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_nickController.dispose();
|
||||
_phoneController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _save() {
|
||||
final name = _nameController.text.trim();
|
||||
final phone = _phoneController.text.trim();
|
||||
if (name.isEmpty) {
|
||||
AppDialogs.alert(
|
||||
context,
|
||||
message: AppLocalizations.of(context).nameRequired,
|
||||
confirmText: AppLocalizations.of(context).commonConfirm,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (phone.isEmpty) {
|
||||
AppDialogs.alert(
|
||||
context,
|
||||
message: AppLocalizations.of(context).phoneRequired,
|
||||
confirmText: AppLocalizations.of(context).commonConfirm,
|
||||
);
|
||||
return;
|
||||
}
|
||||
final nick = _nickController.text.trim();
|
||||
final updated = Contact(
|
||||
id: widget.contact?.id ?? 0,
|
||||
name: name,
|
||||
nickName: nick.isEmpty ? null : nick,
|
||||
phoneNumber: phone,
|
||||
avatar: widget.contact?.avatar,
|
||||
position: widget.contact?.position ?? 0,
|
||||
emergency: _emergency,
|
||||
showDesktop: widget.contact?.showDesktop ?? true,
|
||||
bindPhone: widget.contact?.bindPhone,
|
||||
bindSn: widget.contact?.bindSn,
|
||||
createTime: widget.contact?.createTime,
|
||||
updateTime: widget.contact?.updateTime,
|
||||
);
|
||||
context.pop(updated);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final isEdit = widget.contact != null;
|
||||
return CupertinoPageScaffold(
|
||||
backgroundColor: AppColors.surface,
|
||||
navigationBar: CupertinoNavigationBar(
|
||||
middle: Text(isEdit ? l10n.commonEdit : l10n.commonAdd),
|
||||
leading: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
onPressed: () => context.pop(),
|
||||
child: Text(l10n.commonCancel),
|
||||
),
|
||||
trailing: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
onPressed: _save,
|
||||
child: Text(l10n.commonSave),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
children: [
|
||||
LabeledField(
|
||||
label: l10n.contactName,
|
||||
controller: _nameController,
|
||||
placeholder: l10n.contactName,
|
||||
),
|
||||
LabeledField(
|
||||
label: l10n.contactNick,
|
||||
controller: _nickController,
|
||||
placeholder: l10n.contactNick,
|
||||
),
|
||||
LabeledField(
|
||||
label: l10n.contactPhone,
|
||||
controller: _phoneController,
|
||||
placeholder: l10n.contactPhone,
|
||||
keyboardType: TextInputType.phone,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
color: AppColors.surface,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.contactEmergency,
|
||||
style: const TextStyle(
|
||||
fontSize: 14.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
CupertinoSwitch(
|
||||
value: _emergency,
|
||||
onChanged: (v) => setState(() => _emergency = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: PrimaryButton(
|
||||
text: isEdit ? l10n.commonSave : l10n.commonAdd,
|
||||
onPressed: _save,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,160 +1,279 @@
|
||||
import 'package:azlistview_plus/azlistview_plus.dart';
|
||||
import 'package:cupertino_ui/cupertino_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:lpinyin/lpinyin.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../../../core/widgets/feature_ui.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import 'package:ttstd_family_care/core/widgets/feature_ui.dart';
|
||||
import 'package:ttstd_family_care/core/widgets/settings_ui.dart';
|
||||
import 'package:ttstd_family_care/features/contacts/domain/contact_models.dart';
|
||||
import 'package:ttstd_family_care/features/contacts/data/contact_providers.dart';
|
||||
import 'package:ttstd_family_care/l10n/app_localizations.dart';
|
||||
|
||||
/// 联系人页(管理 → 联系人)。
|
||||
import '../../device/data/device_providers.dart';
|
||||
|
||||
/// 联系人列表的 A-Z 索引包装项。
|
||||
class _ContactAzItem implements ISuspensionBean {
|
||||
_ContactAzItem(this.contact);
|
||||
|
||||
final Contact contact;
|
||||
|
||||
@override
|
||||
bool isShowSuspension = false;
|
||||
|
||||
/// 紧急联系人归入「★ 常用」分组并置顶;其余按姓名首字母(拼音首字母)分组。
|
||||
@override
|
||||
String getSuspensionTag() {
|
||||
if (contact.emergency) return '★';
|
||||
final name = contact.displayName.trim();
|
||||
if (name.isEmpty) return '#';
|
||||
final raw = PinyinHelper.getFirstWordPinyin(name).toUpperCase();
|
||||
if (raw.isNotEmpty) {
|
||||
final first = raw[0];
|
||||
if (first.codeUnitAt(0) >= 65 && first.codeUnitAt(0) <= 90) {
|
||||
return first;
|
||||
}
|
||||
}
|
||||
return '#';
|
||||
}
|
||||
}
|
||||
|
||||
/// 联系人页面(家属端)。
|
||||
///
|
||||
/// 对应移动端设计稿:搜索栏 + 分组列表(常用/同事/家人)+ 右侧字母索引。
|
||||
class ContactsPage extends StatelessWidget {
|
||||
/// 数据来源于 [ClientContactController](`/api/v1/client/sn/contact`)。
|
||||
/// 分组规则:紧急联系人(`emergency=true`)归入「★ 常用」分组置顶,其余按姓名首字母(拼音首字母)分入 A-Z 索引分组,右侧提供字母索引条快速跳转。
|
||||
class ContactsPage extends ConsumerStatefulWidget {
|
||||
const ContactsPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ContactsPage> createState() => _ContactsPageState();
|
||||
}
|
||||
|
||||
class _ContactsPageState extends ConsumerState<ContactsPage> {
|
||||
|
||||
Future<void> _refresh() async {
|
||||
ref.invalidate(contactsProvider);
|
||||
await ref.read(contactsProvider.future);
|
||||
}
|
||||
|
||||
void _onTap(Contact contact) {
|
||||
context.push('/contacts/detail', extra: contact);
|
||||
}
|
||||
|
||||
Future<void> _onAdd() async {
|
||||
final sn = ref.read(selectedDeviceSnProvider);
|
||||
if (sn == null || sn.isEmpty) return;
|
||||
final result = await context.push<Contact?>('/contacts/edit');
|
||||
if (result == null || !mounted) return;
|
||||
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final repo = ref.read(contactRepositoryProvider);
|
||||
final err = await repo.addContact(sn, result);
|
||||
if (!mounted) return;
|
||||
if (err != null) {
|
||||
AppDialogs.alert(context, message: err, confirmText: l10n.commonConfirm);
|
||||
return;
|
||||
}
|
||||
AppDialogs.alert(
|
||||
context,
|
||||
message: l10n.contactAdded,
|
||||
confirmText: l10n.commonConfirm,
|
||||
);
|
||||
_refresh();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return PopScope(
|
||||
// 拦截系统返回键:优先出栈;若无法出栈则跳回「管理」页,避免直接退到桌面。
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (didPop) return;
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.go('/messages');
|
||||
}
|
||||
},
|
||||
child: CupertinoPageScaffold(
|
||||
navigationBar: CupertinoNavigationBar(
|
||||
middle: Text(l10n.contactsTitle),
|
||||
leading: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
child: const Icon(CupertinoIcons.back),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
trailing: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
child: const Icon(CupertinoIcons.add, size: 26),
|
||||
onPressed: () {},
|
||||
),
|
||||
final sn = ref.watch(selectedDeviceSnProvider);
|
||||
final contactsAsync = ref.watch(contactsProvider);
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
backgroundColor: AppColors.surface,
|
||||
navigationBar: CupertinoNavigationBar(
|
||||
middle: Text(l10n.contactsTitle),
|
||||
trailing: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
onPressed: sn != null && sn.isNotEmpty ? _onAdd : null,
|
||||
child: const Icon(CupertinoIcons.add, size: 26),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
ListView(
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
children: [
|
||||
// 搜索栏
|
||||
Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 10, 16, 4),
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: CupertinoColors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [BoxShadow(color: Color(0x14141E3C), blurRadius: 8, offset: Offset(0, 2))],
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
SizedBox(width: 14),
|
||||
Icon(CupertinoIcons.search, size: 16, color: AppColors.sub),
|
||||
SizedBox(width: 8),
|
||||
Text('搜索姓名、号码', style: TextStyle(fontSize: 14, color: AppColors.sub)),
|
||||
],
|
||||
),
|
||||
),
|
||||
_group(l10n.grpFavorite, [
|
||||
_Contact('妈', AppColors.blue, '妈妈', '138 **** 6621'),
|
||||
_Contact('李', AppColors.green, '李工 · 运维', '159 **** 0248'),
|
||||
]),
|
||||
_group(l10n.grpColleague, [
|
||||
_Contact('王', AppColors.purple, '王经理', '186 **** 7733 · 产品部'),
|
||||
_Contact('张', AppColors.orange, '张总监', '135 **** 5512 · 技术部'),
|
||||
_Contact('赵', AppColors.red, '赵设计', '188 **** 2019 · 设计部'),
|
||||
]),
|
||||
_group(l10n.grpFamily, [
|
||||
_Contact('爸', AppColors.teal, '爸爸', '137 **** 0098'),
|
||||
_Contact('妹', const Color(0xFFEC4899), '妹妹', '199 **** 3344'),
|
||||
]),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
right: 5,
|
||||
top: 150,
|
||||
),
|
||||
child: SafeArea(
|
||||
child: contactsAsync.when(
|
||||
loading: () => const Center(child: CupertinoActivityIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Column(
|
||||
children: const [
|
||||
Text('★', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: AppColors.sub)),
|
||||
SizedBox(height: 3),
|
||||
Text('同', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: AppColors.sub)),
|
||||
SizedBox(height: 3),
|
||||
Text('家', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: AppColors.sub)),
|
||||
SizedBox(height: 3),
|
||||
Text('A', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: AppColors.sub)),
|
||||
SizedBox(height: 3),
|
||||
Text('B', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: AppColors.sub)),
|
||||
SizedBox(height: 3),
|
||||
Text('C', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: AppColors.sub)),
|
||||
SizedBox(height: 3),
|
||||
Text('D', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: AppColors.sub)),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('$e'),
|
||||
const SizedBox(height: 12),
|
||||
CupertinoButton(
|
||||
onPressed: _refresh,
|
||||
child: Text(l10n.commonRetry),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
data: (contacts) {
|
||||
if (sn == null || sn.isEmpty) {
|
||||
return Center(child: Text(l10n.contactNoDevice));
|
||||
}
|
||||
if (contacts.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(l10n.contactListEmpty),
|
||||
const SizedBox(height: 12),
|
||||
CupertinoButton(
|
||||
onPressed: _onAdd,
|
||||
child: Text(l10n.commonAdd),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final items = contacts
|
||||
.map((c) => _ContactAzItem(c))
|
||||
.toList()
|
||||
..sort((a, b) {
|
||||
// 「★ 常用」置顶,其余按分组标签(字母)排序。
|
||||
if (a.getSuspensionTag() == '★') return -1;
|
||||
if (b.getSuspensionTag() == '★') return 1;
|
||||
return a.getSuspensionTag().compareTo(b.getSuspensionTag());
|
||||
});
|
||||
SuspensionUtil.setShowSuspensionStatus(items);
|
||||
|
||||
final indexBarData = _buildIndexBarData(items);
|
||||
|
||||
return AzListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
data: items,
|
||||
itemCount: items.length,
|
||||
indexBarData: indexBarData,
|
||||
indexBarMargin: const EdgeInsets.symmetric(vertical: 8),
|
||||
susItemHeight: 36,
|
||||
itemBuilder: (ctx, index) => _ContactTile(
|
||||
contact: items[index].contact,
|
||||
onTap: () => _onTap(items[index].contact),
|
||||
),
|
||||
susItemBuilder: (ctx, index) =>
|
||||
_buildSusHeader(context, items[index].getSuspensionTag()),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _group(String title, List<_Contact> items) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 14, 18, 4),
|
||||
child: Text(title,
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: AppColors.sub)),
|
||||
),
|
||||
CardBox(
|
||||
padding: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < items.length; i++) ...[
|
||||
if (i > 0) const Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: AppDivider()),
|
||||
_row(items[i]),
|
||||
],
|
||||
],
|
||||
/// 根据索引项实际出现的分组标签,生成右侧 IndexBar 数据(★ 置顶,A-Z 居中,# 兜底)。
|
||||
List<String> _buildIndexBarData(List<_ContactAzItem> items) {
|
||||
final tags = items.map((e) => e.getSuspensionTag()).toSet();
|
||||
const letters = <String>[
|
||||
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M',
|
||||
'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'
|
||||
];
|
||||
final ordered = <String>[];
|
||||
if (tags.contains('★')) ordered.add('★');
|
||||
for (final l in letters) {
|
||||
if (tags.contains(l)) ordered.add(l);
|
||||
}
|
||||
if (tags.contains('#')) ordered.add('#');
|
||||
return ordered;
|
||||
}
|
||||
|
||||
/// 分组头(吸顶),★ 分组显示「★ 常用」文案。
|
||||
Widget _buildSusHeader(BuildContext context, String tag) {
|
||||
final title = tag == '★' ? '★ 常用' : tag;
|
||||
return SizedBox(
|
||||
width: MediaQuery.of(context).size.width,
|
||||
height: 36,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
alignment: Alignment.centerLeft,
|
||||
color: AppColors.surface,
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.sub,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 单个联系人条目(Cupertino 风格),点击进入详情页。
|
||||
class _ContactTile extends StatelessWidget {
|
||||
const _ContactTile({
|
||||
required this.contact,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final Contact contact;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final url = contact.avatar;
|
||||
final hasAvatar = url != null && url.isNotEmpty;
|
||||
|
||||
final avatar = hasAvatar
|
||||
? CircleAvatar(
|
||||
radius: 22,
|
||||
backgroundImage: NetworkImage(url),
|
||||
onBackgroundImageError: (_, _) {},
|
||||
)
|
||||
: CircleAvatar(
|
||||
radius: 22,
|
||||
backgroundColor: AppColors.green.withValues(alpha: 0.15),
|
||||
child: Text(
|
||||
contact.displayName.isNotEmpty
|
||||
? contact.displayName.characters.first
|
||||
: '?',
|
||||
style: const TextStyle(color: AppColors.green, fontSize: 16),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
);
|
||||
|
||||
Widget _row(_Contact c) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(color: c.color, borderRadius: BorderRadius.circular(13)),
|
||||
child: Center(child: Text(c.initial, style: const TextStyle(fontSize: 17, color: CupertinoColors.white))),
|
||||
),
|
||||
avatar,
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(c.name, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.ink)),
|
||||
Text(c.phone, style: const TextStyle(fontSize: 12, color: AppColors.sub)),
|
||||
Text(
|
||||
contact.displayName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 14.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.ink,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
contact.phoneNumber,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.sub),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Text('›', style: TextStyle(fontSize: 16, color: Color(0xFFCBD2DC))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _Contact {
|
||||
const _Contact(this.initial, this.color, this.name, this.phone);
|
||||
final String initial;
|
||||
final Color color;
|
||||
final String name;
|
||||
final String phone;
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,41 @@ class DeviceApi {
|
||||
}
|
||||
}
|
||||
|
||||
/// 绑定设备(手动输入 SN 或扫码得到 SN 后调用)。
|
||||
///
|
||||
/// 成功返回 null;失败返回后台错误提示。
|
||||
Future<String?> bindDevice(String sn, {String? remark}) async {
|
||||
try {
|
||||
final data = await DioClient.post(
|
||||
'v1/client/sn/bind',
|
||||
queryParameters: {'sn': sn},
|
||||
data: remark != null && remark.isNotEmpty ? {'remark': remark} : null,
|
||||
);
|
||||
debugPrint('[DeviceApi] bindDevice sn=$sn data=$data');
|
||||
return null;
|
||||
} on ApiException catch (e) {
|
||||
debugPrint('[DeviceApi] bindDevice sn=$sn failed: ${e.message}');
|
||||
return e.message;
|
||||
}
|
||||
}
|
||||
|
||||
/// 解绑设备。
|
||||
///
|
||||
/// 成功返回 null;失败返回后台错误提示。
|
||||
Future<String?> unbindDevice(String sn) async {
|
||||
try {
|
||||
final data = await DioClient.post(
|
||||
'v1/client/sn/unbind',
|
||||
queryParameters: {'sn': sn},
|
||||
);
|
||||
debugPrint('[DeviceApi] unbindDevice sn=$sn data=$data');
|
||||
return null;
|
||||
} on ApiException catch (e) {
|
||||
debugPrint('[DeviceApi] unbindDevice sn=$sn failed: ${e.message}');
|
||||
return e.message;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取已绑定设备基本信息(无上报数据时返回 null)。
|
||||
Future<DeviceSystemInfo?> getDeviceInfo(String sn) async {
|
||||
try {
|
||||
@@ -160,4 +195,36 @@ class DeviceApi {
|
||||
);
|
||||
}).toList(growable: false);
|
||||
}
|
||||
|
||||
/// 删除单张设备截图(家属端 client 接口)。
|
||||
///
|
||||
/// [sn] 设备序列号,[id] 截图ID。成功返回 null;失败返回后台错误提示。
|
||||
Future<String?> deleteScreenshot(String sn, int id) async {
|
||||
try {
|
||||
await DioClient.delete(
|
||||
'v1/client/sn/ops/screenshots/$id?sn=${Uri.encodeQueryComponent(sn)}',
|
||||
);
|
||||
debugPrint('[DeviceApi] deleteScreenshot sn=$sn id=$id success');
|
||||
return null;
|
||||
} on ApiException catch (e) {
|
||||
debugPrint('[DeviceApi] deleteScreenshot sn=$sn id=$id failed: ${e.message}');
|
||||
return e.message;
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空设备全部截图(家属端 client 接口)。
|
||||
///
|
||||
/// [sn] 设备序列号。成功返回 null;失败返回后台错误提示。
|
||||
Future<String?> clearScreenshots(String sn) async {
|
||||
try {
|
||||
await DioClient.delete(
|
||||
'v1/client/sn/ops/screenshots?sn=${Uri.encodeQueryComponent(sn)}',
|
||||
);
|
||||
debugPrint('[DeviceApi] clearScreenshots sn=$sn success');
|
||||
return null;
|
||||
} on ApiException catch (e) {
|
||||
debugPrint('[DeviceApi] clearScreenshots sn=$sn failed: ${e.message}');
|
||||
return e.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,13 @@ class DeviceRepositoryImpl implements DeviceRepository {
|
||||
@override
|
||||
Future<List<DeviceBrief>> getMyDevices() => _api.getMyDevices();
|
||||
|
||||
@override
|
||||
Future<String?> bindDevice(String sn, {String? remark}) =>
|
||||
_api.bindDevice(sn, remark: remark);
|
||||
|
||||
@override
|
||||
Future<String?> unbindDevice(String sn) => _api.unbindDevice(sn);
|
||||
|
||||
@override
|
||||
Future<DeviceSystemInfo?> getDeviceInfo(String sn) => _api.getDeviceInfo(sn);
|
||||
|
||||
@@ -32,4 +39,11 @@ class DeviceRepositoryImpl implements DeviceRepository {
|
||||
@override
|
||||
Future<String?> sendAppOp(String sn, String op, String packageName) =>
|
||||
_api.sendAppOp(sn, op, packageName);
|
||||
|
||||
@override
|
||||
Future<String?> deleteScreenshot(String sn, int id) =>
|
||||
_api.deleteScreenshot(sn, id);
|
||||
|
||||
@override
|
||||
Future<String?> clearScreenshots(String sn) => _api.clearScreenshots(sn);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,16 @@ abstract class DeviceRepository {
|
||||
/// 当前用户已绑定的设备列表。
|
||||
Future<List<DeviceBrief>> getMyDevices();
|
||||
|
||||
/// 绑定设备(手动输入 SN 或扫码获取 SN)。
|
||||
///
|
||||
/// 成功返回 null;失败返回错误提示。
|
||||
Future<String?> bindDevice(String sn, {String? remark});
|
||||
|
||||
/// 解绑设备。
|
||||
///
|
||||
/// 成功返回 null;失败返回错误提示。
|
||||
Future<String?> unbindDevice(String sn);
|
||||
|
||||
/// 已绑定设备基本信息(无上报数据时返回 null)。
|
||||
Future<DeviceSystemInfo?> getDeviceInfo(String sn);
|
||||
|
||||
@@ -28,4 +38,14 @@ abstract class DeviceRepository {
|
||||
/// [op] 为后端操作标识:`launch` / `stop` / `uninstall` / `clear_data`。
|
||||
/// 成功返回 null;失败返回错误提示。
|
||||
Future<String?> sendAppOp(String sn, String op, String packageName);
|
||||
|
||||
/// 删除单张设备截图(家属端 client 接口)。
|
||||
///
|
||||
/// [sn] 设备序列号,[id] 截图ID。成功返回 null;失败返回错误提示。
|
||||
Future<String?> deleteScreenshot(String sn, int id);
|
||||
|
||||
/// 清空设备全部截图(家属端 client 接口)。
|
||||
///
|
||||
/// [sn] 设备序列号。成功返回 null;失败返回错误提示。
|
||||
Future<String?> clearScreenshots(String sn);
|
||||
}
|
||||
|
||||
141
lib/features/device/presentation/device_bind_page.dart
Normal file
141
lib/features/device/presentation/device_bind_page.dart
Normal file
@@ -0,0 +1,141 @@
|
||||
import 'package:cupertino_ui/cupertino_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/widgets/feature_ui.dart';
|
||||
import '../../../core/widgets/settings_ui.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import 'device_manage_controller.dart';
|
||||
|
||||
/// 绑定设备页(手动输入 SN)。
|
||||
///
|
||||
/// 提供 SN / 备注名输入、「确认绑定」与「扫码绑定」两个入口。
|
||||
/// 扫码入口跳转 `/device/scan`,扫描结果回填到 SN 输入框。
|
||||
class DeviceBindPage extends ConsumerStatefulWidget {
|
||||
const DeviceBindPage({super.key, this.initialSn});
|
||||
|
||||
/// 由扫码页回传的初始 SN(可选)。
|
||||
final String? initialSn;
|
||||
|
||||
@override
|
||||
ConsumerState<DeviceBindPage> createState() => _DeviceBindPageState();
|
||||
}
|
||||
|
||||
class _DeviceBindPageState extends ConsumerState<DeviceBindPage> {
|
||||
late final TextEditingController _snController;
|
||||
final TextEditingController _remarkController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_snController = TextEditingController(text: widget.initialSn ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_snController.dispose();
|
||||
_remarkController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final action = ref.watch(deviceManageControllerProvider);
|
||||
|
||||
// 一次性错误提示:展示后立即消费。
|
||||
ref.listen<DeviceManageState>(deviceManageControllerProvider, (prev, next) {
|
||||
final alert = next.alert;
|
||||
if (alert == null) return;
|
||||
ref.read(deviceManageControllerProvider.notifier).consumeAlert();
|
||||
AppDialogs.alert(context, message: alert, confirmText: l10n.commonConfirm);
|
||||
});
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
navigationBar: CupertinoNavigationBar(
|
||||
middle: Text(l10n.deviceBindTitle),
|
||||
leading: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
child: const Icon(CupertinoIcons.back),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 24),
|
||||
children: [
|
||||
CardBox(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
children: [
|
||||
LabeledField(
|
||||
label: l10n.deviceBindSnLabel,
|
||||
controller: _snController,
|
||||
placeholder: l10n.deviceBindSnHint,
|
||||
keyboardType: TextInputType.text,
|
||||
),
|
||||
LabeledField(
|
||||
label: l10n.deviceBindRemarkLabel,
|
||||
controller: _remarkController,
|
||||
placeholder: l10n.deviceBindRemarkHint,
|
||||
maxLength: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(22, 6, 22, 2),
|
||||
child: Text(
|
||||
l10n.deviceBindTip,
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.sub, height: 1.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
PrimaryButton(
|
||||
text: l10n.deviceBindConfirm,
|
||||
loading: action.submitting,
|
||||
onPressed: () => _submit(l10n),
|
||||
),
|
||||
SecondaryButton(
|
||||
text: l10n.deviceBindScan,
|
||||
icon: CupertinoIcons.qrcode_viewfinder,
|
||||
onPressed: action.submitting ? null : () => _openScanner(l10n),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 校验 SN 后提交绑定,成功则提示并返回上一页。
|
||||
Future<void> _submit(AppLocalizations l10n) async {
|
||||
final sn = _snController.text.trim();
|
||||
if (sn.isEmpty) {
|
||||
await AppDialogs.alert(
|
||||
context,
|
||||
message: l10n.deviceBindSnEmpty,
|
||||
confirmText: l10n.commonConfirm,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final ok = await ref.read(deviceManageControllerProvider.notifier).bind(
|
||||
sn,
|
||||
remark: _remarkController.text,
|
||||
);
|
||||
if (!ok || !mounted) return;
|
||||
await AppDialogs.alert(
|
||||
context,
|
||||
message: l10n.deviceBindSuccess,
|
||||
confirmText: l10n.commonConfirm,
|
||||
);
|
||||
if (mounted) context.pop();
|
||||
}
|
||||
|
||||
/// 打开扫码页;扫描成功后把 SN 回填输入框。
|
||||
Future<void> _openScanner(AppLocalizations l10n) async {
|
||||
final code = await context.push<String>('/device/scan');
|
||||
if (code == null || code.isEmpty || !mounted) return;
|
||||
setState(() => _snController.text = code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:ttstd_family_care/core/network/error_message.dart';
|
||||
import 'package:ttstd_family_care/features/device/data/device_providers.dart';
|
||||
|
||||
/// 设备管理(绑定 / 解绑)操作状态。
|
||||
///
|
||||
/// [submitting] 表示正在请求中,用于禁用按钮并展示 loading;
|
||||
/// [alert] 为一次性提示文案,UI 展示后必须调用 [DeviceManageController.consumeAlert] 消费;
|
||||
/// [lastSuccess] 标记最近一次操作是否成功,供 UI 决定是否关闭页面。
|
||||
class DeviceManageState {
|
||||
const DeviceManageState({
|
||||
this.submitting = false,
|
||||
this.alert,
|
||||
this.lastSuccess = false,
|
||||
});
|
||||
|
||||
final bool submitting;
|
||||
final String? alert;
|
||||
final bool lastSuccess;
|
||||
|
||||
DeviceManageState copyWith({
|
||||
bool? submitting,
|
||||
String? alert,
|
||||
bool? lastSuccess,
|
||||
}) =>
|
||||
DeviceManageState(
|
||||
submitting: submitting ?? this.submitting,
|
||||
alert: alert,
|
||||
lastSuccess: lastSuccess ?? this.lastSuccess,
|
||||
);
|
||||
}
|
||||
|
||||
/// 设备绑定 / 解绑控制器。
|
||||
///
|
||||
/// 页面级控制器,**不使用** keepAlive,随页面销毁自动释放(见 AGENTS.md §4)。
|
||||
/// 操作成功后主动 `ref.invalidate(myDevicesProvider)` 刷新绑定列表。
|
||||
class DeviceManageController extends Notifier<DeviceManageState> {
|
||||
@override
|
||||
DeviceManageState build() => const DeviceManageState();
|
||||
|
||||
/// 绑定设备。[sn] 为设备序列号,[remark] 为可选备注名。
|
||||
///
|
||||
/// SN 为空等输入校验由 UI 层负责(便于使用本地化文案),此处只做兜底裁剪。
|
||||
/// 返回是否绑定成功。
|
||||
Future<bool> bind(String sn, {String? remark}) =>
|
||||
_run(() => ref.read(deviceRepositoryProvider).bindDevice(
|
||||
sn.trim(),
|
||||
remark: remark?.trim(),
|
||||
));
|
||||
|
||||
/// 解绑设备。返回是否解绑成功。
|
||||
Future<bool> unbind(String sn) =>
|
||||
_run(() => ref.read(deviceRepositoryProvider).unbindDevice(sn));
|
||||
|
||||
/// 消费一次性提示,避免重复弹窗。
|
||||
void consumeAlert() {
|
||||
if (state.alert != null) {
|
||||
state = state.copyWith(alert: null, lastSuccess: state.lastSuccess);
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一执行绑定/解绑请求:处理 submitting 开关、错误文案与列表刷新。
|
||||
Future<bool> _run(Future<String?> Function() action) async {
|
||||
if (state.submitting) return false;
|
||||
state = state.copyWith(submitting: true, alert: null, lastSuccess: false);
|
||||
try {
|
||||
final error = await action();
|
||||
if (error != null) {
|
||||
state = state.copyWith(
|
||||
submitting: false,
|
||||
alert: error,
|
||||
lastSuccess: false,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// 绑定关系变化,刷新已绑定设备列表。
|
||||
ref.invalidate(myDevicesProvider);
|
||||
state = state.copyWith(submitting: false, lastSuccess: true);
|
||||
return true;
|
||||
} catch (e) {
|
||||
// 统一文案映射,禁止直接 toString()(见 AGENTS.md §12)。
|
||||
state = state.copyWith(
|
||||
submitting: false,
|
||||
alert: userMessageOf(e),
|
||||
lastSuccess: false,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 设备管理控制器 Provider(页面级,不 keepAlive)。
|
||||
final deviceManageControllerProvider =
|
||||
NotifierProvider<DeviceManageController, DeviceManageState>(
|
||||
DeviceManageController.new,
|
||||
);
|
||||
252
lib/features/device/presentation/device_manage_page.dart
Normal file
252
lib/features/device/presentation/device_manage_page.dart
Normal file
@@ -0,0 +1,252 @@
|
||||
import 'package:cupertino_ui/cupertino_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/widgets/feature_ui.dart';
|
||||
import '../../../core/widgets/settings_ui.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../data/device_providers.dart';
|
||||
import '../domain/device_models.dart';
|
||||
import 'device_manage_controller.dart';
|
||||
|
||||
/// 设备管理页(我的 → 绑定设备 → 管理)。
|
||||
///
|
||||
/// 列出当前账号已绑定的全部设备,每项尾部提供「解绑」按钮;
|
||||
/// 右上角提供「添加设备」入口跳转到绑定页。
|
||||
class DeviceManagePage extends ConsumerWidget {
|
||||
const DeviceManagePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final devicesAsync = ref.watch(myDevicesProvider);
|
||||
final action = ref.watch(deviceManageControllerProvider);
|
||||
|
||||
// 一次性提示:展示后立即消费,避免重建时重复弹窗。
|
||||
ref.listen<DeviceManageState>(deviceManageControllerProvider, (prev, next) {
|
||||
final alert = next.alert;
|
||||
if (alert == null) return;
|
||||
ref.read(deviceManageControllerProvider.notifier).consumeAlert();
|
||||
AppDialogs.alert(
|
||||
context,
|
||||
message: alert,
|
||||
confirmText: l10n.commonConfirm,
|
||||
);
|
||||
});
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (didPop) return;
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.go('/profile');
|
||||
}
|
||||
},
|
||||
child: CupertinoPageScaffold(
|
||||
navigationBar: CupertinoNavigationBar(
|
||||
middle: Text(l10n.deviceManageTitle),
|
||||
leading: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
child: const Icon(CupertinoIcons.back),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
trailing: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
child: const Icon(CupertinoIcons.add, size: 26),
|
||||
onPressed: () => context.push('/device/bind'),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
ListView(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
children: [
|
||||
SectionTitle(l10n.deviceBound),
|
||||
devicesAsync.when(
|
||||
loading: () => const CardBox(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 20),
|
||||
child: Center(child: CupertinoActivityIndicator()),
|
||||
),
|
||||
),
|
||||
error: (e, _) => CardBox(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.deviceLoadFailed,
|
||||
style: const TextStyle(fontSize: 13, color: AppColors.sub),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
// 局部错误:内联展示 + retry(见 AGENTS.md §12)。
|
||||
GestureDetector(
|
||||
onTap: () => ref.invalidate(myDevicesProvider),
|
||||
child: Text(
|
||||
l10n.commonRetry,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.green,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (devices) => devices.isEmpty
|
||||
? CardBox(
|
||||
child: Text(
|
||||
l10n.deviceNone,
|
||||
style: const TextStyle(fontSize: 13, color: AppColors.sub),
|
||||
),
|
||||
)
|
||||
: CardBox(
|
||||
padding: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < devices.length; i++) ...[
|
||||
if (i > 0) const AppDivider(),
|
||||
_DeviceRow(
|
||||
device: devices[i],
|
||||
onUnbind: () =>
|
||||
_confirmUnbind(context, ref, devices[i], l10n),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SecondaryButton(
|
||||
text: l10n.deviceManageAdd,
|
||||
icon: CupertinoIcons.add_circled,
|
||||
onPressed: () => context.push('/device/bind'),
|
||||
),
|
||||
],
|
||||
),
|
||||
// 解绑请求进行中:全屏蒙层拦截重复点击。
|
||||
if (action.submitting)
|
||||
const Positioned.fill(
|
||||
child: ColoredBox(
|
||||
color: Color(0x33000000),
|
||||
child: Center(child: CupertinoActivityIndicator(radius: 14)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 解绑二次确认,确认后调用控制器并提示结果。
|
||||
Future<void> _confirmUnbind(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
DeviceBrief device,
|
||||
AppLocalizations l10n,
|
||||
) async {
|
||||
final name = device.snName?.isNotEmpty == true ? device.snName! : device.serialno;
|
||||
final confirmed = await AppDialogs.confirm(
|
||||
context,
|
||||
title: l10n.deviceUnbindConfirmTitle,
|
||||
message: l10n.deviceUnbindConfirmMessage(name),
|
||||
confirmText: l10n.deviceUnbind,
|
||||
cancelText: l10n.commonCancel,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
final ok = await ref
|
||||
.read(deviceManageControllerProvider.notifier)
|
||||
.unbind(device.serialno);
|
||||
if (!ok || !context.mounted) return;
|
||||
await AppDialogs.alert(
|
||||
context,
|
||||
message: l10n.deviceUnbindSuccess,
|
||||
confirmText: l10n.commonConfirm,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 设备列表项:左侧图标 + 名称/SN,右侧解绑按钮。
|
||||
class _DeviceRow extends StatelessWidget {
|
||||
const _DeviceRow({required this.device, required this.onUnbind});
|
||||
|
||||
final DeviceBrief device;
|
||||
final VoidCallback onUnbind;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final title =
|
||||
device.snName?.isNotEmpty == true ? device.snName! : device.serialno;
|
||||
final subtitle = device.snModel?.isNotEmpty == true
|
||||
? '${device.serialno} · ${device.snModel}'
|
||||
: device.serialno;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.green,
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
),
|
||||
child: const Center(child: Text('📱', style: TextStyle(fontSize: 17))),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 14.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.ink,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.sub),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: onUnbind,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFEF2F2),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: AppColors.red.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Text(
|
||||
l10n.deviceUnbind,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.red,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
228
lib/features/device/presentation/device_scan_page.dart
Normal file
228
lib/features/device/presentation/device_scan_page.dart
Normal file
@@ -0,0 +1,228 @@
|
||||
import 'package:cupertino_ui/cupertino_ui.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
|
||||
import '../../../core/widgets/feature_ui.dart';
|
||||
import '../../../core/widgets/settings_ui.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
/// 二维码扫描页。
|
||||
///
|
||||
/// 支持两种识别方式:
|
||||
/// 1. 相机实时扫码([MobileScanner] 的 `onDetect` 回调);
|
||||
/// 2. 从相册选取图片后离线解析([MobileScannerController.analyzeImage])。
|
||||
///
|
||||
/// 识别成功后通过 `context.pop(code)` 把结果回传给调用方(绑定设备页)。
|
||||
class DeviceScanPage extends StatefulWidget {
|
||||
const DeviceScanPage({super.key});
|
||||
|
||||
@override
|
||||
State<DeviceScanPage> createState() => _DeviceScanPageState();
|
||||
}
|
||||
|
||||
class _DeviceScanPageState extends State<DeviceScanPage> {
|
||||
final MobileScannerController _controller = MobileScannerController(
|
||||
detectionSpeed: DetectionSpeed.noDuplicates,
|
||||
formats: const [BarcodeFormat.qrCode],
|
||||
);
|
||||
|
||||
/// 防止连续回调导致多次 pop。
|
||||
bool _handled = false;
|
||||
|
||||
/// 相册解析中,避免重复选图。
|
||||
bool _pickingImage = false;
|
||||
|
||||
bool _torchOn = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
navigationBar: CupertinoNavigationBar(
|
||||
middle: Text(l10n.scanTitle),
|
||||
leading: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
child: const Icon(CupertinoIcons.back),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
trailing: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
onPressed: _toggleTorch,
|
||||
child: Icon(
|
||||
_torchOn ? CupertinoIcons.lightbulb_fill : CupertinoIcons.lightbulb,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
MobileScanner(
|
||||
controller: _controller,
|
||||
onDetect: (capture) => _onDetect(capture, l10n),
|
||||
errorBuilder: (context, error, child) => _PermissionHint(
|
||||
message: l10n.scanPermissionDenied,
|
||||
),
|
||||
),
|
||||
// 取景框 + 提示文案
|
||||
IgnorePointer(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 230,
|
||||
height: 230,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: AppColors.green,
|
||||
width: 3,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 40),
|
||||
child: Text(
|
||||
l10n.scanHint,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 13.5,
|
||||
color: CupertinoColors.white,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_pickingImage)
|
||||
const Positioned.fill(
|
||||
child: ColoredBox(
|
||||
color: Color(0x66000000),
|
||||
child: Center(child: CupertinoActivityIndicator(radius: 14)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: SecondaryButton(
|
||||
text: l10n.scanFromGallery,
|
||||
icon: CupertinoIcons.photo_on_rectangle,
|
||||
onPressed: _pickingImage ? null : () => _pickFromGallery(l10n),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 相机识别回调:取首个非空条码值后回传并关闭页面。
|
||||
void _onDetect(BarcodeCapture capture, AppLocalizations l10n) {
|
||||
if (_handled) return;
|
||||
final code = _firstValue(capture);
|
||||
if (code == null) return;
|
||||
_handled = true;
|
||||
context.pop(code);
|
||||
}
|
||||
|
||||
/// 从相册选取图片并离线解析二维码。
|
||||
Future<void> _pickFromGallery(AppLocalizations l10n) async {
|
||||
setState(() => _pickingImage = true);
|
||||
try {
|
||||
final picked = await ImagePicker().pickImage(source: ImageSource.gallery);
|
||||
if (picked == null) return;
|
||||
|
||||
final capture = await _controller.analyzeImage(picked.path);
|
||||
final code = capture == null ? null : _firstValue(capture);
|
||||
if (!mounted) return;
|
||||
|
||||
if (code == null) {
|
||||
await AppDialogs.alert(
|
||||
context,
|
||||
message: l10n.scanNoCodeFound,
|
||||
confirmText: l10n.commonConfirm,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
_handled = true;
|
||||
context.pop(code);
|
||||
} finally {
|
||||
if (mounted) setState(() => _pickingImage = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换闪光灯,失败时静默忽略(部分设备/模拟器不支持)。
|
||||
Future<void> _toggleTorch() async {
|
||||
try {
|
||||
await _controller.toggleTorch();
|
||||
if (mounted) setState(() => _torchOn = !_torchOn);
|
||||
} catch (_) {
|
||||
// 不支持闪光灯,忽略。
|
||||
}
|
||||
}
|
||||
|
||||
/// 提取扫描结果中第一个有效的二维码文本。
|
||||
String? _firstValue(BarcodeCapture capture) {
|
||||
for (final barcode in capture.barcodes) {
|
||||
final value = barcode.rawValue;
|
||||
if (value != null && value.trim().isNotEmpty) return value.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 相机不可用(权限被拒或初始化失败)时的占位提示。
|
||||
class _PermissionHint extends StatelessWidget {
|
||||
const _PermissionHint({required this.message});
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ColoredBox(
|
||||
color: const Color(0xFF111827),
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 40),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
CupertinoIcons.video_camera,
|
||||
size: 46,
|
||||
color: CupertinoColors.white,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: CupertinoColors.white,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -195,6 +195,21 @@ class LocationController extends Notifier<LocationState> {
|
||||
stop();
|
||||
}
|
||||
|
||||
/// 重新启动连续定位(供地图「定位到本机」按钮手动触发)。
|
||||
///
|
||||
/// 若之前已停止(拿到首个结果后自动停止),可再次启动以获取最新位置;
|
||||
/// 已启动时调用仅重新注册回调、不重复初始化。
|
||||
void restart() {
|
||||
if (_plugin == null) {
|
||||
// 插件尚未初始化(极端时序下),从头走初始化流程。
|
||||
_started = false;
|
||||
_init();
|
||||
return;
|
||||
}
|
||||
_started = true;
|
||||
_plugin?.startLocation();
|
||||
}
|
||||
|
||||
/// 停止连续定位(保留插件实例,可再次 [startLocation])。
|
||||
///
|
||||
/// 本机定位拿到首个有效结果后即停止,符合「有结果且非定位错误就停止定位」的约束。
|
||||
|
||||
@@ -92,7 +92,7 @@ class HomeTab extends ConsumerWidget {
|
||||
_usageCard(l10n),
|
||||
_deviceOpsCard(context, l10n, ref, sn),
|
||||
SectionTitle(l10n.homeCommon),
|
||||
_commonCard(l10n),
|
||||
_commonCard(context, ref, l10n, sn),
|
||||
]),
|
||||
),
|
||||
),
|
||||
@@ -647,13 +647,23 @@ class HomeTab extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _commonCard(AppLocalizations l10n) => CardBox(
|
||||
Widget _commonCard(BuildContext context, WidgetRef ref, AppLocalizations l10n, String? sn) => CardBox(
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: IconTile(emoji: '📁', color: AppColors.blue, label: l10n.cfFileTransfer)),
|
||||
Expanded(child: IconTile(emoji: '📷', color: AppColors.green, label: l10n.cfRemoteCam)),
|
||||
Expanded(
|
||||
child: IconTile(
|
||||
emoji: '📷',
|
||||
color: AppColors.green,
|
||||
label: l10n.cfRemoteCam,
|
||||
size: 50,
|
||||
iconSize: 20,
|
||||
onTap: () => _runDeviceOp(context, ref, l10n, sn, 'take_photo',
|
||||
refreshScreenshots: true),
|
||||
),
|
||||
),
|
||||
Expanded(child: IconTile(emoji: '💬', color: AppColors.orange, label: l10n.cfMsgSync)),
|
||||
Expanded(child: IconTile(emoji: '🧹', color: AppColors.purple, label: l10n.cfClean)),
|
||||
],
|
||||
|
||||
@@ -27,6 +27,8 @@ class MapPage extends ConsumerStatefulWidget {
|
||||
class _MapPageState extends ConsumerState<MapPage> {
|
||||
BMFMapController? _mapController;
|
||||
bool _markerAdded = false;
|
||||
// 是否正在「定位到我」:点击定位按钮后置 true,拿到本机定位后归 false。
|
||||
bool _isLocatingSelf = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -55,13 +57,19 @@ class _MapPageState extends ConsumerState<MapPage> {
|
||||
);
|
||||
|
||||
// 监听本机定位结果:定位成功后把蓝色定位点更新到地图(BMFUserLocation)。
|
||||
// 若正处于「定位到我」流程,则把地图中心同步到本机位置并复位标记。
|
||||
ref.listen<LocationState>(
|
||||
locationControllerProvider,
|
||||
(prev, next) {
|
||||
if (next is LocationSuccess) {
|
||||
_updateUserLocation(next.location);
|
||||
if (_isLocatingSelf) {
|
||||
_moveToUserLocation(next.location);
|
||||
setState(() => _isLocatingSelf = false);
|
||||
}
|
||||
} else if (next is LocationFailed) {
|
||||
debugPrint('[Location] 全屏地图:定位失败 -> ${next.message}');
|
||||
if (_isLocatingSelf) setState(() => _isLocatingSelf = false);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -86,6 +94,7 @@ class _MapPageState extends ConsumerState<MapPage> {
|
||||
),
|
||||
),
|
||||
_buildOverlay(locationAsync, l10n),
|
||||
_buildLocateButton(l10n),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -141,6 +150,63 @@ class _MapPageState extends ConsumerState<MapPage> {
|
||||
controller.updateLocationData(userLocation);
|
||||
}
|
||||
|
||||
/// 把地图中心移动到本机定位位置(不带动画也行,这里用动画平滑过渡)。
|
||||
void _moveToUserLocation(DeviceCurrentLocation location) {
|
||||
final controller = _mapController;
|
||||
if (controller == null) return;
|
||||
controller.setCenterCoordinate(
|
||||
BMFCoordinate(location.latitude, location.longitude),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
/// 右下角「定位到我」浮动按钮:点击后重新启动本机定位,
|
||||
/// 定位成功会在 listen 回调中把地图中心移到本机位置。
|
||||
Widget _buildLocateButton(AppLocalizations l10n) {
|
||||
final isLocating = _isLocatingSelf;
|
||||
return Positioned(
|
||||
right: 6,
|
||||
bottom: 100,
|
||||
child: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
onPressed: isLocating ? null : _onLocateMe,
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: CupertinoColors.systemBackground.withValues(alpha: 0.92),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x33000000),
|
||||
blurRadius: 6,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: isLocating
|
||||
? const CupertinoActivityIndicator(radius: 11)
|
||||
: const Icon(
|
||||
CupertinoIcons.location_fill,
|
||||
size: 22,
|
||||
color: CupertinoColors.activeBlue,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 点击「定位到我」:标记进入定位中,重新启动手机本机定位。
|
||||
void _onLocateMe() {
|
||||
// 若已经拿到过本机位置,先立即把中心移过去(定位回调通常很快,提升即时反馈)。
|
||||
final current = ref.read(locationControllerProvider);
|
||||
if (current is LocationSuccess) {
|
||||
_moveToUserLocation(current.location);
|
||||
}
|
||||
setState(() => _isLocatingSelf = true);
|
||||
ref.read(locationControllerProvider.notifier).restart();
|
||||
}
|
||||
|
||||
/// 根据最新定位刷新地图标记与中心。
|
||||
void _syncLocation(DeviceLocation? location) {
|
||||
final controller = _mapController;
|
||||
|
||||
@@ -1,41 +1,38 @@
|
||||
import 'package:cupertino_ui/cupertino_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../../../core/utils/time_format.dart';
|
||||
import '../../../core/widgets/feature_ui.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../../device/data/device_providers.dart';
|
||||
import '../../device/domain/device_models.dart';
|
||||
|
||||
/// 截图查看页。
|
||||
///
|
||||
/// 单张居中展示设备截图,可通过左右滑动(PageView)切换上一张 / 下一张,
|
||||
/// 顶部展示当前序号与总数,底部展示截图时间。从首页截图卡片的"查看全部"
|
||||
/// 或点击某张缩略图进入,并定位到对应索引。
|
||||
class ScreenshotPage extends StatefulWidget {
|
||||
class ScreenshotPage extends ConsumerStatefulWidget {
|
||||
const ScreenshotPage({
|
||||
super.key,
|
||||
required this.screenshots,
|
||||
required this.initialIndex,
|
||||
required this.sn,
|
||||
this.initialIndex = 0,
|
||||
});
|
||||
|
||||
/// 全部截图列表。
|
||||
final List<ScreenshotVO> screenshots;
|
||||
|
||||
/// 初始展示的截图索引。
|
||||
final String sn;
|
||||
final int initialIndex;
|
||||
|
||||
@override
|
||||
State<ScreenshotPage> createState() => _ScreenshotPageState();
|
||||
ConsumerState<ScreenshotPage> createState() => _ScreenshotPageState();
|
||||
}
|
||||
|
||||
class _ScreenshotPageState extends State<ScreenshotPage> {
|
||||
class _ScreenshotPageState extends ConsumerState<ScreenshotPage> {
|
||||
late final PageController _pageController;
|
||||
late List<ScreenshotVO> _screenshots;
|
||||
late int _currentIndex;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentIndex = widget.initialIndex;
|
||||
_pageController = PageController(initialPage: widget.initialIndex);
|
||||
_screenshots = List<ScreenshotVO>.from(widget.screenshots);
|
||||
_currentIndex = widget.initialIndex.clamp(0, _screenshots.length - 1);
|
||||
_pageController = PageController(initialPage: _currentIndex);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -44,66 +41,297 @@ class _ScreenshotPageState extends State<ScreenshotPage> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 刷新截图列表:本地移除后立即失效源 Provider,
|
||||
/// 返回首页时最近截图列表会自动重新拉取。
|
||||
Future<void> _refreshSource() async {
|
||||
ref.invalidate(recentScreenshotsProvider(widget.sn));
|
||||
}
|
||||
|
||||
void _deleteCurrent() {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
showCupertinoDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => CupertinoAlertDialog(
|
||||
title: Text(l10n.homeScreenshotDeleteCurrent),
|
||||
content: Text(l10n.homeScreenshotDeleteConfirm),
|
||||
actions: [
|
||||
CupertinoDialogAction(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: Text(l10n.cancel),
|
||||
),
|
||||
CupertinoDialogAction(
|
||||
isDestructiveAction: true,
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: Text(l10n.homeScreenshotDelete),
|
||||
),
|
||||
],
|
||||
),
|
||||
).then((confirmed) async {
|
||||
if (confirmed != true) return;
|
||||
final target = _screenshots[_currentIndex];
|
||||
final id = target.id;
|
||||
if (id == null) return;
|
||||
|
||||
// 先调用后台 client 接口删除单条截图。
|
||||
final err = await ref
|
||||
.read(deviceRepositoryProvider)
|
||||
.deleteScreenshot(widget.sn, id);
|
||||
if (!mounted) return;
|
||||
if (err != null) {
|
||||
_showError(l10n, err);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_screenshots.removeAt(_currentIndex);
|
||||
if (_currentIndex > 0) _currentIndex -= 1;
|
||||
});
|
||||
_refreshSource();
|
||||
|
||||
if (_screenshots.isEmpty) {
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
return;
|
||||
}
|
||||
final safeIndex = _currentIndex.clamp(0, _screenshots.length - 1);
|
||||
if (safeIndex != _pageController.page?.round()) {
|
||||
_pageController.jumpToPage(safeIndex);
|
||||
}
|
||||
|
||||
if (mounted) _toast(l10n.homeScreenshotDeleted);
|
||||
});
|
||||
}
|
||||
|
||||
void _clearAll() {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
showCupertinoDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => CupertinoAlertDialog(
|
||||
title: Text(l10n.homeScreenshotClearAll),
|
||||
content: Text(l10n.homeScreenshotClearConfirm),
|
||||
actions: [
|
||||
CupertinoDialogAction(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: Text(l10n.cancel),
|
||||
),
|
||||
CupertinoDialogAction(
|
||||
isDestructiveAction: true,
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: Text(l10n.homeScreenshotClearAll),
|
||||
),
|
||||
],
|
||||
),
|
||||
).then((confirmed) async {
|
||||
if (confirmed != true) return;
|
||||
|
||||
if (_screenshots.isEmpty) return;
|
||||
|
||||
// 先调用后台 client 接口清空全部截图。
|
||||
final err = await ref
|
||||
.read(deviceRepositoryProvider)
|
||||
.clearScreenshots(widget.sn);
|
||||
if (!mounted) return;
|
||||
if (err != null) {
|
||||
_showError(l10n, err);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_screenshots.clear();
|
||||
});
|
||||
_refreshSource();
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
_toast(l10n.homeScreenshotCleared);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _showError(AppLocalizations l10n, String message) {
|
||||
if (!mounted) return;
|
||||
showCupertinoDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => CupertinoAlertDialog(
|
||||
content: Text(message),
|
||||
actions: [
|
||||
CupertinoDialogAction(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: Text(l10n.commonConfirm),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _toast(String message) {
|
||||
final overlay = Overlay.of(context);
|
||||
if (overlay == null) return;
|
||||
final entry = OverlayEntry(
|
||||
builder: (ctx) => Positioned(
|
||||
bottom: 90,
|
||||
left: 24,
|
||||
right: 24,
|
||||
child: IgnorePointer(
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: CupertinoColors.systemGrey.withValues(alpha: 0.92),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
message,
|
||||
style: const TextStyle(color: CupertinoColors.white),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
overlay.insert(entry);
|
||||
Future.delayed(const Duration(seconds: 2), () => entry.remove());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final list = widget.screenshots;
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
backgroundColor: CupertinoColors.black,
|
||||
navigationBar: CupertinoNavigationBar(
|
||||
middle: Text(
|
||||
'${l10n.homeScreenshots} · ${_currentIndex + 1}/${list.length}',
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
backgroundColor: CupertinoColors.black.withValues(alpha: 0.85),
|
||||
brightness: Brightness.dark,
|
||||
middle: _screenshots.isEmpty
|
||||
? null
|
||||
: Text(
|
||||
'${_currentIndex + 1} / ${_screenshots.length}',
|
||||
style: const TextStyle(color: CupertinoColors.white),
|
||||
),
|
||||
leading: CupertinoNavigationBarBackButton(
|
||||
color: CupertinoColors.white,
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
previousPageTitle: l10n.back,
|
||||
trailing: _screenshots.isNotEmpty
|
||||
? CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
onPressed: _clearAll,
|
||||
child: Text(
|
||||
l10n.homeScreenshotClearAll,
|
||||
style: const TextStyle(
|
||||
color: CupertinoColors.white,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
itemCount: list.length,
|
||||
onPageChanged: (i) => setState(() => _currentIndex = i),
|
||||
itemBuilder: (_, i) {
|
||||
final item = list[i];
|
||||
return Center(
|
||||
child: InteractiveViewer(
|
||||
maxScale: 4,
|
||||
child: Image.network(
|
||||
item.url ?? '',
|
||||
fit: BoxFit.contain,
|
||||
loadingBuilder: (ctx, child, progress) => progress == null
|
||||
? child
|
||||
: const Center(
|
||||
child: CupertinoActivityIndicator(radius: 14)),
|
||||
errorBuilder: (ctx, err, _) => const Center(
|
||||
child: Icon(CupertinoIcons.photo,
|
||||
size: 48, color: AppColors.sub),
|
||||
),
|
||||
child: _screenshots.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
l10n.homeScreenshotEmpty,
|
||||
style:
|
||||
const TextStyle(color: CupertinoColors.systemGrey),
|
||||
),
|
||||
)
|
||||
: PageView.builder(
|
||||
controller: _pageController,
|
||||
itemCount: _screenshots.length,
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
_currentIndex = index;
|
||||
});
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final item = _screenshots[index];
|
||||
final url = item.url;
|
||||
return InteractiveViewer(
|
||||
child: Center(
|
||||
child: url != null
|
||||
? Image.network(
|
||||
url,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (ctx, error, stack) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
CupertinoIcons.photo,
|
||||
size: 64,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.commonLoadFailed,
|
||||
style: const TextStyle(
|
||||
color:
|
||||
CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
CupertinoIcons.photo,
|
||||
size: 64,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (_screenshots.isNotEmpty)
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: CupertinoColors.black.withValues(alpha: 0.85),
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: CupertinoColors.separator
|
||||
.withValues(alpha: 0.3),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
child: CupertinoButton(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
color: CupertinoColors.destructiveRed,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
onPressed: _deleteCurrent,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
CupertinoIcons.delete,
|
||||
color: CupertinoColors.white,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.homeScreenshotDelete,
|
||||
style: const TextStyle(
|
||||
color: CupertinoColors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 底部:截图时间信息(统一格式化时间戳)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Builder(builder: (context) {
|
||||
final formatted =
|
||||
formatTimeString(list[_currentIndex].uploadTime);
|
||||
return Text(
|
||||
formatted.isNotEmpty
|
||||
? '🕐 $formatted'
|
||||
: (list[_currentIndex].fileName ?? ''),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style:
|
||||
const TextStyle(fontSize: 13, color: AppColors.sub),
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -76,7 +76,7 @@ class MessagesTab extends ConsumerWidget {
|
||||
),
|
||||
child: SafeArea(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(bottom: 96),
|
||||
padding: const EdgeInsets.only(bottom: 100),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 10, 18, 4),
|
||||
@@ -85,9 +85,10 @@ class MessagesTab extends ConsumerWidget {
|
||||
),
|
||||
// 横版菜单
|
||||
SizedBox(
|
||||
height: 96,
|
||||
height: 108,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
clipBehavior: Clip.none,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 12),
|
||||
itemCount: menus.length,
|
||||
@@ -95,11 +96,12 @@ class MessagesTab extends ConsumerWidget {
|
||||
final m = menus[i];
|
||||
return Container(
|
||||
width: 80,
|
||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: CupertinoColors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: const [
|
||||
BoxShadow(color: Color(0x14141E3C), blurRadius: 14, offset: Offset(0, 4)),
|
||||
BoxShadow(color: Color(0x14141E3C), blurRadius: 12, offset: Offset(0, 4)),
|
||||
],
|
||||
),
|
||||
child: IconTile(
|
||||
|
||||
@@ -77,8 +77,8 @@ class ProfileTab extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
|
||||
SectionTitle(l10n.deviceBound),
|
||||
_deviceListCard(ref, l10n),
|
||||
SectionTitle(l10n.deviceBound, action: l10n.deviceManage),
|
||||
_deviceListCard(context, ref, l10n),
|
||||
|
||||
SectionTitle(l10n.profileInfo),
|
||||
CardBox(
|
||||
@@ -103,7 +103,8 @@ class ProfileTab extends ConsumerWidget {
|
||||
const AppDivider(),
|
||||
_settingRow('🔒', const Color(0xFF22C55E), l10n.setPrivacy, '定位、相册、权限管理'),
|
||||
const AppDivider(),
|
||||
_settingRow('🛡️', AppColors.red, l10n.setSecurity, '登录设备、密码'),
|
||||
_settingRow('🛡️', AppColors.red, l10n.setSecurity, '登录设备、密码',
|
||||
onTap: () => context.push('/security')),
|
||||
const AppDivider(),
|
||||
_settingRow('❓', AppColors.gray, l10n.setHelp, null),
|
||||
const AppDivider(),
|
||||
@@ -139,7 +140,8 @@ class ProfileTab extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _deviceListCard(WidgetRef ref, AppLocalizations l10n) {
|
||||
Widget _deviceListCard(
|
||||
BuildContext context, WidgetRef ref, AppLocalizations l10n) {
|
||||
final devicesAsync = ref.watch(myDevicesProvider);
|
||||
return CardBox(
|
||||
padding: EdgeInsets.zero,
|
||||
@@ -160,17 +162,34 @@ class ProfileTab extends ConsumerWidget {
|
||||
),
|
||||
data: (devices) {
|
||||
if (devices.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(l10n.deviceNone,
|
||||
style: const TextStyle(fontSize: 13, color: AppColors.sub)),
|
||||
// 无绑定设备时也可点击进入设备管理页添加设备。
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => context.push('/device/manage'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(l10n.deviceNone,
|
||||
style: const TextStyle(fontSize: 13, color: AppColors.sub)),
|
||||
),
|
||||
Text('${l10n.deviceManageAdd} ›',
|
||||
style: const TextStyle(fontSize: 13, color: AppColors.green)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
for (var i = 0; i < devices.length; i++) ...[
|
||||
if (i > 0) const AppDivider(),
|
||||
_deviceCard(devices[i], l10n),
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => context.push('/device/manage'),
|
||||
child: _deviceCard(devices[i], l10n),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
@@ -238,7 +257,9 @@ class ProfileTab extends ConsumerWidget {
|
||||
),
|
||||
);
|
||||
|
||||
Widget _settingRow(String emoji, Color color, String label, String? sub) => Padding(
|
||||
Widget _settingRow(String emoji, Color color, String label, String? sub,
|
||||
{VoidCallback? onTap}) {
|
||||
final row = Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -265,4 +286,11 @@ class ProfileTab extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
);
|
||||
if (onTap == null) return row;
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: row,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,6 +548,120 @@ abstract class AppLocalizations {
|
||||
/// **'联系人'**
|
||||
String get contactsTitle;
|
||||
|
||||
/// 联系人表单:姓名
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'姓名'**
|
||||
String get contactName;
|
||||
|
||||
/// 联系人表单:昵称
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'昵称(可选)'**
|
||||
String get contactNick;
|
||||
|
||||
/// 联系人表单:手机号
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'手机号'**
|
||||
String get contactPhone;
|
||||
|
||||
/// 联系人表单:是否紧急联系人
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'设为紧急/常用联系人'**
|
||||
String get contactEmergency;
|
||||
|
||||
/// 联系人列表为空提示
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'暂无联系人'**
|
||||
String get contactListEmpty;
|
||||
|
||||
/// 未绑定设备时的提示
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'请先在「我的设备」中绑定设备'**
|
||||
String get contactNoDevice;
|
||||
|
||||
/// 新增联系人成功提示
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'已添加联系人'**
|
||||
String get contactAdded;
|
||||
|
||||
/// 修改联系人成功提示
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'已保存'**
|
||||
String get contactUpdated;
|
||||
|
||||
/// 删除联系人成功提示
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'已删除'**
|
||||
String get contactDeleted;
|
||||
|
||||
/// 姓名校验失败
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'请输入姓名'**
|
||||
String get nameRequired;
|
||||
|
||||
/// 手机号校验失败
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'请输入手机号'**
|
||||
String get phoneRequired;
|
||||
|
||||
/// 删除联系人二次确认
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'确定删除联系人「{name}」吗?'**
|
||||
String deleteConfirm(Object name);
|
||||
|
||||
/// 新增联系人按钮
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'新增联系人'**
|
||||
String get commonAdd;
|
||||
|
||||
/// 联系人详情页标题
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'联系人详情'**
|
||||
String get contactDetailTitle;
|
||||
|
||||
/// 详情页:紧急联系人标签
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'紧急联系人'**
|
||||
String get contactIsEmergency;
|
||||
|
||||
/// 详情页:紧急联系人状态-是
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'是'**
|
||||
String get contactEmergencyYes;
|
||||
|
||||
/// 详情页:紧急联系人状态-否
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'否'**
|
||||
String get contactEmergencyNo;
|
||||
|
||||
/// 编辑联系人标题
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'编辑联系人'**
|
||||
String get commonEdit;
|
||||
|
||||
/// 删除联系人按钮
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'删除'**
|
||||
String get commonDelete;
|
||||
|
||||
/// 联系人分组:常用
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
@@ -674,6 +788,18 @@ abstract class AppLocalizations {
|
||||
/// **'定位精度约 10 米'**
|
||||
String get mapAccuracy;
|
||||
|
||||
/// 地图页定位到本机位置按钮
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'定位到我'**
|
||||
String get mapLocateMe;
|
||||
|
||||
/// 地图页正在定位本机位置提示
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'正在定位我的位置…'**
|
||||
String get mapLocatingSelf;
|
||||
|
||||
/// 首页最近截图卡片
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
@@ -698,6 +824,54 @@ abstract class AppLocalizations {
|
||||
/// **'暂无截图'**
|
||||
String get homeNoScreenshot;
|
||||
|
||||
/// 截图页右上角清空全部截图
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'清空截图'**
|
||||
String get homeScreenshotClearAll;
|
||||
|
||||
/// 删除单条截图按钮
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'删除'**
|
||||
String get homeScreenshotDelete;
|
||||
|
||||
/// 删除当前截图按钮
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'删除这张截图'**
|
||||
String get homeScreenshotDeleteCurrent;
|
||||
|
||||
/// 删除单条截图确认弹窗文案
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'确认删除这张截图吗?'**
|
||||
String get homeScreenshotDeleteConfirm;
|
||||
|
||||
/// 清空全部截图确认弹窗文案
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'确认清空所有截图吗?'**
|
||||
String get homeScreenshotClearConfirm;
|
||||
|
||||
/// 删除单条截图后的提示
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'已删除'**
|
||||
String get homeScreenshotDeleted;
|
||||
|
||||
/// 清空全部截图后的提示
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'已清空全部截图'**
|
||||
String get homeScreenshotCleared;
|
||||
|
||||
/// 截图全部删除后的空态提示
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'截图已全部删除'**
|
||||
String get homeScreenshotEmpty;
|
||||
|
||||
/// 首页使用时长卡片
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
@@ -985,6 +1159,426 @@ abstract class AppLocalizations {
|
||||
/// In zh, this message translates to:
|
||||
/// **'关于'**
|
||||
String get setAbout;
|
||||
|
||||
/// 设备管理页标题
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'设备管理'**
|
||||
String get deviceManageTitle;
|
||||
|
||||
/// 设备管理页:添加设备入口
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'添加设备'**
|
||||
String get deviceManageAdd;
|
||||
|
||||
/// 设备列表项解绑按钮
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'解绑'**
|
||||
String get deviceUnbind;
|
||||
|
||||
/// 解绑确认弹窗标题
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'确认解绑'**
|
||||
String get deviceUnbindConfirmTitle;
|
||||
|
||||
/// 解绑确认弹窗内容
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'解绑后将无法继续查看和管理该设备({name}),是否继续?'**
|
||||
String deviceUnbindConfirmMessage(String name);
|
||||
|
||||
/// 解绑成功提示
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'设备已解绑'**
|
||||
String get deviceUnbindSuccess;
|
||||
|
||||
/// 绑定设备页标题
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'绑定设备'**
|
||||
String get deviceBindTitle;
|
||||
|
||||
/// 绑定设备页 SN 输入框标签
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'设备序列号(SN)'**
|
||||
String get deviceBindSnLabel;
|
||||
|
||||
/// 绑定设备页 SN 输入框占位符
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'请输入设备机身或包装上的 SN'**
|
||||
String get deviceBindSnHint;
|
||||
|
||||
/// 绑定设备页备注输入框标签
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'备注名(可选)'**
|
||||
String get deviceBindRemarkLabel;
|
||||
|
||||
/// 绑定设备页备注输入框占位符
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'如:妈妈的平板'**
|
||||
String get deviceBindRemarkHint;
|
||||
|
||||
/// 绑定设备页确认按钮
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'确认绑定'**
|
||||
String get deviceBindConfirm;
|
||||
|
||||
/// 绑定设备页扫码按钮
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'扫码绑定'**
|
||||
String get deviceBindScan;
|
||||
|
||||
/// SN 为空校验提示
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'请输入设备序列号'**
|
||||
String get deviceBindSnEmpty;
|
||||
|
||||
/// 绑定成功提示
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'设备绑定成功'**
|
||||
String get deviceBindSuccess;
|
||||
|
||||
/// 绑定设备页说明文案
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'SN 通常位于设备背面标签或设置-关于本机中。'**
|
||||
String get deviceBindTip;
|
||||
|
||||
/// 扫码页标题
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'扫描二维码'**
|
||||
String get scanTitle;
|
||||
|
||||
/// 扫码页取景框提示
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'将设备二维码放入框内,即可自动识别'**
|
||||
String get scanHint;
|
||||
|
||||
/// 扫码页相册按钮
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'从相册选取'**
|
||||
String get scanFromGallery;
|
||||
|
||||
/// 扫码页闪光灯按钮
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'闪光灯'**
|
||||
String get scanTorch;
|
||||
|
||||
/// 相册图片未识别到二维码提示
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'未识别到二维码,请重试'**
|
||||
String get scanNoCodeFound;
|
||||
|
||||
/// 相机权限被拒提示
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'无法访问相机,请在系统设置中开启权限'**
|
||||
String get scanPermissionDenied;
|
||||
|
||||
/// 账号与安全页标题
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'账号与安全'**
|
||||
String get securityTitle;
|
||||
|
||||
/// 账号与安全:登录设备入口
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'登录设备'**
|
||||
String get securityLoginDevices;
|
||||
|
||||
/// 账号与安全:登录设备副标题
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'查看并管理已登录的终端'**
|
||||
String get securityLoginDevicesSub;
|
||||
|
||||
/// 账号与安全:修改密码入口
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'修改密码'**
|
||||
String get securityChangePassword;
|
||||
|
||||
/// 账号与安全:修改密码副标题
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'定期更换可提升账号安全性'**
|
||||
String get securityChangePasswordSub;
|
||||
|
||||
/// 账号与安全:更换手机入口
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'更换手机'**
|
||||
String get securityChangeMobile;
|
||||
|
||||
/// 账号与安全:更换手机副标题
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'用于登录与接收验证码'**
|
||||
String get securityChangeMobileSub;
|
||||
|
||||
/// 账号与安全:微信绑定入口
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'微信绑定'**
|
||||
String get securityWechat;
|
||||
|
||||
/// 账号与安全:微信绑定副标题
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'绑定后可使用微信快捷登录'**
|
||||
String get securityWechatSub;
|
||||
|
||||
/// 登录设备页标题
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'登录设备'**
|
||||
String get loginDevicesTitle;
|
||||
|
||||
/// 登录设备列表为空
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'暂无登录设备记录'**
|
||||
String get loginDevicesEmpty;
|
||||
|
||||
/// 当前设备标记
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'本机'**
|
||||
String get loginDevicesCurrent;
|
||||
|
||||
/// 登录设备下线按钮
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'下线'**
|
||||
String get loginDevicesRevoke;
|
||||
|
||||
/// 下线确认提示
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'确认让该设备退出登录?'**
|
||||
String get loginDevicesRevokeConfirm;
|
||||
|
||||
/// 下线成功提示
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'该设备已下线'**
|
||||
String get loginDevicesRevokeSuccess;
|
||||
|
||||
/// 修改密码页标题
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'修改密码'**
|
||||
String get changePasswordTitle;
|
||||
|
||||
/// 修改密码:原密码
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'当前密码'**
|
||||
String get changePasswordOld;
|
||||
|
||||
/// 修改密码:新密码
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'新密码'**
|
||||
String get changePasswordNew;
|
||||
|
||||
/// 修改密码:确认新密码
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'确认新密码'**
|
||||
String get changePasswordConfirmField;
|
||||
|
||||
/// 修改密码:规则说明
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'密码需 8-20 位,包含字母和数字'**
|
||||
String get changePasswordRule;
|
||||
|
||||
/// 修改密码:两次不一致
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'两次输入的新密码不一致'**
|
||||
String get changePasswordMismatch;
|
||||
|
||||
/// 修改密码:格式不合规
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'新密码不符合规则,请重新输入'**
|
||||
String get changePasswordInvalid;
|
||||
|
||||
/// 修改密码成功提示
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'密码修改成功'**
|
||||
String get changePasswordSuccess;
|
||||
|
||||
/// 更换手机页标题
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'更换手机'**
|
||||
String get changeMobileTitle;
|
||||
|
||||
/// 更换手机:当前号码
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'当前手机号'**
|
||||
String get changeMobileCurrent;
|
||||
|
||||
/// 更换手机:新号码
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'新手机号'**
|
||||
String get changeMobileNew;
|
||||
|
||||
/// 更换手机:验证码
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'验证码'**
|
||||
String get changeMobileCode;
|
||||
|
||||
/// 更换手机:密码校验
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'账号密码'**
|
||||
String get changeMobilePassword;
|
||||
|
||||
/// 更换手机:发送验证码
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'获取验证码'**
|
||||
String get changeMobileSendCode;
|
||||
|
||||
/// 更换手机:倒计时重发
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'{seconds}s 后重发'**
|
||||
String changeMobileResendIn(int seconds);
|
||||
|
||||
/// 更换手机:号码格式错误
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'请输入正确的手机号'**
|
||||
String get changeMobileInvalid;
|
||||
|
||||
/// 更换手机:验证码为空
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'请输入验证码'**
|
||||
String get changeMobileCodeEmpty;
|
||||
|
||||
/// 更换手机:密码为空
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'请输入账号密码'**
|
||||
String get changeMobilePasswordEmpty;
|
||||
|
||||
/// 更换手机成功提示
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'手机号更换成功'**
|
||||
String get changeMobileSuccess;
|
||||
|
||||
/// 微信绑定页标题
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'微信绑定'**
|
||||
String get wechatTitle;
|
||||
|
||||
/// 微信已绑定状态
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'已绑定'**
|
||||
String get wechatBound;
|
||||
|
||||
/// 微信未绑定状态
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'未绑定'**
|
||||
String get wechatUnbound;
|
||||
|
||||
/// 绑定微信按钮
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'绑定微信'**
|
||||
String get wechatBind;
|
||||
|
||||
/// 解绑微信按钮
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'解除绑定'**
|
||||
String get wechatUnbind;
|
||||
|
||||
/// 解绑微信确认提示
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'解绑后将无法使用微信快捷登录,是否继续?'**
|
||||
String get wechatUnbindConfirm;
|
||||
|
||||
/// 绑定微信成功提示
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'微信绑定成功'**
|
||||
String get wechatBindSuccess;
|
||||
|
||||
/// 解绑微信成功提示
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'微信已解绑'**
|
||||
String get wechatUnbindSuccess;
|
||||
|
||||
/// 微信 SDK 未接入提示
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'微信登录暂不可用,请稍后重试'**
|
||||
String get wechatSdkUnavailable;
|
||||
|
||||
/// 通用:确定
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'确定'**
|
||||
String get commonConfirm;
|
||||
|
||||
/// 通用:取消
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'取消'**
|
||||
String get commonCancel;
|
||||
|
||||
/// 通用:保存
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'保存'**
|
||||
String get commonSave;
|
||||
|
||||
/// 通用:重试
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'重试'**
|
||||
String get commonRetry;
|
||||
|
||||
/// 通用:加载失败
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'加载失败'**
|
||||
String get commonLoadFailed;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -236,6 +236,65 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get contactsTitle => 'Contacts';
|
||||
|
||||
@override
|
||||
String get contactName => 'Name';
|
||||
|
||||
@override
|
||||
String get contactNick => 'Nickname (optional)';
|
||||
|
||||
@override
|
||||
String get contactPhone => 'Phone';
|
||||
|
||||
@override
|
||||
String get contactEmergency => 'Mark as emergency/favorite contact';
|
||||
|
||||
@override
|
||||
String get contactListEmpty => 'No contacts yet';
|
||||
|
||||
@override
|
||||
String get contactNoDevice => 'Please bind a device in \"My Device\" first';
|
||||
|
||||
@override
|
||||
String get contactAdded => 'Contact added';
|
||||
|
||||
@override
|
||||
String get contactUpdated => 'Saved';
|
||||
|
||||
@override
|
||||
String get contactDeleted => 'Deleted';
|
||||
|
||||
@override
|
||||
String get nameRequired => 'Please enter a name';
|
||||
|
||||
@override
|
||||
String get phoneRequired => 'Please enter a phone number';
|
||||
|
||||
@override
|
||||
String deleteConfirm(Object name) {
|
||||
return 'Delete contact \"$name\"?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get commonAdd => 'Add Contact';
|
||||
|
||||
@override
|
||||
String get contactDetailTitle => 'Contact Details';
|
||||
|
||||
@override
|
||||
String get contactIsEmergency => 'Emergency Contact';
|
||||
|
||||
@override
|
||||
String get contactEmergencyYes => 'Yes';
|
||||
|
||||
@override
|
||||
String get contactEmergencyNo => 'No';
|
||||
|
||||
@override
|
||||
String get commonEdit => 'Edit Contact';
|
||||
|
||||
@override
|
||||
String get commonDelete => 'Delete';
|
||||
|
||||
@override
|
||||
String get grpFavorite => '★ Favorites';
|
||||
|
||||
@@ -301,6 +360,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get mapAccuracy => 'Location accuracy approx. 10 m';
|
||||
|
||||
@override
|
||||
String get mapLocateMe => 'Locate Me';
|
||||
|
||||
@override
|
||||
String get mapLocatingSelf => 'Locating my position…';
|
||||
|
||||
@override
|
||||
String get homeScreenshots => 'Recent Screenshots';
|
||||
|
||||
@@ -313,6 +378,30 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get homeNoScreenshot => 'No screenshots yet';
|
||||
|
||||
@override
|
||||
String get homeScreenshotClearAll => 'Clear';
|
||||
|
||||
@override
|
||||
String get homeScreenshotDelete => 'Delete';
|
||||
|
||||
@override
|
||||
String get homeScreenshotDeleteCurrent => 'Delete this screenshot';
|
||||
|
||||
@override
|
||||
String get homeScreenshotDeleteConfirm => 'Delete this screenshot?';
|
||||
|
||||
@override
|
||||
String get homeScreenshotClearConfirm => 'Clear all screenshots?';
|
||||
|
||||
@override
|
||||
String get homeScreenshotDeleted => 'Deleted';
|
||||
|
||||
@override
|
||||
String get homeScreenshotCleared => 'All screenshots cleared';
|
||||
|
||||
@override
|
||||
String get homeScreenshotEmpty => 'All screenshots deleted';
|
||||
|
||||
@override
|
||||
String get homeUsage => 'Today\'s Usage';
|
||||
|
||||
@@ -457,4 +546,227 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get setAbout => 'About';
|
||||
|
||||
@override
|
||||
String get deviceManageTitle => 'Device Management';
|
||||
|
||||
@override
|
||||
String get deviceManageAdd => 'Add Device';
|
||||
|
||||
@override
|
||||
String get deviceUnbind => 'Unbind';
|
||||
|
||||
@override
|
||||
String get deviceUnbindConfirmTitle => 'Confirm Unbind';
|
||||
|
||||
@override
|
||||
String deviceUnbindConfirmMessage(String name) {
|
||||
return 'After unbinding, you will no longer be able to view or manage this device ($name). Continue?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get deviceUnbindSuccess => 'Device unbound';
|
||||
|
||||
@override
|
||||
String get deviceBindTitle => 'Bind Device';
|
||||
|
||||
@override
|
||||
String get deviceBindSnLabel => 'Serial Number (SN)';
|
||||
|
||||
@override
|
||||
String get deviceBindSnHint => 'Enter the SN on the device or package';
|
||||
|
||||
@override
|
||||
String get deviceBindRemarkLabel => 'Remark (optional)';
|
||||
|
||||
@override
|
||||
String get deviceBindRemarkHint => 'e.g. Mom\'s tablet';
|
||||
|
||||
@override
|
||||
String get deviceBindConfirm => 'Confirm Binding';
|
||||
|
||||
@override
|
||||
String get deviceBindScan => 'Scan QR Code';
|
||||
|
||||
@override
|
||||
String get deviceBindSnEmpty => 'Please enter the serial number';
|
||||
|
||||
@override
|
||||
String get deviceBindSuccess => 'Device bound successfully';
|
||||
|
||||
@override
|
||||
String get deviceBindTip =>
|
||||
'The SN is usually on the label on the back of the device or in Settings - About.';
|
||||
|
||||
@override
|
||||
String get scanTitle => 'Scan QR Code';
|
||||
|
||||
@override
|
||||
String get scanHint =>
|
||||
'Place the device QR code inside the frame to scan automatically';
|
||||
|
||||
@override
|
||||
String get scanFromGallery => 'From Gallery';
|
||||
|
||||
@override
|
||||
String get scanTorch => 'Flashlight';
|
||||
|
||||
@override
|
||||
String get scanNoCodeFound => 'No QR code detected, please try again';
|
||||
|
||||
@override
|
||||
String get scanPermissionDenied =>
|
||||
'Cannot access the camera. Please enable permission in system settings';
|
||||
|
||||
@override
|
||||
String get securityTitle => 'Account & Security';
|
||||
|
||||
@override
|
||||
String get securityLoginDevices => 'Login Devices';
|
||||
|
||||
@override
|
||||
String get securityLoginDevicesSub => 'View and manage signed-in terminals';
|
||||
|
||||
@override
|
||||
String get securityChangePassword => 'Change Password';
|
||||
|
||||
@override
|
||||
String get securityChangePasswordSub =>
|
||||
'Changing regularly improves account security';
|
||||
|
||||
@override
|
||||
String get securityChangeMobile => 'Change Phone';
|
||||
|
||||
@override
|
||||
String get securityChangeMobileSub => 'Used for login and verification codes';
|
||||
|
||||
@override
|
||||
String get securityWechat => 'WeChat Binding';
|
||||
|
||||
@override
|
||||
String get securityWechatSub => 'Bind to enable quick WeChat login';
|
||||
|
||||
@override
|
||||
String get loginDevicesTitle => 'Login Devices';
|
||||
|
||||
@override
|
||||
String get loginDevicesEmpty => 'No login records yet';
|
||||
|
||||
@override
|
||||
String get loginDevicesCurrent => 'This device';
|
||||
|
||||
@override
|
||||
String get loginDevicesRevoke => 'Sign out';
|
||||
|
||||
@override
|
||||
String get loginDevicesRevokeConfirm =>
|
||||
'Sign this device out of your account?';
|
||||
|
||||
@override
|
||||
String get loginDevicesRevokeSuccess => 'The device has been signed out';
|
||||
|
||||
@override
|
||||
String get changePasswordTitle => 'Change Password';
|
||||
|
||||
@override
|
||||
String get changePasswordOld => 'Current password';
|
||||
|
||||
@override
|
||||
String get changePasswordNew => 'New password';
|
||||
|
||||
@override
|
||||
String get changePasswordConfirmField => 'Confirm new password';
|
||||
|
||||
@override
|
||||
String get changePasswordRule =>
|
||||
'8-20 characters, with both letters and numbers';
|
||||
|
||||
@override
|
||||
String get changePasswordMismatch => 'The new passwords do not match';
|
||||
|
||||
@override
|
||||
String get changePasswordInvalid =>
|
||||
'The new password does not meet the rules';
|
||||
|
||||
@override
|
||||
String get changePasswordSuccess => 'Password changed successfully';
|
||||
|
||||
@override
|
||||
String get changeMobileTitle => 'Change Phone';
|
||||
|
||||
@override
|
||||
String get changeMobileCurrent => 'Current phone number';
|
||||
|
||||
@override
|
||||
String get changeMobileNew => 'New phone number';
|
||||
|
||||
@override
|
||||
String get changeMobileCode => 'Verification code';
|
||||
|
||||
@override
|
||||
String get changeMobilePassword => 'Account password';
|
||||
|
||||
@override
|
||||
String get changeMobileSendCode => 'Get code';
|
||||
|
||||
@override
|
||||
String changeMobileResendIn(int seconds) {
|
||||
return 'Resend in ${seconds}s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get changeMobileInvalid => 'Please enter a valid phone number';
|
||||
|
||||
@override
|
||||
String get changeMobileCodeEmpty => 'Please enter the verification code';
|
||||
|
||||
@override
|
||||
String get changeMobilePasswordEmpty => 'Please enter your account password';
|
||||
|
||||
@override
|
||||
String get changeMobileSuccess => 'Phone number changed successfully';
|
||||
|
||||
@override
|
||||
String get wechatTitle => 'WeChat Binding';
|
||||
|
||||
@override
|
||||
String get wechatBound => 'Bound';
|
||||
|
||||
@override
|
||||
String get wechatUnbound => 'Not bound';
|
||||
|
||||
@override
|
||||
String get wechatBind => 'Bind WeChat';
|
||||
|
||||
@override
|
||||
String get wechatUnbind => 'Unbind';
|
||||
|
||||
@override
|
||||
String get wechatUnbindConfirm =>
|
||||
'After unbinding you cannot use quick WeChat login. Continue?';
|
||||
|
||||
@override
|
||||
String get wechatBindSuccess => 'WeChat bound successfully';
|
||||
|
||||
@override
|
||||
String get wechatUnbindSuccess => 'WeChat unbound';
|
||||
|
||||
@override
|
||||
String get wechatSdkUnavailable =>
|
||||
'WeChat login is unavailable, please try again later';
|
||||
|
||||
@override
|
||||
String get commonConfirm => 'OK';
|
||||
|
||||
@override
|
||||
String get commonCancel => 'Cancel';
|
||||
|
||||
@override
|
||||
String get commonSave => 'Save';
|
||||
|
||||
@override
|
||||
String get commonRetry => 'Retry';
|
||||
|
||||
@override
|
||||
String get commonLoadFailed => 'Failed to load';
|
||||
}
|
||||
|
||||
@@ -235,6 +235,65 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get contactsTitle => '联系人';
|
||||
|
||||
@override
|
||||
String get contactName => '姓名';
|
||||
|
||||
@override
|
||||
String get contactNick => '昵称(可选)';
|
||||
|
||||
@override
|
||||
String get contactPhone => '手机号';
|
||||
|
||||
@override
|
||||
String get contactEmergency => '设为紧急/常用联系人';
|
||||
|
||||
@override
|
||||
String get contactListEmpty => '暂无联系人';
|
||||
|
||||
@override
|
||||
String get contactNoDevice => '请先在「我的设备」中绑定设备';
|
||||
|
||||
@override
|
||||
String get contactAdded => '已添加联系人';
|
||||
|
||||
@override
|
||||
String get contactUpdated => '已保存';
|
||||
|
||||
@override
|
||||
String get contactDeleted => '已删除';
|
||||
|
||||
@override
|
||||
String get nameRequired => '请输入姓名';
|
||||
|
||||
@override
|
||||
String get phoneRequired => '请输入手机号';
|
||||
|
||||
@override
|
||||
String deleteConfirm(Object name) {
|
||||
return '确定删除联系人「$name」吗?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get commonAdd => '新增联系人';
|
||||
|
||||
@override
|
||||
String get contactDetailTitle => '联系人详情';
|
||||
|
||||
@override
|
||||
String get contactIsEmergency => '紧急联系人';
|
||||
|
||||
@override
|
||||
String get contactEmergencyYes => '是';
|
||||
|
||||
@override
|
||||
String get contactEmergencyNo => '否';
|
||||
|
||||
@override
|
||||
String get commonEdit => '编辑联系人';
|
||||
|
||||
@override
|
||||
String get commonDelete => '删除';
|
||||
|
||||
@override
|
||||
String get grpFavorite => '★ 常用';
|
||||
|
||||
@@ -298,6 +357,12 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get mapAccuracy => '定位精度约 10 米';
|
||||
|
||||
@override
|
||||
String get mapLocateMe => '定位到我';
|
||||
|
||||
@override
|
||||
String get mapLocatingSelf => '正在定位我的位置…';
|
||||
|
||||
@override
|
||||
String get homeScreenshots => '最近截图';
|
||||
|
||||
@@ -310,6 +375,30 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get homeNoScreenshot => '暂无截图';
|
||||
|
||||
@override
|
||||
String get homeScreenshotClearAll => '清空截图';
|
||||
|
||||
@override
|
||||
String get homeScreenshotDelete => '删除';
|
||||
|
||||
@override
|
||||
String get homeScreenshotDeleteCurrent => '删除这张截图';
|
||||
|
||||
@override
|
||||
String get homeScreenshotDeleteConfirm => '确认删除这张截图吗?';
|
||||
|
||||
@override
|
||||
String get homeScreenshotClearConfirm => '确认清空所有截图吗?';
|
||||
|
||||
@override
|
||||
String get homeScreenshotDeleted => '已删除';
|
||||
|
||||
@override
|
||||
String get homeScreenshotCleared => '已清空全部截图';
|
||||
|
||||
@override
|
||||
String get homeScreenshotEmpty => '截图已全部删除';
|
||||
|
||||
@override
|
||||
String get homeUsage => '今日使用时长';
|
||||
|
||||
@@ -453,4 +542,218 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get setAbout => '关于';
|
||||
|
||||
@override
|
||||
String get deviceManageTitle => '设备管理';
|
||||
|
||||
@override
|
||||
String get deviceManageAdd => '添加设备';
|
||||
|
||||
@override
|
||||
String get deviceUnbind => '解绑';
|
||||
|
||||
@override
|
||||
String get deviceUnbindConfirmTitle => '确认解绑';
|
||||
|
||||
@override
|
||||
String deviceUnbindConfirmMessage(String name) {
|
||||
return '解绑后将无法继续查看和管理该设备($name),是否继续?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get deviceUnbindSuccess => '设备已解绑';
|
||||
|
||||
@override
|
||||
String get deviceBindTitle => '绑定设备';
|
||||
|
||||
@override
|
||||
String get deviceBindSnLabel => '设备序列号(SN)';
|
||||
|
||||
@override
|
||||
String get deviceBindSnHint => '请输入设备机身或包装上的 SN';
|
||||
|
||||
@override
|
||||
String get deviceBindRemarkLabel => '备注名(可选)';
|
||||
|
||||
@override
|
||||
String get deviceBindRemarkHint => '如:妈妈的平板';
|
||||
|
||||
@override
|
||||
String get deviceBindConfirm => '确认绑定';
|
||||
|
||||
@override
|
||||
String get deviceBindScan => '扫码绑定';
|
||||
|
||||
@override
|
||||
String get deviceBindSnEmpty => '请输入设备序列号';
|
||||
|
||||
@override
|
||||
String get deviceBindSuccess => '设备绑定成功';
|
||||
|
||||
@override
|
||||
String get deviceBindTip => 'SN 通常位于设备背面标签或设置-关于本机中。';
|
||||
|
||||
@override
|
||||
String get scanTitle => '扫描二维码';
|
||||
|
||||
@override
|
||||
String get scanHint => '将设备二维码放入框内,即可自动识别';
|
||||
|
||||
@override
|
||||
String get scanFromGallery => '从相册选取';
|
||||
|
||||
@override
|
||||
String get scanTorch => '闪光灯';
|
||||
|
||||
@override
|
||||
String get scanNoCodeFound => '未识别到二维码,请重试';
|
||||
|
||||
@override
|
||||
String get scanPermissionDenied => '无法访问相机,请在系统设置中开启权限';
|
||||
|
||||
@override
|
||||
String get securityTitle => '账号与安全';
|
||||
|
||||
@override
|
||||
String get securityLoginDevices => '登录设备';
|
||||
|
||||
@override
|
||||
String get securityLoginDevicesSub => '查看并管理已登录的终端';
|
||||
|
||||
@override
|
||||
String get securityChangePassword => '修改密码';
|
||||
|
||||
@override
|
||||
String get securityChangePasswordSub => '定期更换可提升账号安全性';
|
||||
|
||||
@override
|
||||
String get securityChangeMobile => '更换手机';
|
||||
|
||||
@override
|
||||
String get securityChangeMobileSub => '用于登录与接收验证码';
|
||||
|
||||
@override
|
||||
String get securityWechat => '微信绑定';
|
||||
|
||||
@override
|
||||
String get securityWechatSub => '绑定后可使用微信快捷登录';
|
||||
|
||||
@override
|
||||
String get loginDevicesTitle => '登录设备';
|
||||
|
||||
@override
|
||||
String get loginDevicesEmpty => '暂无登录设备记录';
|
||||
|
||||
@override
|
||||
String get loginDevicesCurrent => '本机';
|
||||
|
||||
@override
|
||||
String get loginDevicesRevoke => '下线';
|
||||
|
||||
@override
|
||||
String get loginDevicesRevokeConfirm => '确认让该设备退出登录?';
|
||||
|
||||
@override
|
||||
String get loginDevicesRevokeSuccess => '该设备已下线';
|
||||
|
||||
@override
|
||||
String get changePasswordTitle => '修改密码';
|
||||
|
||||
@override
|
||||
String get changePasswordOld => '当前密码';
|
||||
|
||||
@override
|
||||
String get changePasswordNew => '新密码';
|
||||
|
||||
@override
|
||||
String get changePasswordConfirmField => '确认新密码';
|
||||
|
||||
@override
|
||||
String get changePasswordRule => '密码需 8-20 位,包含字母和数字';
|
||||
|
||||
@override
|
||||
String get changePasswordMismatch => '两次输入的新密码不一致';
|
||||
|
||||
@override
|
||||
String get changePasswordInvalid => '新密码不符合规则,请重新输入';
|
||||
|
||||
@override
|
||||
String get changePasswordSuccess => '密码修改成功';
|
||||
|
||||
@override
|
||||
String get changeMobileTitle => '更换手机';
|
||||
|
||||
@override
|
||||
String get changeMobileCurrent => '当前手机号';
|
||||
|
||||
@override
|
||||
String get changeMobileNew => '新手机号';
|
||||
|
||||
@override
|
||||
String get changeMobileCode => '验证码';
|
||||
|
||||
@override
|
||||
String get changeMobilePassword => '账号密码';
|
||||
|
||||
@override
|
||||
String get changeMobileSendCode => '获取验证码';
|
||||
|
||||
@override
|
||||
String changeMobileResendIn(int seconds) {
|
||||
return '${seconds}s 后重发';
|
||||
}
|
||||
|
||||
@override
|
||||
String get changeMobileInvalid => '请输入正确的手机号';
|
||||
|
||||
@override
|
||||
String get changeMobileCodeEmpty => '请输入验证码';
|
||||
|
||||
@override
|
||||
String get changeMobilePasswordEmpty => '请输入账号密码';
|
||||
|
||||
@override
|
||||
String get changeMobileSuccess => '手机号更换成功';
|
||||
|
||||
@override
|
||||
String get wechatTitle => '微信绑定';
|
||||
|
||||
@override
|
||||
String get wechatBound => '已绑定';
|
||||
|
||||
@override
|
||||
String get wechatUnbound => '未绑定';
|
||||
|
||||
@override
|
||||
String get wechatBind => '绑定微信';
|
||||
|
||||
@override
|
||||
String get wechatUnbind => '解除绑定';
|
||||
|
||||
@override
|
||||
String get wechatUnbindConfirm => '解绑后将无法使用微信快捷登录,是否继续?';
|
||||
|
||||
@override
|
||||
String get wechatBindSuccess => '微信绑定成功';
|
||||
|
||||
@override
|
||||
String get wechatUnbindSuccess => '微信已解绑';
|
||||
|
||||
@override
|
||||
String get wechatSdkUnavailable => '微信登录暂不可用,请稍后重试';
|
||||
|
||||
@override
|
||||
String get commonConfirm => '确定';
|
||||
|
||||
@override
|
||||
String get commonCancel => '取消';
|
||||
|
||||
@override
|
||||
String get commonSave => '保存';
|
||||
|
||||
@override
|
||||
String get commonRetry => '重试';
|
||||
|
||||
@override
|
||||
String get commonLoadFailed => '加载失败';
|
||||
}
|
||||
|
||||
@@ -75,6 +75,37 @@
|
||||
"cfRemoteCam": "Remote Camera",
|
||||
"cfMore": "More",
|
||||
"contactsTitle": "Contacts",
|
||||
"contactName": "Name",
|
||||
"contactNick": "Nickname (optional)",
|
||||
"contactPhone": "Phone",
|
||||
"contactEmergency": "Mark as emergency/favorite contact",
|
||||
"contactListEmpty": "No contacts yet",
|
||||
"contactNoDevice": "Please bind a device in \"My Device\" first",
|
||||
"contactAdded": "Contact added",
|
||||
"contactUpdated": "Saved",
|
||||
"contactDeleted": "Deleted",
|
||||
"nameRequired": "Please enter a name",
|
||||
"phoneRequired": "Please enter a phone number",
|
||||
"deleteConfirm": "Delete contact \"{name}\"?",
|
||||
"commonAdd": "Add Contact",
|
||||
"contactDetailTitle": "Contact Details",
|
||||
"@contactDetailTitle": {
|
||||
"description": "联系人详情页标题"
|
||||
},
|
||||
"contactIsEmergency": "Emergency Contact",
|
||||
"@contactIsEmergency": {
|
||||
"description": "详情页:紧急联系人标签"
|
||||
},
|
||||
"contactEmergencyYes": "Yes",
|
||||
"@contactEmergencyYes": {
|
||||
"description": "详情页:紧急联系人状态-是"
|
||||
},
|
||||
"contactEmergencyNo": "No",
|
||||
"@contactEmergencyNo": {
|
||||
"description": "详情页:紧急联系人状态-否"
|
||||
},
|
||||
"commonEdit": "Edit Contact",
|
||||
"commonDelete": "Delete",
|
||||
"grpFavorite": "★ Favorites",
|
||||
"grpColleague": "Colleagues",
|
||||
"grpFamily": "Family",
|
||||
@@ -96,10 +127,20 @@
|
||||
"mapCoordinates": "Lat 39.9965, Lng 116.4821",
|
||||
"mapUpdatedAt": "Updated at 14:02",
|
||||
"mapAccuracy": "Location accuracy approx. 10 m",
|
||||
"mapLocateMe": "Locate Me",
|
||||
"mapLocatingSelf": "Locating my position…",
|
||||
"homeScreenshots": "Recent Screenshots",
|
||||
"homeViewAll": "View All",
|
||||
"homeViewDetail": "View Detail",
|
||||
"homeNoScreenshot": "No screenshots yet",
|
||||
"homeScreenshotClearAll": "Clear",
|
||||
"homeScreenshotDelete": "Delete",
|
||||
"homeScreenshotDeleteCurrent": "Delete this screenshot",
|
||||
"homeScreenshotDeleteConfirm": "Delete this screenshot?",
|
||||
"homeScreenshotClearConfirm": "Clear all screenshots?",
|
||||
"homeScreenshotDeleted": "Deleted",
|
||||
"homeScreenshotCleared": "All screenshots cleared",
|
||||
"homeScreenshotEmpty": "All screenshots deleted",
|
||||
"homeUsage": "Today's Usage",
|
||||
"homeUsageTrend": "↓12% from yesterday",
|
||||
"homeUsageToday": "Today",
|
||||
@@ -147,5 +188,75 @@
|
||||
"setPrivacy": "Privacy",
|
||||
"setSecurity": "Account & Security",
|
||||
"setHelp": "Help & Feedback",
|
||||
"setAbout": "About"
|
||||
"setAbout": "About",
|
||||
"deviceManageTitle": "Device Management",
|
||||
"deviceManageAdd": "Add Device",
|
||||
"deviceUnbind": "Unbind",
|
||||
"deviceUnbindConfirmTitle": "Confirm Unbind",
|
||||
"deviceUnbindConfirmMessage": "After unbinding, you will no longer be able to view or manage this device ({name}). Continue?",
|
||||
"deviceUnbindSuccess": "Device unbound",
|
||||
"deviceBindTitle": "Bind Device",
|
||||
"deviceBindSnLabel": "Serial Number (SN)",
|
||||
"deviceBindSnHint": "Enter the SN on the device or package",
|
||||
"deviceBindRemarkLabel": "Remark (optional)",
|
||||
"deviceBindRemarkHint": "e.g. Mom's tablet",
|
||||
"deviceBindConfirm": "Confirm Binding",
|
||||
"deviceBindScan": "Scan QR Code",
|
||||
"deviceBindSnEmpty": "Please enter the serial number",
|
||||
"deviceBindSuccess": "Device bound successfully",
|
||||
"deviceBindTip": "The SN is usually on the label on the back of the device or in Settings - About.",
|
||||
"scanTitle": "Scan QR Code",
|
||||
"scanHint": "Place the device QR code inside the frame to scan automatically",
|
||||
"scanFromGallery": "From Gallery",
|
||||
"scanTorch": "Flashlight",
|
||||
"scanNoCodeFound": "No QR code detected, please try again",
|
||||
"scanPermissionDenied": "Cannot access the camera. Please enable permission in system settings",
|
||||
"securityTitle": "Account & Security",
|
||||
"securityLoginDevices": "Login Devices",
|
||||
"securityLoginDevicesSub": "View and manage signed-in terminals",
|
||||
"securityChangePassword": "Change Password",
|
||||
"securityChangePasswordSub": "Changing regularly improves account security",
|
||||
"securityChangeMobile": "Change Phone",
|
||||
"securityChangeMobileSub": "Used for login and verification codes",
|
||||
"securityWechat": "WeChat Binding",
|
||||
"securityWechatSub": "Bind to enable quick WeChat login",
|
||||
"loginDevicesTitle": "Login Devices",
|
||||
"loginDevicesEmpty": "No login records yet",
|
||||
"loginDevicesCurrent": "This device",
|
||||
"loginDevicesRevoke": "Sign out",
|
||||
"loginDevicesRevokeConfirm": "Sign this device out of your account?",
|
||||
"loginDevicesRevokeSuccess": "The device has been signed out",
|
||||
"changePasswordTitle": "Change Password",
|
||||
"changePasswordOld": "Current password",
|
||||
"changePasswordNew": "New password",
|
||||
"changePasswordConfirmField": "Confirm new password",
|
||||
"changePasswordRule": "8-20 characters, with both letters and numbers",
|
||||
"changePasswordMismatch": "The new passwords do not match",
|
||||
"changePasswordInvalid": "The new password does not meet the rules",
|
||||
"changePasswordSuccess": "Password changed successfully",
|
||||
"changeMobileTitle": "Change Phone",
|
||||
"changeMobileCurrent": "Current phone number",
|
||||
"changeMobileNew": "New phone number",
|
||||
"changeMobileCode": "Verification code",
|
||||
"changeMobilePassword": "Account password",
|
||||
"changeMobileSendCode": "Get code",
|
||||
"changeMobileResendIn": "Resend in {seconds}s",
|
||||
"changeMobileInvalid": "Please enter a valid phone number",
|
||||
"changeMobileCodeEmpty": "Please enter the verification code",
|
||||
"changeMobilePasswordEmpty": "Please enter your account password",
|
||||
"changeMobileSuccess": "Phone number changed successfully",
|
||||
"wechatTitle": "WeChat Binding",
|
||||
"wechatBound": "Bound",
|
||||
"wechatUnbound": "Not bound",
|
||||
"wechatBind": "Bind WeChat",
|
||||
"wechatUnbind": "Unbind",
|
||||
"wechatUnbindConfirm": "After unbinding you cannot use quick WeChat login. Continue?",
|
||||
"wechatBindSuccess": "WeChat bound successfully",
|
||||
"wechatUnbindSuccess": "WeChat unbound",
|
||||
"wechatSdkUnavailable": "WeChat login is unavailable, please try again later",
|
||||
"commonConfirm": "OK",
|
||||
"commonCancel": "Cancel",
|
||||
"commonSave": "Save",
|
||||
"commonRetry": "Retry",
|
||||
"commonLoadFailed": "Failed to load"
|
||||
}
|
||||
|
||||
@@ -300,6 +300,82 @@
|
||||
"@contactsTitle": {
|
||||
"description": "联系人页标题"
|
||||
},
|
||||
"contactName": "姓名",
|
||||
"@contactName": {
|
||||
"description": "联系人表单:姓名"
|
||||
},
|
||||
"contactNick": "昵称(可选)",
|
||||
"@contactNick": {
|
||||
"description": "联系人表单:昵称"
|
||||
},
|
||||
"contactPhone": "手机号",
|
||||
"@contactPhone": {
|
||||
"description": "联系人表单:手机号"
|
||||
},
|
||||
"contactEmergency": "设为紧急/常用联系人",
|
||||
"@contactEmergency": {
|
||||
"description": "联系人表单:是否紧急联系人"
|
||||
},
|
||||
"contactListEmpty": "暂无联系人",
|
||||
"@contactListEmpty": {
|
||||
"description": "联系人列表为空提示"
|
||||
},
|
||||
"contactNoDevice": "请先在「我的设备」中绑定设备",
|
||||
"@contactNoDevice": {
|
||||
"description": "未绑定设备时的提示"
|
||||
},
|
||||
"contactAdded": "已添加联系人",
|
||||
"@contactAdded": {
|
||||
"description": "新增联系人成功提示"
|
||||
},
|
||||
"contactUpdated": "已保存",
|
||||
"@contactUpdated": {
|
||||
"description": "修改联系人成功提示"
|
||||
},
|
||||
"contactDeleted": "已删除",
|
||||
"@contactDeleted": {
|
||||
"description": "删除联系人成功提示"
|
||||
},
|
||||
"nameRequired": "请输入姓名",
|
||||
"@nameRequired": {
|
||||
"description": "姓名校验失败"
|
||||
},
|
||||
"phoneRequired": "请输入手机号",
|
||||
"@phoneRequired": {
|
||||
"description": "手机号校验失败"
|
||||
},
|
||||
"deleteConfirm": "确定删除联系人「{name}」吗?",
|
||||
"@deleteConfirm": {
|
||||
"description": "删除联系人二次确认"
|
||||
},
|
||||
"commonAdd": "新增联系人",
|
||||
"@commonAdd": {
|
||||
"description": "新增联系人按钮"
|
||||
},
|
||||
"contactDetailTitle": "联系人详情",
|
||||
"@contactDetailTitle": {
|
||||
"description": "联系人详情页标题"
|
||||
},
|
||||
"contactIsEmergency": "紧急联系人",
|
||||
"@contactIsEmergency": {
|
||||
"description": "详情页:紧急联系人标签"
|
||||
},
|
||||
"contactEmergencyYes": "是",
|
||||
"@contactEmergencyYes": {
|
||||
"description": "详情页:紧急联系人状态-是"
|
||||
},
|
||||
"contactEmergencyNo": "否",
|
||||
"@contactEmergencyNo": {
|
||||
"description": "详情页:紧急联系人状态-否"
|
||||
},
|
||||
"commonEdit": "编辑联系人",
|
||||
"@commonEdit": {
|
||||
"description": "编辑联系人标题"
|
||||
},
|
||||
"commonDelete": "删除",
|
||||
"@commonDelete": {
|
||||
"description": "删除联系人按钮"
|
||||
},
|
||||
"grpFavorite": "★ 常用",
|
||||
"@grpFavorite": {
|
||||
"description": "联系人分组:常用"
|
||||
@@ -384,6 +460,14 @@
|
||||
"@mapAccuracy": {
|
||||
"description": "地图页定位精度"
|
||||
},
|
||||
"mapLocateMe": "定位到我",
|
||||
"@mapLocateMe": {
|
||||
"description": "地图页定位到本机位置按钮"
|
||||
},
|
||||
"mapLocatingSelf": "正在定位我的位置…",
|
||||
"@mapLocatingSelf": {
|
||||
"description": "地图页正在定位本机位置提示"
|
||||
},
|
||||
"homeScreenshots": "最近截图",
|
||||
"@homeScreenshots": {
|
||||
"description": "首页最近截图卡片"
|
||||
@@ -400,6 +484,38 @@
|
||||
"@homeNoScreenshot": {
|
||||
"description": "首页最近截图为空时的占位文案"
|
||||
},
|
||||
"homeScreenshotClearAll": "清空截图",
|
||||
"@homeScreenshotClearAll": {
|
||||
"description": "截图页右上角清空全部截图"
|
||||
},
|
||||
"homeScreenshotDelete": "删除",
|
||||
"@homeScreenshotDelete": {
|
||||
"description": "删除单条截图按钮"
|
||||
},
|
||||
"homeScreenshotDeleteCurrent": "删除这张截图",
|
||||
"@homeScreenshotDeleteCurrent": {
|
||||
"description": "删除当前截图按钮"
|
||||
},
|
||||
"homeScreenshotDeleteConfirm": "确认删除这张截图吗?",
|
||||
"@homeScreenshotDeleteConfirm": {
|
||||
"description": "删除单条截图确认弹窗文案"
|
||||
},
|
||||
"homeScreenshotClearConfirm": "确认清空所有截图吗?",
|
||||
"@homeScreenshotClearConfirm": {
|
||||
"description": "清空全部截图确认弹窗文案"
|
||||
},
|
||||
"homeScreenshotDeleted": "已删除",
|
||||
"@homeScreenshotDeleted": {
|
||||
"description": "删除单条截图后的提示"
|
||||
},
|
||||
"homeScreenshotCleared": "已清空全部截图",
|
||||
"@homeScreenshotCleared": {
|
||||
"description": "清空全部截图后的提示"
|
||||
},
|
||||
"homeScreenshotEmpty": "截图已全部删除",
|
||||
"@homeScreenshotEmpty": {
|
||||
"description": "截图全部删除后的空态提示"
|
||||
},
|
||||
"homeUsage": "今日使用时长",
|
||||
"@homeUsage": {
|
||||
"description": "首页使用时长卡片"
|
||||
@@ -591,5 +707,295 @@
|
||||
"setAbout": "关于",
|
||||
"@setAbout": {
|
||||
"description": "设置:关于"
|
||||
},
|
||||
"deviceManageTitle": "设备管理",
|
||||
"@deviceManageTitle": {
|
||||
"description": "设备管理页标题"
|
||||
},
|
||||
"deviceManageAdd": "添加设备",
|
||||
"@deviceManageAdd": {
|
||||
"description": "设备管理页:添加设备入口"
|
||||
},
|
||||
"deviceUnbind": "解绑",
|
||||
"@deviceUnbind": {
|
||||
"description": "设备列表项解绑按钮"
|
||||
},
|
||||
"deviceUnbindConfirmTitle": "确认解绑",
|
||||
"@deviceUnbindConfirmTitle": {
|
||||
"description": "解绑确认弹窗标题"
|
||||
},
|
||||
"deviceUnbindConfirmMessage": "解绑后将无法继续查看和管理该设备({name}),是否继续?",
|
||||
"@deviceUnbindConfirmMessage": {
|
||||
"description": "解绑确认弹窗内容",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deviceUnbindSuccess": "设备已解绑",
|
||||
"@deviceUnbindSuccess": {
|
||||
"description": "解绑成功提示"
|
||||
},
|
||||
"deviceBindTitle": "绑定设备",
|
||||
"@deviceBindTitle": {
|
||||
"description": "绑定设备页标题"
|
||||
},
|
||||
"deviceBindSnLabel": "设备序列号(SN)",
|
||||
"@deviceBindSnLabel": {
|
||||
"description": "绑定设备页 SN 输入框标签"
|
||||
},
|
||||
"deviceBindSnHint": "请输入设备机身或包装上的 SN",
|
||||
"@deviceBindSnHint": {
|
||||
"description": "绑定设备页 SN 输入框占位符"
|
||||
},
|
||||
"deviceBindRemarkLabel": "备注名(可选)",
|
||||
"@deviceBindRemarkLabel": {
|
||||
"description": "绑定设备页备注输入框标签"
|
||||
},
|
||||
"deviceBindRemarkHint": "如:妈妈的平板",
|
||||
"@deviceBindRemarkHint": {
|
||||
"description": "绑定设备页备注输入框占位符"
|
||||
},
|
||||
"deviceBindConfirm": "确认绑定",
|
||||
"@deviceBindConfirm": {
|
||||
"description": "绑定设备页确认按钮"
|
||||
},
|
||||
"deviceBindScan": "扫码绑定",
|
||||
"@deviceBindScan": {
|
||||
"description": "绑定设备页扫码按钮"
|
||||
},
|
||||
"deviceBindSnEmpty": "请输入设备序列号",
|
||||
"@deviceBindSnEmpty": {
|
||||
"description": "SN 为空校验提示"
|
||||
},
|
||||
"deviceBindSuccess": "设备绑定成功",
|
||||
"@deviceBindSuccess": {
|
||||
"description": "绑定成功提示"
|
||||
},
|
||||
"deviceBindTip": "SN 通常位于设备背面标签或设置-关于本机中。",
|
||||
"@deviceBindTip": {
|
||||
"description": "绑定设备页说明文案"
|
||||
},
|
||||
"scanTitle": "扫描二维码",
|
||||
"@scanTitle": {
|
||||
"description": "扫码页标题"
|
||||
},
|
||||
"scanHint": "将设备二维码放入框内,即可自动识别",
|
||||
"@scanHint": {
|
||||
"description": "扫码页取景框提示"
|
||||
},
|
||||
"scanFromGallery": "从相册选取",
|
||||
"@scanFromGallery": {
|
||||
"description": "扫码页相册按钮"
|
||||
},
|
||||
"scanTorch": "闪光灯",
|
||||
"@scanTorch": {
|
||||
"description": "扫码页闪光灯按钮"
|
||||
},
|
||||
"scanNoCodeFound": "未识别到二维码,请重试",
|
||||
"@scanNoCodeFound": {
|
||||
"description": "相册图片未识别到二维码提示"
|
||||
},
|
||||
"scanPermissionDenied": "无法访问相机,请在系统设置中开启权限",
|
||||
"@scanPermissionDenied": {
|
||||
"description": "相机权限被拒提示"
|
||||
},
|
||||
"securityTitle": "账号与安全",
|
||||
"@securityTitle": {
|
||||
"description": "账号与安全页标题"
|
||||
},
|
||||
"securityLoginDevices": "登录设备",
|
||||
"@securityLoginDevices": {
|
||||
"description": "账号与安全:登录设备入口"
|
||||
},
|
||||
"securityLoginDevicesSub": "查看并管理已登录的终端",
|
||||
"@securityLoginDevicesSub": {
|
||||
"description": "账号与安全:登录设备副标题"
|
||||
},
|
||||
"securityChangePassword": "修改密码",
|
||||
"@securityChangePassword": {
|
||||
"description": "账号与安全:修改密码入口"
|
||||
},
|
||||
"securityChangePasswordSub": "定期更换可提升账号安全性",
|
||||
"@securityChangePasswordSub": {
|
||||
"description": "账号与安全:修改密码副标题"
|
||||
},
|
||||
"securityChangeMobile": "更换手机",
|
||||
"@securityChangeMobile": {
|
||||
"description": "账号与安全:更换手机入口"
|
||||
},
|
||||
"securityChangeMobileSub": "用于登录与接收验证码",
|
||||
"@securityChangeMobileSub": {
|
||||
"description": "账号与安全:更换手机副标题"
|
||||
},
|
||||
"securityWechat": "微信绑定",
|
||||
"@securityWechat": {
|
||||
"description": "账号与安全:微信绑定入口"
|
||||
},
|
||||
"securityWechatSub": "绑定后可使用微信快捷登录",
|
||||
"@securityWechatSub": {
|
||||
"description": "账号与安全:微信绑定副标题"
|
||||
},
|
||||
"loginDevicesTitle": "登录设备",
|
||||
"@loginDevicesTitle": {
|
||||
"description": "登录设备页标题"
|
||||
},
|
||||
"loginDevicesEmpty": "暂无登录设备记录",
|
||||
"@loginDevicesEmpty": {
|
||||
"description": "登录设备列表为空"
|
||||
},
|
||||
"loginDevicesCurrent": "本机",
|
||||
"@loginDevicesCurrent": {
|
||||
"description": "当前设备标记"
|
||||
},
|
||||
"loginDevicesRevoke": "下线",
|
||||
"@loginDevicesRevoke": {
|
||||
"description": "登录设备下线按钮"
|
||||
},
|
||||
"loginDevicesRevokeConfirm": "确认让该设备退出登录?",
|
||||
"@loginDevicesRevokeConfirm": {
|
||||
"description": "下线确认提示"
|
||||
},
|
||||
"loginDevicesRevokeSuccess": "该设备已下线",
|
||||
"@loginDevicesRevokeSuccess": {
|
||||
"description": "下线成功提示"
|
||||
},
|
||||
"changePasswordTitle": "修改密码",
|
||||
"@changePasswordTitle": {
|
||||
"description": "修改密码页标题"
|
||||
},
|
||||
"changePasswordOld": "当前密码",
|
||||
"@changePasswordOld": {
|
||||
"description": "修改密码:原密码"
|
||||
},
|
||||
"changePasswordNew": "新密码",
|
||||
"@changePasswordNew": {
|
||||
"description": "修改密码:新密码"
|
||||
},
|
||||
"changePasswordConfirmField": "确认新密码",
|
||||
"@changePasswordConfirmField": {
|
||||
"description": "修改密码:确认新密码"
|
||||
},
|
||||
"changePasswordRule": "密码需 8-20 位,包含字母和数字",
|
||||
"@changePasswordRule": {
|
||||
"description": "修改密码:规则说明"
|
||||
},
|
||||
"changePasswordMismatch": "两次输入的新密码不一致",
|
||||
"@changePasswordMismatch": {
|
||||
"description": "修改密码:两次不一致"
|
||||
},
|
||||
"changePasswordInvalid": "新密码不符合规则,请重新输入",
|
||||
"@changePasswordInvalid": {
|
||||
"description": "修改密码:格式不合规"
|
||||
},
|
||||
"changePasswordSuccess": "密码修改成功",
|
||||
"@changePasswordSuccess": {
|
||||
"description": "修改密码成功提示"
|
||||
},
|
||||
"changeMobileTitle": "更换手机",
|
||||
"@changeMobileTitle": {
|
||||
"description": "更换手机页标题"
|
||||
},
|
||||
"changeMobileCurrent": "当前手机号",
|
||||
"@changeMobileCurrent": {
|
||||
"description": "更换手机:当前号码"
|
||||
},
|
||||
"changeMobileNew": "新手机号",
|
||||
"@changeMobileNew": {
|
||||
"description": "更换手机:新号码"
|
||||
},
|
||||
"changeMobileCode": "验证码",
|
||||
"@changeMobileCode": {
|
||||
"description": "更换手机:验证码"
|
||||
},
|
||||
"changeMobilePassword": "账号密码",
|
||||
"@changeMobilePassword": {
|
||||
"description": "更换手机:密码校验"
|
||||
},
|
||||
"changeMobileSendCode": "获取验证码",
|
||||
"@changeMobileSendCode": {
|
||||
"description": "更换手机:发送验证码"
|
||||
},
|
||||
"changeMobileResendIn": "{seconds}s 后重发",
|
||||
"@changeMobileResendIn": {
|
||||
"description": "更换手机:倒计时重发",
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"changeMobileInvalid": "请输入正确的手机号",
|
||||
"@changeMobileInvalid": {
|
||||
"description": "更换手机:号码格式错误"
|
||||
},
|
||||
"changeMobileCodeEmpty": "请输入验证码",
|
||||
"@changeMobileCodeEmpty": {
|
||||
"description": "更换手机:验证码为空"
|
||||
},
|
||||
"changeMobilePasswordEmpty": "请输入账号密码",
|
||||
"@changeMobilePasswordEmpty": {
|
||||
"description": "更换手机:密码为空"
|
||||
},
|
||||
"changeMobileSuccess": "手机号更换成功",
|
||||
"@changeMobileSuccess": {
|
||||
"description": "更换手机成功提示"
|
||||
},
|
||||
"wechatTitle": "微信绑定",
|
||||
"@wechatTitle": {
|
||||
"description": "微信绑定页标题"
|
||||
},
|
||||
"wechatBound": "已绑定",
|
||||
"@wechatBound": {
|
||||
"description": "微信已绑定状态"
|
||||
},
|
||||
"wechatUnbound": "未绑定",
|
||||
"@wechatUnbound": {
|
||||
"description": "微信未绑定状态"
|
||||
},
|
||||
"wechatBind": "绑定微信",
|
||||
"@wechatBind": {
|
||||
"description": "绑定微信按钮"
|
||||
},
|
||||
"wechatUnbind": "解除绑定",
|
||||
"@wechatUnbind": {
|
||||
"description": "解绑微信按钮"
|
||||
},
|
||||
"wechatUnbindConfirm": "解绑后将无法使用微信快捷登录,是否继续?",
|
||||
"@wechatUnbindConfirm": {
|
||||
"description": "解绑微信确认提示"
|
||||
},
|
||||
"wechatBindSuccess": "微信绑定成功",
|
||||
"@wechatBindSuccess": {
|
||||
"description": "绑定微信成功提示"
|
||||
},
|
||||
"wechatUnbindSuccess": "微信已解绑",
|
||||
"@wechatUnbindSuccess": {
|
||||
"description": "解绑微信成功提示"
|
||||
},
|
||||
"wechatSdkUnavailable": "微信登录暂不可用,请稍后重试",
|
||||
"@wechatSdkUnavailable": {
|
||||
"description": "微信 SDK 未接入提示"
|
||||
},
|
||||
"commonConfirm": "确定",
|
||||
"@commonConfirm": {
|
||||
"description": "通用:确定"
|
||||
},
|
||||
"commonCancel": "取消",
|
||||
"@commonCancel": {
|
||||
"description": "通用:取消"
|
||||
},
|
||||
"commonSave": "保存",
|
||||
"@commonSave": {
|
||||
"description": "通用:保存"
|
||||
},
|
||||
"commonRetry": "重试",
|
||||
"@commonRetry": {
|
||||
"description": "通用:重试"
|
||||
},
|
||||
"commonLoadFailed": "加载失败",
|
||||
"@commonLoadFailed": {
|
||||
"description": "通用:加载失败"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Project-level configuration.
|
||||
cmake_minimum_required(VERSION 3.13)
|
||||
project(runner LANGUAGES CXX)
|
||||
set(CMAKE_INSTALL_PREFIX "$ENV{HOME}/ttstd_family_care" CACHE PATH "" FORCE)
|
||||
|
||||
# The name of the executable created for the application. Change this to change
|
||||
# the on-disk name of your application.
|
||||
|
||||
@@ -6,11 +6,15 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <file_selector_linux/file_selector_plugin.h>
|
||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
|
||||
#include <mmkv_linux/mmkv_linux_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
||||
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
||||
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_linux
|
||||
flutter_secure_storage_linux
|
||||
flutter_webrtc
|
||||
mmkv_linux
|
||||
|
||||
@@ -6,15 +6,19 @@ import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import device_info_plus
|
||||
import file_selector_macos
|
||||
import flutter_secure_storage_darwin
|
||||
import flutter_webrtc
|
||||
import mmkv_ios
|
||||
import mobile_scanner
|
||||
import shared_preferences_foundation
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||
FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin"))
|
||||
MMKVPlugin.register(with: registry.registrar(forPlugin: "MMKVPlugin"))
|
||||
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
}
|
||||
|
||||
136
pubspec.lock
136
pubspec.lock
@@ -49,6 +49,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
azlistview_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: azlistview_plus
|
||||
sha256: bbf08532db8ba2b9054100f68829e1841b23f3244e54712934f0f192651d8319
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -177,6 +185,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cross_file
|
||||
sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.5+4"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -297,6 +313,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
file_selector_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_linux
|
||||
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.4"
|
||||
file_selector_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_macos
|
||||
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.5"
|
||||
file_selector_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_platform_interface
|
||||
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
file_selector_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_windows
|
||||
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.3+5"
|
||||
fixnum:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -509,6 +557,70 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
image_picker:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: image_picker
|
||||
sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.3"
|
||||
image_picker_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_android
|
||||
sha256: "6f3a1995eafb000333174fae92202622033b0ee7fd917a6cd3730295264df84a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.13+19"
|
||||
image_picker_for_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_for_web
|
||||
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.1"
|
||||
image_picker_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_ios
|
||||
sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.13+6"
|
||||
image_picker_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_linux
|
||||
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2"
|
||||
image_picker_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_macos
|
||||
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2+1"
|
||||
image_picker_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_platform_interface
|
||||
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.11.1"
|
||||
image_picker_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_windows
|
||||
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -621,6 +733,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
lpinyin:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: lpinyin
|
||||
sha256: "0bb843363f1f65170efd09fbdfc760c7ec34fc6354f9fcb2f89e74866a0d814a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.3"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -717,6 +837,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
mobile_scanner:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: mobile_scanner
|
||||
sha256: d234581c090526676fd8fab4ada92f35c6746e3fb4f05a399665d75a399fb760
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.2.3"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -925,6 +1053,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.4"
|
||||
scrollable_positioned_list:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: scrollable_positioned_list
|
||||
sha256: "1b54d5f1329a1e263269abc9e2543d90806131aa14fe7c6062a8054d57249287"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.8"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -85,9 +85,18 @@ dependencies:
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
|
||||
# 二维码扫描(相机实时扫码 + 相册图片解析)
|
||||
mobile_scanner: ^5.2.3
|
||||
|
||||
# 从相册选取图片(用于二维码图片识别)
|
||||
image_picker: ^1.1.2
|
||||
|
||||
azlistview_plus: ^3.0.0
|
||||
|
||||
cupertino_ui: any
|
||||
material_ui: any
|
||||
intl: any
|
||||
lpinyin: ^2.0.3
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
@@ -6,12 +6,15 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
|
||||
#include <mmkv_win32/mmkv_win32_plugin.h>
|
||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FileSelectorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
FlutterWebRTCPluginRegisterWithRegistrar(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_windows
|
||||
flutter_secure_storage_windows
|
||||
flutter_webrtc
|
||||
mmkv_win32
|
||||
|
||||
Reference in New Issue
Block a user