From 98de1e11325aa1d89dc0ead11fd970af4bf1b501 Mon Sep 17 00:00:00 2001 From: TongTongStudio Date: Fri, 21 Aug 2026 17:12:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E7=BB=91=E5=AE=9A=E3=80=81=E6=89=AB=E7=A0=81=E4=B8=8E=E8=81=94?= =?UTF-8?q?=E7=B3=BB=E4=BA=BA=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 7 + ios/Runner/Info.plist | 4 + lib/app/router/app_router.dart | 65 +- lib/core/widgets/settings_ui.dart | 322 ++++++++++ lib/features/account/data/account_api.dart | 113 ++++ .../account/data/account_providers.dart | 23 + .../account/data/account_repository_impl.dart | 44 ++ .../account/domain/account_models.dart | 82 +++ .../account/domain/account_repository.dart | 47 ++ .../presentation/account_controller.dart | 161 +++++ .../presentation/change_mobile_page.dart | 219 +++++++ .../presentation/change_password_page.dart | 138 ++++ .../presentation/login_devices_page.dart | 201 ++++++ .../account/presentation/security_page.dart | 95 +++ .../presentation/wechat_binding_page.dart | 184 ++++++ lib/features/contacts/data/contact_api.dart | 91 +++ .../contacts/data/contact_providers.dart | 24 + .../data/contact_repository_impl.dart | 25 + .../contacts/domain/contact_models.dart | 98 +++ .../contacts/domain/contact_repository.dart | 22 + .../presentation/contact_detail_page.dart | 202 ++++++ .../presentation/contact_edit_page.dart | 159 +++++ .../contacts/presentation/contacts_page.dart | 369 +++++++---- lib/features/device/data/device_api.dart | 67 ++ .../device/data/device_repository_impl.dart | 14 + .../device/domain/device_repository.dart | 20 + .../device/presentation/device_bind_page.dart | 141 +++++ .../device_manage_controller.dart | 96 +++ .../presentation/device_manage_page.dart | 252 ++++++++ .../device/presentation/device_scan_page.dart | 228 +++++++ .../home/data/location_controller.dart | 15 + lib/features/home/presentation/home_tab.dart | 16 +- lib/features/home/presentation/map_page.dart | 66 ++ .../home/presentation/screenshot_page.dart | 350 +++++++++-- .../messages/presentation/messages_tab.dart | 8 +- .../profile/presentation/profile_tab.dart | 48 +- lib/l10n/app_localizations.dart | 594 ++++++++++++++++++ lib/l10n/app_localizations_en.dart | 312 +++++++++ lib/l10n/app_localizations_zh.dart | 303 +++++++++ lib/l10n/intl_en.arb | 113 +++- lib/l10n/intl_zh.arb | 406 ++++++++++++ linux/CMakeLists.txt | 1 + linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 4 + pubspec.lock | 136 ++++ pubspec.yaml | 9 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 49 files changed, 5699 insertions(+), 204 deletions(-) create mode 100644 lib/core/widgets/settings_ui.dart create mode 100644 lib/features/account/data/account_api.dart create mode 100644 lib/features/account/data/account_providers.dart create mode 100644 lib/features/account/data/account_repository_impl.dart create mode 100644 lib/features/account/domain/account_models.dart create mode 100644 lib/features/account/domain/account_repository.dart create mode 100644 lib/features/account/presentation/account_controller.dart create mode 100644 lib/features/account/presentation/change_mobile_page.dart create mode 100644 lib/features/account/presentation/change_password_page.dart create mode 100644 lib/features/account/presentation/login_devices_page.dart create mode 100644 lib/features/account/presentation/security_page.dart create mode 100644 lib/features/account/presentation/wechat_binding_page.dart create mode 100644 lib/features/contacts/data/contact_api.dart create mode 100644 lib/features/contacts/data/contact_providers.dart create mode 100644 lib/features/contacts/data/contact_repository_impl.dart create mode 100644 lib/features/contacts/domain/contact_models.dart create mode 100644 lib/features/contacts/domain/contact_repository.dart create mode 100644 lib/features/contacts/presentation/contact_detail_page.dart create mode 100644 lib/features/contacts/presentation/contact_edit_page.dart create mode 100644 lib/features/device/presentation/device_bind_page.dart create mode 100644 lib/features/device/presentation/device_manage_controller.dart create mode 100644 lib/features/device/presentation/device_manage_page.dart create mode 100644 lib/features/device/presentation/device_scan_page.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 42ba259..005abcd 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -22,6 +22,13 @@ + + + + + + + 用于在地图上展示设备位置 NSLocationAlwaysAndWhenInUseUsageDescription 用于在地图上展示设备位置 + NSCameraUsageDescription + 用于扫描设备二维码完成绑定 + NSPhotoLibraryUsageDescription + 用于从相册选取二维码图片完成设备绑定 io.flutter.embedded_views_preview diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart index 50601a4..8f4aa2f 100644 --- a/lib/app/router/app_router.dart +++ b/lib/app/router/app_router.dart @@ -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); } } diff --git a/lib/core/widgets/settings_ui.dart b/lib/core/widgets/settings_ui.dart new file mode 100644 index 0000000..059ee85 --- /dev/null +++ b/lib/core/widgets/settings_ui.dart @@ -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 alert( + BuildContext context, { + String? title, + required String message, + required String confirmText, + }) => + showCupertinoDialog( + 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 confirm( + BuildContext context, { + String? title, + required String message, + required String confirmText, + required String cancelText, + bool destructive = true, + }) async { + final result = await showCupertinoDialog( + 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; + } +} diff --git a/lib/features/account/data/account_api.dart b/lib/features/account/data/account_api.dart new file mode 100644 index 0000000..d35b2f5 --- /dev/null +++ b/lib/features/account/data/account_api.dart @@ -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> 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 revokeLoginDevice(String id) => + _write(() => DioClient.post( + 'account/login-devices/revoke', + queryParameters: {'id': id}, + )); + + /// 修改密码。 + Future changePassword({ + required String oldPassword, + required String newPassword, + }) => + _write(() => DioClient.post( + 'account/password', + data: {'oldPassword': oldPassword, 'newPassword': newPassword}, + )); + + /// 发送更换手机号验证码到新手机号。 + Future sendChangeMobileCode(String newMobile) => + _write(() => DioClient.post( + 'account/mobile/sms/code', + queryParameters: {'mobile': newMobile}, + )); + + /// 更换绑定手机号。 + Future changeMobile({ + required String newMobile, + required String code, + required String password, + }) => + _write(() => DioClient.post( + 'account/mobile', + data: { + 'mobile': newMobile, + 'code': code, + 'password': password, + }, + )); + + /// 查询微信绑定状态。 + Future getWechatBinding() async { + try { + final data = await DioClient.get('account/wechat'); + if (data is Map) return WechatBinding.fromJson(data); + return WechatBinding.unbound; + } on ApiException catch (e) { + debugPrint('[AccountApi] getWechatBinding failed: ${e.message}'); + return WechatBinding.unbound; + } + } + + /// 绑定微信。 + Future bindWechat(String authCode) => _write(() => DioClient.post( + 'account/wechat/bind', + data: {'code': authCode}, + )); + + /// 解绑微信。 + Future unbindWechat() => + _write(() => DioClient.post('account/wechat/unbind')); + + /// 统一处理写操作:成功返回 null,业务失败返回后台提示文案。 + Future _write(Future Function() call) async { + try { + await call(); + return null; + } on ApiException catch (e) { + return e.message; + } + } + + /// 兼容后台可能返回「数组」或「分页对象(含 list/records)」两种结构。 + List _parseList( + dynamic data, + T Function(Map) fromJson, + ) { + Iterable? raw; + if (data is List) { + raw = data; + } else if (data is Map) { + raw = (data['list'] ?? data['records']) as Iterable?; + } + if (raw == null) return const []; + return raw + .whereType>() + .map(fromJson) + .toList(growable: false); + } +} diff --git a/lib/features/account/data/account_providers.dart b/lib/features/account/data/account_providers.dart new file mode 100644 index 0000000..54270bc --- /dev/null +++ b/lib/features/account/data/account_providers.dart @@ -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((ref) { + const api = AccountApi(); + return AccountRepositoryImpl(api); +}); + +/// 登录设备(会话)列表。 +final loginDevicesProvider = FutureProvider>((ref) async { + final repo = ref.watch(accountRepositoryProvider); + return repo.getLoginDevices(); +}); + +/// 微信绑定状态。 +final wechatBindingProvider = FutureProvider((ref) async { + final repo = ref.watch(accountRepositoryProvider); + return repo.getWechatBinding(); +}); diff --git a/lib/features/account/data/account_repository_impl.dart b/lib/features/account/data/account_repository_impl.dart new file mode 100644 index 0000000..2700ca9 --- /dev/null +++ b/lib/features/account/data/account_repository_impl.dart @@ -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> getLoginDevices() => _api.getLoginDevices(); + + @override + Future revokeLoginDevice(String id) => _api.revokeLoginDevice(id); + + @override + Future changePassword({ + required String oldPassword, + required String newPassword, + }) => + _api.changePassword(oldPassword: oldPassword, newPassword: newPassword); + + @override + Future sendChangeMobileCode(String newMobile) => + _api.sendChangeMobileCode(newMobile); + + @override + Future changeMobile({ + required String newMobile, + required String code, + required String password, + }) => + _api.changeMobile(newMobile: newMobile, code: code, password: password); + + @override + Future getWechatBinding() => _api.getWechatBinding(); + + @override + Future bindWechat(String authCode) => _api.bindWechat(authCode); + + @override + Future unbindWechat() => _api.unbindWechat(); +} diff --git a/lib/features/account/domain/account_models.dart b/lib/features/account/domain/account_models.dart new file mode 100644 index 0000000..59fad62 --- /dev/null +++ b/lib/features/account/domain/account_models.dart @@ -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 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 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); +} diff --git a/lib/features/account/domain/account_repository.dart b/lib/features/account/domain/account_repository.dart new file mode 100644 index 0000000..f0a274e --- /dev/null +++ b/lib/features/account/domain/account_repository.dart @@ -0,0 +1,47 @@ +import 'package:ttstd_family_care/features/account/domain/account_models.dart'; + +/// 账号与安全仓储:登录设备管理、修改密码、更换手机、微信绑定。 +abstract class AccountRepository { + /// 登录过本账号的设备(会话)列表。 + Future> getLoginDevices(); + + /// 将指定登录设备(会话)下线。 + /// + /// 成功返回 null;失败返回错误提示。 + Future revokeLoginDevice(String id); + + /// 修改密码(校验原密码后设置新密码)。 + /// + /// 成功返回 null;失败返回错误提示。 + Future changePassword({ + required String oldPassword, + required String newPassword, + }); + + /// 发送更换手机号验证码到新手机号。 + /// + /// 成功返回 null;失败返回错误提示。 + Future sendChangeMobileCode(String newMobile); + + /// 更换绑定手机号(需新手机号验证码 + 账号密码校验)。 + /// + /// 成功返回 null;失败返回错误提示。 + Future changeMobile({ + required String newMobile, + required String code, + required String password, + }); + + /// 查询微信绑定状态。 + Future getWechatBinding(); + + /// 绑定微信。[authCode] 为微信授权登录返回的 code。 + /// + /// 成功返回 null;失败返回错误提示。 + Future bindWechat(String authCode); + + /// 解绑微信。 + /// + /// 成功返回 null;失败返回错误提示。 + Future unbindWechat(); +} diff --git a/lib/features/account/presentation/account_controller.dart b/lib/features/account/presentation/account_controller.dart new file mode 100644 index 0000000..06d286e --- /dev/null +++ b/lib/features/account/presentation/account_controller.dart @@ -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 { + 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 changePassword({ + required String oldPassword, + required String newPassword, + }) => + _run(() => ref.read(accountRepositoryProvider).changePassword( + oldPassword: oldPassword, + newPassword: newPassword, + )); + + /// 发送更换手机号验证码,成功后启动 60 秒倒计时。 + Future 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 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 bindWechat(String authCode) => _run( + () => ref.read(accountRepositoryProvider).bindWechat(authCode), + invalidateWechat: true, + ); + + /// 解绑微信,成功返回 true。 + Future unbindWechat() => _run( + () => ref.read(accountRepositoryProvider).unbindWechat(), + invalidateWechat: true, + ); + + /// 将指定登录设备下线,成功返回 true。 + Future 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 _run( + Future 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.new, +); diff --git a/lib/features/account/presentation/change_mobile_page.dart b/lib/features/account/presentation/change_mobile_page.dart new file mode 100644 index 0000000..8c0cf4b --- /dev/null +++ b/lib/features/account/presentation/change_mobile_page.dart @@ -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 createState() => _ChangeMobilePageState(); +} + +class _ChangeMobilePageState extends ConsumerState { + 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(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 _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 _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, + ), + ), + ), + ), + ); +} diff --git a/lib/features/account/presentation/change_password_page.dart b/lib/features/account/presentation/change_password_page.dart new file mode 100644 index 0000000..38d0cda --- /dev/null +++ b/lib/features/account/presentation/change_password_page.dart @@ -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 createState() => _ChangePasswordPageState(); +} + +class _ChangePasswordPageState extends ConsumerState { + 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(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 _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(); + } +} diff --git a/lib/features/account/presentation/login_devices_page.dart b/lib/features/account/presentation/login_devices_page.dart new file mode 100644 index 0000000..91c3a9d --- /dev/null +++ b/lib/features/account/presentation/login_devices_page.dart @@ -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(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 _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 = [ + 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, + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/account/presentation/security_page.dart b/lib/features/account/presentation/security_page.dart new file mode 100644 index 0000000..0919ee6 --- /dev/null +++ b/lib/features/account/presentation/security_page.dart @@ -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'), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/account/presentation/wechat_binding_page.dart b/lib/features/account/presentation/wechat_binding_page.dart new file mode 100644 index 0000000..c909b65 --- /dev/null +++ b/lib/features/account/presentation/wechat_binding_page.dart @@ -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(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 _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 _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, + ), + ), + ); +} diff --git a/lib/features/contacts/data/contact_api.dart b/lib/features/contacts/data/contact_api.dart new file mode 100644 index 0000000..ce8de0c --- /dev/null +++ b/lib/features/contacts/data/contact_api.dart @@ -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> 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 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 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 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 _parseList( + dynamic data, + T Function(Map) fromJson, + ) { + if (data is! List) return const []; + return data + .whereType>() + .map(fromJson) + .toList(growable: false); + } +} diff --git a/lib/features/contacts/data/contact_providers.dart b/lib/features/contacts/data/contact_providers.dart new file mode 100644 index 0000000..6e92ecb --- /dev/null +++ b/lib/features/contacts/data/contact_providers.dart @@ -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((ref) { + const api = ContactApi(); + return ContactRepositoryImpl(api); +}); + +/// 当前选中设备的联系人列表。 +/// +/// 依赖 [selectedDeviceSnProvider] 选择设备;未绑定设备时返回空列表。 +/// 通过 [autoDispose] 避免切换设备后残留旧数据。 +final contactsProvider = + FutureProvider.autoDispose>((ref) async { + final sn = ref.watch(selectedDeviceSnProvider); + if (sn == null || sn.isEmpty) return const []; + final repo = ref.watch(contactRepositoryProvider); + return repo.getContacts(sn); +}); diff --git a/lib/features/contacts/data/contact_repository_impl.dart b/lib/features/contacts/data/contact_repository_impl.dart new file mode 100644 index 0000000..4ec6a67 --- /dev/null +++ b/lib/features/contacts/data/contact_repository_impl.dart @@ -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> getContacts(String sn) => _api.getContacts(sn); + + @override + Future addContact(String sn, Contact contact) => + _api.addContact(sn, contact); + + @override + Future updateContact(String sn, Contact contact) => + _api.updateContact(sn, contact); + + @override + Future deleteContact(String sn, int id) => + _api.deleteContact(sn, id); +} diff --git a/lib/features/contacts/domain/contact_models.dart b/lib/features/contacts/domain/contact_models.dart new file mode 100644 index 0000000..c912104 --- /dev/null +++ b/lib/features/contacts/domain/contact_models.dart @@ -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 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 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; +} diff --git a/lib/features/contacts/domain/contact_repository.dart b/lib/features/contacts/domain/contact_repository.dart new file mode 100644 index 0000000..e5ab43a --- /dev/null +++ b/lib/features/contacts/domain/contact_repository.dart @@ -0,0 +1,22 @@ +import 'package:ttstd_family_care/features/contacts/domain/contact_models.dart'; + +/// 联系人仓储,封装家属端联系人列表/新增/修改/删除/表单获取。 +abstract class ContactRepository { + /// 获取指定设备的联系人列表(按 position 升序,由后台保证)。 + Future> getContacts(String sn); + + /// 新增联系人。 + /// + /// 成功返回 null;失败返回后台错误提示(如手机号重复)。 + Future addContact(String sn, Contact contact); + + /// 修改联系人。 + /// + /// 成功返回 null;失败返回后台错误提示。 + Future updateContact(String sn, Contact contact); + + /// 删除联系人。 + /// + /// 成功返回 null;失败返回后台错误提示。 + Future deleteContact(String sn, int id); +} diff --git a/lib/features/contacts/presentation/contact_detail_page.dart b/lib/features/contacts/presentation/contact_detail_page.dart new file mode 100644 index 0000000..f89871c --- /dev/null +++ b/lib/features/contacts/presentation/contact_detail_page.dart @@ -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 createState() => _ContactDetailPageState(); +} + +class _ContactDetailPageState extends ConsumerState { + late Contact _contact; + + @override + void initState() { + super.initState(); + _contact = widget.contact; + } + + Future _openEdit() async { + final updated = await context.push( + '/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 _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), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/contacts/presentation/contact_edit_page.dart b/lib/features/contacts/presentation/contact_edit_page.dart new file mode 100644 index 0000000..2cbe7f3 --- /dev/null +++ b/lib/features/contacts/presentation/contact_edit_page.dart @@ -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 createState() => _ContactEditPageState(); +} + +class _ContactEditPageState extends ConsumerState { + 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, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/contacts/presentation/contacts_page.dart b/lib/features/contacts/presentation/contacts_page.dart index af23cf9..0856e27 100644 --- a/lib/features/contacts/presentation/contacts_page.dart +++ b/lib/features/contacts/presentation/contacts_page.dart @@ -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 createState() => _ContactsPageState(); +} + +class _ContactsPageState extends ConsumerState { + + Future _refresh() async { + ref.invalidate(contactsProvider); + await ref.read(contactsProvider.future); + } + + void _onTap(Contact contact) { + context.push('/contacts/detail', extra: contact); + } + + Future _onAdd() async { + final sn = ref.read(selectedDeviceSnProvider); + if (sn == null || sn.isEmpty) return; + final result = await context.push('/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 _buildIndexBarData(List<_ContactAzItem> items) { + final tags = items.map((e) => e.getSuspensionTag()).toSet(); + const letters = [ + '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 = []; + 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; + ), + ); + } } diff --git a/lib/features/device/data/device_api.dart b/lib/features/device/data/device_api.dart index d16c9fd..19d5bd1 100644 --- a/lib/features/device/data/device_api.dart +++ b/lib/features/device/data/device_api.dart @@ -26,6 +26,41 @@ class DeviceApi { } } + /// 绑定设备(手动输入 SN 或扫码得到 SN 后调用)。 + /// + /// 成功返回 null;失败返回后台错误提示。 + Future 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 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 getDeviceInfo(String sn) async { try { @@ -160,4 +195,36 @@ class DeviceApi { ); }).toList(growable: false); } + + /// 删除单张设备截图(家属端 client 接口)。 + /// + /// [sn] 设备序列号,[id] 截图ID。成功返回 null;失败返回后台错误提示。 + Future 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 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; + } + } } diff --git a/lib/features/device/data/device_repository_impl.dart b/lib/features/device/data/device_repository_impl.dart index f73e87c..b3c9f44 100644 --- a/lib/features/device/data/device_repository_impl.dart +++ b/lib/features/device/data/device_repository_impl.dart @@ -12,6 +12,13 @@ class DeviceRepositoryImpl implements DeviceRepository { @override Future> getMyDevices() => _api.getMyDevices(); + @override + Future bindDevice(String sn, {String? remark}) => + _api.bindDevice(sn, remark: remark); + + @override + Future unbindDevice(String sn) => _api.unbindDevice(sn); + @override Future getDeviceInfo(String sn) => _api.getDeviceInfo(sn); @@ -32,4 +39,11 @@ class DeviceRepositoryImpl implements DeviceRepository { @override Future sendAppOp(String sn, String op, String packageName) => _api.sendAppOp(sn, op, packageName); + + @override + Future deleteScreenshot(String sn, int id) => + _api.deleteScreenshot(sn, id); + + @override + Future clearScreenshots(String sn) => _api.clearScreenshots(sn); } diff --git a/lib/features/device/domain/device_repository.dart b/lib/features/device/domain/device_repository.dart index 871c969..7418ea3 100644 --- a/lib/features/device/domain/device_repository.dart +++ b/lib/features/device/domain/device_repository.dart @@ -5,6 +5,16 @@ abstract class DeviceRepository { /// 当前用户已绑定的设备列表。 Future> getMyDevices(); + /// 绑定设备(手动输入 SN 或扫码获取 SN)。 + /// + /// 成功返回 null;失败返回错误提示。 + Future bindDevice(String sn, {String? remark}); + + /// 解绑设备。 + /// + /// 成功返回 null;失败返回错误提示。 + Future unbindDevice(String sn); + /// 已绑定设备基本信息(无上报数据时返回 null)。 Future getDeviceInfo(String sn); @@ -28,4 +38,14 @@ abstract class DeviceRepository { /// [op] 为后端操作标识:`launch` / `stop` / `uninstall` / `clear_data`。 /// 成功返回 null;失败返回错误提示。 Future sendAppOp(String sn, String op, String packageName); + + /// 删除单张设备截图(家属端 client 接口)。 + /// + /// [sn] 设备序列号,[id] 截图ID。成功返回 null;失败返回错误提示。 + Future deleteScreenshot(String sn, int id); + + /// 清空设备全部截图(家属端 client 接口)。 + /// + /// [sn] 设备序列号。成功返回 null;失败返回错误提示。 + Future clearScreenshots(String sn); } diff --git a/lib/features/device/presentation/device_bind_page.dart b/lib/features/device/presentation/device_bind_page.dart new file mode 100644 index 0000000..8b34864 --- /dev/null +++ b/lib/features/device/presentation/device_bind_page.dart @@ -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 createState() => _DeviceBindPageState(); +} + +class _DeviceBindPageState extends ConsumerState { + 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(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 _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 _openScanner(AppLocalizations l10n) async { + final code = await context.push('/device/scan'); + if (code == null || code.isEmpty || !mounted) return; + setState(() => _snController.text = code); + } +} diff --git a/lib/features/device/presentation/device_manage_controller.dart b/lib/features/device/presentation/device_manage_controller.dart new file mode 100644 index 0000000..454bf1e --- /dev/null +++ b/lib/features/device/presentation/device_manage_controller.dart @@ -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 { + @override + DeviceManageState build() => const DeviceManageState(); + + /// 绑定设备。[sn] 为设备序列号,[remark] 为可选备注名。 + /// + /// SN 为空等输入校验由 UI 层负责(便于使用本地化文案),此处只做兜底裁剪。 + /// 返回是否绑定成功。 + Future bind(String sn, {String? remark}) => + _run(() => ref.read(deviceRepositoryProvider).bindDevice( + sn.trim(), + remark: remark?.trim(), + )); + + /// 解绑设备。返回是否解绑成功。 + Future 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 _run(Future 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.new, +); diff --git a/lib/features/device/presentation/device_manage_page.dart b/lib/features/device/presentation/device_manage_page.dart new file mode 100644 index 0000000..cf1c015 --- /dev/null +++ b/lib/features/device/presentation/device_manage_page.dart @@ -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(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 _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, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/device/presentation/device_scan_page.dart b/lib/features/device/presentation/device_scan_page.dart new file mode 100644 index 0000000..6b7e355 --- /dev/null +++ b/lib/features/device/presentation/device_scan_page.dart @@ -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 createState() => _DeviceScanPageState(); +} + +class _DeviceScanPageState extends State { + 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 _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 _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, + ), + ), + ], + ), + ), + ), + ); +} diff --git a/lib/features/home/data/location_controller.dart b/lib/features/home/data/location_controller.dart index bf6509e..78a830b 100644 --- a/lib/features/home/data/location_controller.dart +++ b/lib/features/home/data/location_controller.dart @@ -195,6 +195,21 @@ class LocationController extends Notifier { stop(); } + /// 重新启动连续定位(供地图「定位到本机」按钮手动触发)。 + /// + /// 若之前已停止(拿到首个结果后自动停止),可再次启动以获取最新位置; + /// 已启动时调用仅重新注册回调、不重复初始化。 + void restart() { + if (_plugin == null) { + // 插件尚未初始化(极端时序下),从头走初始化流程。 + _started = false; + _init(); + return; + } + _started = true; + _plugin?.startLocation(); + } + /// 停止连续定位(保留插件实例,可再次 [startLocation])。 /// /// 本机定位拿到首个有效结果后即停止,符合「有结果且非定位错误就停止定位」的约束。 diff --git a/lib/features/home/presentation/home_tab.dart b/lib/features/home/presentation/home_tab.dart index e4c44e9..08a32d4 100644 --- a/lib/features/home/presentation/home_tab.dart +++ b/lib/features/home/presentation/home_tab.dart @@ -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)), ], diff --git a/lib/features/home/presentation/map_page.dart b/lib/features/home/presentation/map_page.dart index b1b53e2..0399910 100644 --- a/lib/features/home/presentation/map_page.dart +++ b/lib/features/home/presentation/map_page.dart @@ -27,6 +27,8 @@ class MapPage extends ConsumerStatefulWidget { class _MapPageState extends ConsumerState { BMFMapController? _mapController; bool _markerAdded = false; + // 是否正在「定位到我」:点击定位按钮后置 true,拿到本机定位后归 false。 + bool _isLocatingSelf = false; @override void dispose() { @@ -55,13 +57,19 @@ class _MapPageState extends ConsumerState { ); // 监听本机定位结果:定位成功后把蓝色定位点更新到地图(BMFUserLocation)。 + // 若正处于「定位到我」流程,则把地图中心同步到本机位置并复位标记。 ref.listen( 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 { ), ), _buildOverlay(locationAsync, l10n), + _buildLocateButton(l10n), ], ), ), @@ -141,6 +150,63 @@ class _MapPageState extends ConsumerState { 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; diff --git a/lib/features/home/presentation/screenshot_page.dart b/lib/features/home/presentation/screenshot_page.dart index fb064d7..4282a2c 100644 --- a/lib/features/home/presentation/screenshot_page.dart +++ b/lib/features/home/presentation/screenshot_page.dart @@ -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 screenshots; - - /// 初始展示的截图索引。 + final String sn; final int initialIndex; @override - State createState() => _ScreenshotPageState(); + ConsumerState createState() => _ScreenshotPageState(); } -class _ScreenshotPageState extends State { +class _ScreenshotPageState extends ConsumerState { late final PageController _pageController; + late List _screenshots; late int _currentIndex; @override void initState() { super.initState(); - _currentIndex = widget.initialIndex; - _pageController = PageController(initialPage: widget.initialIndex); + _screenshots = List.from(widget.screenshots); + _currentIndex = widget.initialIndex.clamp(0, _screenshots.length - 1); + _pageController = PageController(initialPage: _currentIndex); } @override @@ -44,66 +41,297 @@ class _ScreenshotPageState extends State { super.dispose(); } + /// 刷新截图列表:本地移除后立即失效源 Provider, + /// 返回首页时最近截图列表会自动重新拉取。 + Future _refreshSource() async { + ref.invalidate(recentScreenshotsProvider(widget.sn)); + } + + void _deleteCurrent() { + final l10n = AppLocalizations.of(context); + + showCupertinoDialog( + 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( + 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( + 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), - ); - }), - ), ], ), ), diff --git a/lib/features/messages/presentation/messages_tab.dart b/lib/features/messages/presentation/messages_tab.dart index 3f90fa8..d47c2f7 100644 --- a/lib/features/messages/presentation/messages_tab.dart +++ b/lib/features/messages/presentation/messages_tab.dart @@ -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( diff --git a/lib/features/profile/presentation/profile_tab.dart b/lib/features/profile/presentation/profile_tab.dart index 82d66d8..c245a90 100644 --- a/lib/features/profile/presentation/profile_tab.dart +++ b/lib/features/profile/presentation/profile_tab.dart @@ -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, + ); + } } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index ef588d9..64740ac 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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 diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 008bdc0..2f0eb64 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 7d55d1a..af32cf3 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -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 => '加载失败'; } diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 55b5ac7..fe9898c 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -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" } diff --git a/lib/l10n/intl_zh.arb b/lib/l10n/intl_zh.arb index ea1a642..14a070f 100644 --- a/lib/l10n/intl_zh.arb +++ b/lib/l10n/intl_zh.arb @@ -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": "通用:加载失败" } } diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index e3e70dc..98aea20 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -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. diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 38535f0..bd437dc 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,11 +6,15 @@ #include "generated_plugin_registrant.h" +#include #include #include #include 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); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 829cb4a..5504052 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux flutter_secure_storage_linux flutter_webrtc mmkv_linux diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index ce83a5e..c59e5c3 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) } diff --git a/pubspec.lock b/pubspec.lock index f090894..a569f06 100644 --- a/pubspec.lock +++ b/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: diff --git a/pubspec.yaml b/pubspec.yaml index ac3662c..ed52a37 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 30d83a3..ddd0c48 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,12 +6,15 @@ #include "generated_plugin_registrant.h" +#include #include #include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); FlutterWebRTCPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index fa4ea08..d6ee694 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows flutter_secure_storage_windows flutter_webrtc mmkv_win32