feat: 新增设备绑定、扫码与联系人管理功能

This commit is contained in:
TongTongStudio
2026-08-21 17:12:17 +08:00
parent 06cdc77172
commit 98de1e1132
49 changed files with 5699 additions and 204 deletions

View File

@@ -22,6 +22,13 @@
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<!-- 扫码绑定设备:相机实时扫码 -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- Android 13+ 从相册选取二维码图片 -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<!-- 相机为可选硬件,无摄像头设备仍可从相册识别二维码 -->
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
<application

View File

@@ -77,6 +77,10 @@
<string>用于在地图上展示设备位置</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>用于在地图上展示设备位置</string>
<key>NSCameraUsageDescription</key>
<string>用于扫描设备二维码完成绑定</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>用于从相册选取二维码图片完成设备绑定</string>
<key>io.flutter.embedded_views_preview</key>
<true/>
</dict>

View File

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

View File

@@ -0,0 +1,322 @@
import 'package:cupertino_ui/cupertino_ui.dart';
import 'feature_ui.dart';
/// 设置类页面通用组件(设备管理、账号与安全等子页共用)。
///
/// 统一「圆角图标块 + 标题/副标题 + 尾部控件」的行样式,以及带标签的输入框、
/// 主按钮与次按钮,避免各页面重复书写冗长的内联样式。
/// 可点击的设置列表行。
///
/// [emoji] + [color] 组成左侧圆角图标块;[trailing] 为空时展示默认的 `›` 箭头。
class SettingsRow extends StatelessWidget {
const SettingsRow({
required this.emoji,
required this.color,
required this.title,
this.subtitle,
this.value,
this.trailing,
this.onTap,
super.key,
});
final String emoji;
final Color color;
final String title;
final String? subtitle;
/// 右侧只读文案(如当前手机号、绑定状态)。
final String? value;
/// 自定义尾部控件,优先级高于 [value] 与默认箭头。
final Widget? trailing;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final row = Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(11),
),
child: Center(child: Text(emoji, style: const TextStyle(fontSize: 17))),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 14.5,
fontWeight: FontWeight.w600,
color: AppColors.ink,
),
),
if (subtitle != null) ...[
const SizedBox(height: 2),
Text(
subtitle!,
style: const TextStyle(fontSize: 12, color: AppColors.sub),
),
],
],
),
),
if (trailing != null)
trailing!
else ...[
if (value != null)
Text(
value!,
style: const TextStyle(fontSize: 13, color: AppColors.sub),
),
const SizedBox(width: 6),
const Text('›', style: TextStyle(fontSize: 16, color: Color(0xFFCBD2DC))),
],
],
),
);
if (onTap == null) return row;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: row,
);
}
}
/// 带标签的输入框(标签在上、输入框在下的表单样式)。
class LabeledField extends StatelessWidget {
const LabeledField({
required this.label,
required this.controller,
this.placeholder,
this.obscureText = false,
this.keyboardType,
this.maxLength,
this.enabled = true,
this.suffix,
super.key,
});
final String label;
final TextEditingController controller;
final String? placeholder;
final bool obscureText;
final TextInputType? keyboardType;
final int? maxLength;
final bool enabled;
/// 输入框右侧控件(如「获取验证码」按钮)。
final Widget? suffix;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: AppColors.sub,
),
),
const SizedBox(height: 6),
Row(
children: [
Expanded(
child: CupertinoTextField(
controller: controller,
placeholder: placeholder,
obscureText: obscureText,
keyboardType: keyboardType,
maxLength: maxLength,
enabled: enabled,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
style: const TextStyle(fontSize: 15, color: AppColors.ink),
placeholderStyle: const TextStyle(fontSize: 14, color: AppColors.sub),
decoration: BoxDecoration(
color: enabled ? CupertinoColors.white : AppColors.track,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFE6EAF0)),
),
),
),
if (suffix != null) ...[
const SizedBox(width: 10),
suffix!,
],
],
),
],
),
);
}
/// 实心主按钮(品牌绿),[loading] 时展示指示器并禁用点击。
class PrimaryButton extends StatelessWidget {
const PrimaryButton({
required this.text,
this.onPressed,
this.loading = false,
this.color = AppColors.green,
super.key,
});
final String text;
final VoidCallback? onPressed;
final bool loading;
final Color color;
@override
Widget build(BuildContext context) {
final disabled = loading || onPressed == null;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: GestureDetector(
onTap: disabled ? null : onPressed,
child: Container(
height: 48,
decoration: BoxDecoration(
color: disabled ? color.withValues(alpha: 0.5) : color,
borderRadius: BorderRadius.circular(14),
),
child: Center(
child: loading
? const CupertinoActivityIndicator(color: CupertinoColors.white)
: Text(
text,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: CupertinoColors.white,
),
),
),
),
),
);
}
}
/// 描边次按钮(用于「扫码绑定」「从相册选取」等辅助操作)。
class SecondaryButton extends StatelessWidget {
const SecondaryButton({
required this.text,
this.onPressed,
this.color = AppColors.green,
this.icon,
super.key,
});
final String text;
final VoidCallback? onPressed;
final Color color;
final IconData? icon;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: GestureDetector(
onTap: onPressed,
child: Container(
height: 48,
decoration: BoxDecoration(
color: CupertinoColors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: color, width: 1.2),
),
child: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null) ...[
Icon(icon, size: 18, color: color),
const SizedBox(width: 8),
],
Text(
text,
style: TextStyle(
fontSize: 15.5,
fontWeight: FontWeight.w700,
color: color,
),
),
],
),
),
),
),
);
}
/// 统一的提示弹窗与确认弹窗工具。
class AppDialogs {
const AppDialogs._();
/// 单按钮提示弹窗。
static Future<void> alert(
BuildContext context, {
String? title,
required String message,
required String confirmText,
}) =>
showCupertinoDialog<void>(
context: context,
builder: (ctx) => CupertinoAlertDialog(
title: title == null ? null : Text(title),
content: Text(message),
actions: [
CupertinoDialogAction(
isDefaultAction: true,
onPressed: () => Navigator.of(ctx).pop(),
child: Text(confirmText),
),
],
),
);
/// 二次确认弹窗,返回用户是否确认。
static Future<bool> confirm(
BuildContext context, {
String? title,
required String message,
required String confirmText,
required String cancelText,
bool destructive = true,
}) async {
final result = await showCupertinoDialog<bool>(
context: context,
builder: (ctx) => CupertinoAlertDialog(
title: title == null ? null : Text(title),
content: Text(message),
actions: [
CupertinoDialogAction(
onPressed: () => Navigator.of(ctx).pop(false),
child: Text(cancelText),
),
CupertinoDialogAction(
isDestructiveAction: destructive,
onPressed: () => Navigator.of(ctx).pop(true),
child: Text(confirmText),
),
],
),
);
return result ?? false;
}
}

View File

@@ -0,0 +1,113 @@
import 'package:cupertino_ui/cupertino_ui.dart';
import 'package:ttstd_family_care/core/network/api_exception.dart';
import 'package:ttstd_family_care/core/network/dio_client.dart';
import 'package:ttstd_family_care/features/account/domain/account_models.dart';
/// 账号与安全接口访问层。
///
/// 后端接口前缀为 `/api/v1/open/account`(与设备接口所在的 `/api/v1/client` 不同)。
/// [DioClient] 已统一解析后台返回体的 `data` 字段,业务失败时抛出 [ApiException];
/// 本层将查询类接口的异常降级为空数据,写操作则把错误提示回传给控制器。
class AccountApi {
const AccountApi();
/// 登录设备(会话)列表。
Future<List<LoginDevice>> getLoginDevices() async {
try {
final data = await DioClient.get('account/login-devices');
return _parseList(data, LoginDevice.fromJson);
} on ApiException catch (e) {
debugPrint('[AccountApi] getLoginDevices failed: ${e.message}');
return const [];
}
}
/// 将指定登录设备下线。
Future<String?> revokeLoginDevice(String id) =>
_write(() => DioClient.post(
'account/login-devices/revoke',
queryParameters: {'id': id},
));
/// 修改密码。
Future<String?> changePassword({
required String oldPassword,
required String newPassword,
}) =>
_write(() => DioClient.post(
'account/password',
data: {'oldPassword': oldPassword, 'newPassword': newPassword},
));
/// 发送更换手机号验证码到新手机号。
Future<String?> sendChangeMobileCode(String newMobile) =>
_write(() => DioClient.post(
'account/mobile/sms/code',
queryParameters: {'mobile': newMobile},
));
/// 更换绑定手机号。
Future<String?> changeMobile({
required String newMobile,
required String code,
required String password,
}) =>
_write(() => DioClient.post(
'account/mobile',
data: {
'mobile': newMobile,
'code': code,
'password': password,
},
));
/// 查询微信绑定状态。
Future<WechatBinding> getWechatBinding() async {
try {
final data = await DioClient.get('account/wechat');
if (data is Map<String, dynamic>) return WechatBinding.fromJson(data);
return WechatBinding.unbound;
} on ApiException catch (e) {
debugPrint('[AccountApi] getWechatBinding failed: ${e.message}');
return WechatBinding.unbound;
}
}
/// 绑定微信。
Future<String?> bindWechat(String authCode) => _write(() => DioClient.post(
'account/wechat/bind',
data: {'code': authCode},
));
/// 解绑微信。
Future<String?> unbindWechat() =>
_write(() => DioClient.post('account/wechat/unbind'));
/// 统一处理写操作:成功返回 null,业务失败返回后台提示文案。
Future<String?> _write(Future<dynamic> Function() call) async {
try {
await call();
return null;
} on ApiException catch (e) {
return e.message;
}
}
/// 兼容后台可能返回「数组」或「分页对象(含 list/records)」两种结构。
List<T> _parseList<T>(
dynamic data,
T Function(Map<String, dynamic>) fromJson,
) {
Iterable<dynamic>? raw;
if (data is List) {
raw = data;
} else if (data is Map<String, dynamic>) {
raw = (data['list'] ?? data['records']) as Iterable<dynamic>?;
}
if (raw == null) return const [];
return raw
.whereType<Map<String, dynamic>>()
.map(fromJson)
.toList(growable: false);
}
}

View File

@@ -0,0 +1,23 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:ttstd_family_care/features/account/data/account_api.dart';
import 'package:ttstd_family_care/features/account/data/account_repository_impl.dart';
import 'package:ttstd_family_care/features/account/domain/account_models.dart';
import 'package:ttstd_family_care/features/account/domain/account_repository.dart';
/// 账号与安全仓储 Provider。
final accountRepositoryProvider = Provider<AccountRepository>((ref) {
const api = AccountApi();
return AccountRepositoryImpl(api);
});
/// 登录设备(会话)列表。
final loginDevicesProvider = FutureProvider<List<LoginDevice>>((ref) async {
final repo = ref.watch(accountRepositoryProvider);
return repo.getLoginDevices();
});
/// 微信绑定状态。
final wechatBindingProvider = FutureProvider<WechatBinding>((ref) async {
final repo = ref.watch(accountRepositoryProvider);
return repo.getWechatBinding();
});

View File

@@ -0,0 +1,44 @@
import 'package:ttstd_family_care/features/account/data/account_api.dart';
import 'package:ttstd_family_care/features/account/domain/account_models.dart';
import 'package:ttstd_family_care/features/account/domain/account_repository.dart';
/// [AccountRepository] 的默认实现,委托给 [AccountApi]。
class AccountRepositoryImpl implements AccountRepository {
const AccountRepositoryImpl(this._api);
final AccountApi _api;
@override
Future<List<LoginDevice>> getLoginDevices() => _api.getLoginDevices();
@override
Future<String?> revokeLoginDevice(String id) => _api.revokeLoginDevice(id);
@override
Future<String?> changePassword({
required String oldPassword,
required String newPassword,
}) =>
_api.changePassword(oldPassword: oldPassword, newPassword: newPassword);
@override
Future<String?> sendChangeMobileCode(String newMobile) =>
_api.sendChangeMobileCode(newMobile);
@override
Future<String?> changeMobile({
required String newMobile,
required String code,
required String password,
}) =>
_api.changeMobile(newMobile: newMobile, code: code, password: password);
@override
Future<WechatBinding> getWechatBinding() => _api.getWechatBinding();
@override
Future<String?> bindWechat(String authCode) => _api.bindWechat(authCode);
@override
Future<String?> unbindWechat() => _api.unbindWechat();
}

View File

@@ -0,0 +1,82 @@
/// 账号与安全相关领域模型。
///
/// 纯 Dart 实体,零 Flutter/UI 依赖(见 AGENTS.md §3)。
library;
/// 登录设备(会话)记录。
///
/// 对应「账号与安全 → 登录设备」列表项,展示登录过本账号的终端信息。
class LoginDevice {
const LoginDevice({
required this.id,
this.deviceName,
this.deviceModel,
this.osName,
this.location,
this.ip,
this.lastActiveTime,
this.current = false,
});
/// 会话唯一标识,用于「下线」操作。
final String id;
final String? deviceName;
final String? deviceModel;
final String? osName;
/// 登录地点(后台按 IP 解析)。
final String? location;
final String? ip;
/// 最近活跃时间。
final String? lastActiveTime;
/// 是否为当前设备(当前设备不允许下线)。
final bool current;
/// 列表主标题:优先设备名,其次型号,最后兜底会话 ID。
String get displayName {
if (deviceName != null && deviceName!.isNotEmpty) return deviceName!;
if (deviceModel != null && deviceModel!.isNotEmpty) return deviceModel!;
return id;
}
factory LoginDevice.fromJson(Map<String, dynamic> json) => LoginDevice(
id: (json['id'] ?? json['sessionId'] ?? '').toString(),
deviceName: json['deviceName'] as String?,
deviceModel: json['deviceModel'] as String?,
osName: json['osName'] as String?,
location: json['location'] as String?,
ip: json['ip'] as String?,
lastActiveTime: json['lastActiveTime']?.toString(),
current: json['current'] == true,
);
}
/// 微信绑定状态。
class WechatBinding {
const WechatBinding({
required this.bound,
this.nickname,
this.avatarUrl,
this.bindTime,
});
/// 是否已绑定微信。
final bool bound;
final String? nickname;
final String? avatarUrl;
final String? bindTime;
factory WechatBinding.fromJson(Map<String, dynamic> json) => WechatBinding(
bound: json['bound'] == true,
nickname: json['nickname'] as String?,
avatarUrl: json['avatarUrl'] as String?,
bindTime: json['bindTime']?.toString(),
);
/// 未绑定态的默认值(接口无数据时兜底)。
static const WechatBinding unbound = WechatBinding(bound: false);
}

View File

@@ -0,0 +1,47 @@
import 'package:ttstd_family_care/features/account/domain/account_models.dart';
/// 账号与安全仓储:登录设备管理、修改密码、更换手机、微信绑定。
abstract class AccountRepository {
/// 登录过本账号的设备(会话)列表。
Future<List<LoginDevice>> getLoginDevices();
/// 将指定登录设备(会话)下线。
///
/// 成功返回 null;失败返回错误提示。
Future<String?> revokeLoginDevice(String id);
/// 修改密码(校验原密码后设置新密码)。
///
/// 成功返回 null;失败返回错误提示。
Future<String?> changePassword({
required String oldPassword,
required String newPassword,
});
/// 发送更换手机号验证码到新手机号。
///
/// 成功返回 null;失败返回错误提示。
Future<String?> sendChangeMobileCode(String newMobile);
/// 更换绑定手机号(需新手机号验证码 + 账号密码校验)。
///
/// 成功返回 null;失败返回错误提示。
Future<String?> changeMobile({
required String newMobile,
required String code,
required String password,
});
/// 查询微信绑定状态。
Future<WechatBinding> getWechatBinding();
/// 绑定微信。[authCode] 为微信授权登录返回的 code。
///
/// 成功返回 null;失败返回错误提示。
Future<String?> bindWechat(String authCode);
/// 解绑微信。
///
/// 成功返回 null;失败返回错误提示。
Future<String?> unbindWechat();
}

View File

@@ -0,0 +1,161 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:ttstd_family_care/core/network/error_message.dart';
import 'package:ttstd_family_care/features/account/data/account_providers.dart';
/// 账号与安全写操作状态。
///
/// [alert] 为一次性提示,UI 展示后需调用 [AccountController.consumeAlert] 消费;
/// [countdown] 为更换手机号场景的验证码倒计时秒数(0 表示可再次发送)。
class AccountActionState {
const AccountActionState({
this.submitting = false,
this.sendingCode = false,
this.countdown = 0,
this.alert,
});
final bool submitting;
final bool sendingCode;
final int countdown;
final String? alert;
AccountActionState copyWith({
bool? submitting,
bool? sendingCode,
int? countdown,
String? alert,
}) =>
AccountActionState(
submitting: submitting ?? this.submitting,
sendingCode: sendingCode ?? this.sendingCode,
countdown: countdown ?? this.countdown,
alert: alert,
);
}
/// 账号与安全控制器:修改密码、更换手机、微信绑定、登录设备下线。
///
/// 页面级控制器,**不使用** keepAlive;页面销毁时自动取消倒计时定时器。
class AccountController extends Notifier<AccountActionState> {
Timer? _timer;
@override
AccountActionState build() {
ref.onDispose(() => _timer?.cancel());
return const AccountActionState();
}
/// 消费一次性提示,避免重复弹窗。
void consumeAlert() {
if (state.alert != null) state = state.copyWith(alert: null);
}
/// 修改密码,成功返回 true。
Future<bool> changePassword({
required String oldPassword,
required String newPassword,
}) =>
_run(() => ref.read(accountRepositoryProvider).changePassword(
oldPassword: oldPassword,
newPassword: newPassword,
));
/// 发送更换手机号验证码,成功后启动 60 秒倒计时。
Future<bool> sendChangeMobileCode(String newMobile) async {
if (state.sendingCode || state.countdown > 0) return false;
state = state.copyWith(sendingCode: true, alert: null);
try {
final error =
await ref.read(accountRepositoryProvider).sendChangeMobileCode(
newMobile.trim(),
);
if (error != null) {
state = state.copyWith(sendingCode: false, alert: error);
return false;
}
state = state.copyWith(sendingCode: false, countdown: 60);
_startCountdown();
return true;
} catch (e) {
state = state.copyWith(sendingCode: false, alert: userMessageOf(e));
return false;
}
}
/// 更换绑定手机号,成功返回 true。
Future<bool> changeMobile({
required String newMobile,
required String code,
required String password,
}) =>
_run(() => ref.read(accountRepositoryProvider).changeMobile(
newMobile: newMobile.trim(),
code: code.trim(),
password: password,
));
/// 绑定微信([authCode] 由微信 SDK 授权回调提供),成功返回 true。
Future<bool> bindWechat(String authCode) => _run(
() => ref.read(accountRepositoryProvider).bindWechat(authCode),
invalidateWechat: true,
);
/// 解绑微信,成功返回 true。
Future<bool> unbindWechat() => _run(
() => ref.read(accountRepositoryProvider).unbindWechat(),
invalidateWechat: true,
);
/// 将指定登录设备下线,成功返回 true。
Future<bool> revokeLoginDevice(String id) => _run(
() => ref.read(accountRepositoryProvider).revokeLoginDevice(id),
invalidateLoginDevices: true,
);
/// 每秒递减倒计时,归零后自动停止。
void _startCountdown() {
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
final next = state.countdown - 1;
if (next <= 0) {
timer.cancel();
state = state.copyWith(countdown: 0, alert: state.alert);
} else {
state = state.copyWith(countdown: next, alert: state.alert);
}
});
}
/// 统一执行写操作:管理 submitting 开关、错误文案与相关列表刷新。
Future<bool> _run(
Future<String?> Function() action, {
bool invalidateWechat = false,
bool invalidateLoginDevices = false,
}) async {
if (state.submitting) return false;
state = state.copyWith(submitting: true, alert: null);
try {
final error = await action();
if (error != null) {
state = state.copyWith(submitting: false, alert: error);
return false;
}
if (invalidateWechat) ref.invalidate(wechatBindingProvider);
if (invalidateLoginDevices) ref.invalidate(loginDevicesProvider);
state = state.copyWith(submitting: false);
return true;
} catch (e) {
// 统一文案映射,禁止直接 toString()(见 AGENTS.md §12)。
state = state.copyWith(submitting: false, alert: userMessageOf(e));
return false;
}
}
}
/// 账号与安全控制器 Provider(页面级,不 keepAlive)。
final accountControllerProvider =
NotifierProvider<AccountController, AccountActionState>(
AccountController.new,
);

View File

@@ -0,0 +1,219 @@
import 'package:cupertino_ui/cupertino_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/widgets/feature_ui.dart';
import '../../../core/widgets/settings_ui.dart';
import '../../../l10n/app_localizations.dart';
import 'account_controller.dart';
/// 更换手机页(账号与安全 → 更换手机)。
///
/// 流程:输入新手机号 → 获取验证码(60s 倒计时)→ 输入验证码与账号密码 → 提交。
class ChangeMobilePage extends ConsumerStatefulWidget {
const ChangeMobilePage({super.key, this.currentMobile});
/// 当前绑定手机号,用于顶部脱敏展示(可选)。
final String? currentMobile;
@override
ConsumerState<ChangeMobilePage> createState() => _ChangeMobilePageState();
}
class _ChangeMobilePageState extends ConsumerState<ChangeMobilePage> {
final TextEditingController _mobileController = TextEditingController();
final TextEditingController _codeController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
/// 中国大陆手机号校验。
static final RegExp _mobileRule = RegExp(r'^1[3-9]\d{9}$');
@override
void dispose() {
_mobileController.dispose();
_codeController.dispose();
_passwordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final action = ref.watch(accountControllerProvider);
ref.listen<AccountActionState>(accountControllerProvider, (prev, next) {
final alert = next.alert;
if (alert == null) return;
ref.read(accountControllerProvider.notifier).consumeAlert();
AppDialogs.alert(context, message: alert, confirmText: l10n.commonConfirm);
});
final counting = action.countdown > 0;
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text(l10n.changeMobileTitle),
leading: CupertinoButton(
padding: EdgeInsets.zero,
child: const Icon(CupertinoIcons.back),
onPressed: () => context.pop(),
),
),
child: SafeArea(
child: ListView(
padding: const EdgeInsets.only(top: 8, bottom: 24),
children: [
if (widget.currentMobile?.isNotEmpty == true)
CardBox(
padding: EdgeInsets.zero,
child: SettingsRow(
emoji: '📞',
color: AppColors.gray,
title: l10n.changeMobileCurrent,
value: _mask(widget.currentMobile!),
trailing: Text(
_mask(widget.currentMobile!),
style: const TextStyle(fontSize: 13, color: AppColors.sub),
),
),
),
CardBox(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
children: [
LabeledField(
label: l10n.changeMobileNew,
controller: _mobileController,
keyboardType: TextInputType.phone,
maxLength: 11,
),
LabeledField(
label: l10n.changeMobileCode,
controller: _codeController,
keyboardType: TextInputType.number,
maxLength: 6,
suffix: _CodeButton(
text: counting
? l10n.changeMobileResendIn(action.countdown)
: l10n.changeMobileSendCode,
enabled: !counting && !action.sendingCode,
loading: action.sendingCode,
onPressed: () => _sendCode(l10n),
),
),
LabeledField(
label: l10n.changeMobilePassword,
controller: _passwordController,
obscureText: true,
),
],
),
),
const SizedBox(height: 6),
PrimaryButton(
text: l10n.commonSave,
loading: action.submitting,
onPressed: () => _submit(l10n),
),
],
),
),
);
}
/// 校验新手机号后发送验证码。
Future<void> _sendCode(AppLocalizations l10n) async {
final mobile = _mobileController.text.trim();
if (!_mobileRule.hasMatch(mobile)) {
await AppDialogs.alert(
context,
message: l10n.changeMobileInvalid,
confirmText: l10n.commonConfirm,
);
return;
}
await ref.read(accountControllerProvider.notifier).sendChangeMobileCode(mobile);
}
/// 校验三项输入后提交更换请求。
Future<void> _submit(AppLocalizations l10n) async {
final mobile = _mobileController.text.trim();
final code = _codeController.text.trim();
final password = _passwordController.text;
final error = !_mobileRule.hasMatch(mobile)
? l10n.changeMobileInvalid
: code.isEmpty
? l10n.changeMobileCodeEmpty
: password.isEmpty
? l10n.changeMobilePasswordEmpty
: null;
if (error != null) {
await AppDialogs.alert(
context,
message: error,
confirmText: l10n.commonConfirm,
);
return;
}
final ok = await ref.read(accountControllerProvider.notifier).changeMobile(
newMobile: mobile,
code: code,
password: password,
);
if (!ok || !mounted) return;
await AppDialogs.alert(
context,
message: l10n.changeMobileSuccess,
confirmText: l10n.commonConfirm,
);
if (mounted) context.pop();
}
/// 手机号脱敏:保留前 3 后 4 位。
String _mask(String mobile) {
if (mobile.length < 7) return mobile;
return '${mobile.substring(0, 3)}****${mobile.substring(mobile.length - 4)}';
}
}
/// 「获取验证码 / 倒计时」按钮。
class _CodeButton extends StatelessWidget {
const _CodeButton({
required this.text,
required this.enabled,
required this.loading,
required this.onPressed,
});
final String text;
final bool enabled;
final bool loading;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) => GestureDetector(
onTap: enabled ? onPressed : null,
child: Container(
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: enabled ? AppColors.green : AppColors.track,
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: loading
? const CupertinoActivityIndicator(color: CupertinoColors.white)
: Text(
text,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: enabled ? CupertinoColors.white : AppColors.sub,
),
),
),
),
);
}

View File

@@ -0,0 +1,138 @@
import 'package:cupertino_ui/cupertino_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/widgets/feature_ui.dart';
import '../../../core/widgets/settings_ui.dart';
import '../../../l10n/app_localizations.dart';
import 'account_controller.dart';
/// 修改密码页(账号与安全 → 修改密码)。
///
/// 校验规则:新密码 8-20 位且同时包含字母与数字,两次输入需一致。
class ChangePasswordPage extends ConsumerStatefulWidget {
const ChangePasswordPage({super.key});
@override
ConsumerState<ChangePasswordPage> createState() => _ChangePasswordPageState();
}
class _ChangePasswordPageState extends ConsumerState<ChangePasswordPage> {
final TextEditingController _oldController = TextEditingController();
final TextEditingController _newController = TextEditingController();
final TextEditingController _confirmController = TextEditingController();
/// 新密码规则:8-20 位,至少各含一个字母与数字。
static final RegExp _passwordRule =
RegExp(r'^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d!@#$%^&*()_+\-=]{8,20}$');
@override
void dispose() {
_oldController.dispose();
_newController.dispose();
_confirmController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final action = ref.watch(accountControllerProvider);
ref.listen<AccountActionState>(accountControllerProvider, (prev, next) {
final alert = next.alert;
if (alert == null) return;
ref.read(accountControllerProvider.notifier).consumeAlert();
AppDialogs.alert(context, message: alert, confirmText: l10n.commonConfirm);
});
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text(l10n.changePasswordTitle),
leading: CupertinoButton(
padding: EdgeInsets.zero,
child: const Icon(CupertinoIcons.back),
onPressed: () => context.pop(),
),
),
child: SafeArea(
child: ListView(
padding: const EdgeInsets.only(top: 8, bottom: 24),
children: [
CardBox(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
children: [
LabeledField(
label: l10n.changePasswordOld,
controller: _oldController,
obscureText: true,
),
LabeledField(
label: l10n.changePasswordNew,
controller: _newController,
obscureText: true,
),
LabeledField(
label: l10n.changePasswordConfirmField,
controller: _confirmController,
obscureText: true,
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(22, 6, 22, 2),
child: Text(
l10n.changePasswordRule,
style: const TextStyle(fontSize: 12, color: AppColors.sub, height: 1.5),
),
),
const SizedBox(height: 6),
PrimaryButton(
text: l10n.commonSave,
loading: action.submitting,
onPressed: () => _submit(l10n),
),
],
),
),
);
}
/// 前端校验通过后提交修改,成功则提示并返回。
Future<void> _submit(AppLocalizations l10n) async {
final oldPwd = _oldController.text;
final newPwd = _newController.text;
final confirm = _confirmController.text;
if (!_passwordRule.hasMatch(newPwd)) {
await AppDialogs.alert(
context,
message: l10n.changePasswordInvalid,
confirmText: l10n.commonConfirm,
);
return;
}
if (newPwd != confirm) {
await AppDialogs.alert(
context,
message: l10n.changePasswordMismatch,
confirmText: l10n.commonConfirm,
);
return;
}
final ok = await ref.read(accountControllerProvider.notifier).changePassword(
oldPassword: oldPwd,
newPassword: newPwd,
);
if (!ok || !mounted) return;
await AppDialogs.alert(
context,
message: l10n.changePasswordSuccess,
confirmText: l10n.commonConfirm,
);
if (mounted) context.pop();
}
}

View File

@@ -0,0 +1,201 @@
import 'package:cupertino_ui/cupertino_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/widgets/feature_ui.dart';
import '../../../core/widgets/settings_ui.dart';
import '../../../l10n/app_localizations.dart';
import '../data/account_providers.dart';
import '../domain/account_models.dart';
import 'account_controller.dart';
/// 登录设备页(账号与安全 → 登录设备)。
///
/// 列出登录过本账号的终端,非当前设备可执行「下线」操作。
class LoginDevicesPage extends ConsumerWidget {
const LoginDevicesPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final devicesAsync = ref.watch(loginDevicesProvider);
final action = ref.watch(accountControllerProvider);
ref.listen<AccountActionState>(accountControllerProvider, (prev, next) {
final alert = next.alert;
if (alert == null) return;
ref.read(accountControllerProvider.notifier).consumeAlert();
AppDialogs.alert(context, message: alert, confirmText: l10n.commonConfirm);
});
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text(l10n.loginDevicesTitle),
leading: CupertinoButton(
padding: EdgeInsets.zero,
child: const Icon(CupertinoIcons.back),
onPressed: () => context.pop(),
),
),
child: SafeArea(
child: Stack(
children: [
ListView(
padding: const EdgeInsets.only(bottom: 24),
children: [
SectionTitle(l10n.securityLoginDevices),
devicesAsync.when(
loading: () => const CardBox(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 20),
child: Center(child: CupertinoActivityIndicator()),
),
),
error: (e, _) => CardBox(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.commonLoadFailed,
style: const TextStyle(fontSize: 13, color: AppColors.sub),
),
const SizedBox(height: 10),
GestureDetector(
onTap: () => ref.invalidate(loginDevicesProvider),
child: Text(
l10n.commonRetry,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.green,
),
),
),
],
),
),
data: (devices) => devices.isEmpty
? CardBox(
child: Text(
l10n.loginDevicesEmpty,
style: const TextStyle(fontSize: 13, color: AppColors.sub),
),
)
: CardBox(
padding: EdgeInsets.zero,
child: Column(
children: [
for (var i = 0; i < devices.length; i++) ...[
if (i > 0) const AppDivider(),
_LoginDeviceRow(
device: devices[i],
onRevoke: () =>
_confirmRevoke(context, ref, devices[i], l10n),
),
],
],
),
),
),
],
),
if (action.submitting)
const Positioned.fill(
child: ColoredBox(
color: Color(0x33000000),
child: Center(child: CupertinoActivityIndicator(radius: 14)),
),
),
],
),
),
);
}
/// 下线二次确认。
Future<void> _confirmRevoke(
BuildContext context,
WidgetRef ref,
LoginDevice device,
AppLocalizations l10n,
) async {
final confirmed = await AppDialogs.confirm(
context,
title: l10n.loginDevicesRevoke,
message: l10n.loginDevicesRevokeConfirm,
confirmText: l10n.loginDevicesRevoke,
cancelText: l10n.commonCancel,
);
if (!confirmed) return;
final ok = await ref
.read(accountControllerProvider.notifier)
.revokeLoginDevice(device.id);
if (!ok || !context.mounted) return;
await AppDialogs.alert(
context,
message: l10n.loginDevicesRevokeSuccess,
confirmText: l10n.commonConfirm,
);
}
}
/// 登录设备列表项:当前设备展示「本机」标记,其余展示「下线」按钮。
class _LoginDeviceRow extends StatelessWidget {
const _LoginDeviceRow({required this.device, required this.onRevoke});
final LoginDevice device;
final VoidCallback onRevoke;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final parts = <String>[
if (device.osName?.isNotEmpty == true) device.osName!,
if (device.location?.isNotEmpty == true) device.location!,
if (device.lastActiveTime?.isNotEmpty == true) device.lastActiveTime!,
];
return SettingsRow(
emoji: device.current ? '📲' : '💻',
color: device.current ? AppColors.green : AppColors.blue,
title: device.displayName,
subtitle: parts.isEmpty ? device.ip : parts.join(' · '),
trailing: device.current
? Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: const Color(0xFFECFDF3),
borderRadius: BorderRadius.circular(16),
),
child: Text(
l10n.loginDevicesCurrent,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: AppColors.green,
),
),
)
: GestureDetector(
onTap: onRevoke,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
decoration: BoxDecoration(
color: const Color(0xFFFEF2F2),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: AppColors.red.withValues(alpha: 0.4)),
),
child: Text(
l10n.loginDevicesRevoke,
style: const TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w700,
color: AppColors.red,
),
),
),
),
);
}
}

View File

@@ -0,0 +1,95 @@
import 'package:cupertino_ui/cupertino_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/widgets/feature_ui.dart';
import '../../../core/widgets/settings_ui.dart';
import '../../../l10n/app_localizations.dart';
import '../data/account_providers.dart';
/// 账号与安全页(我的 → 设置 → 账号与安全)。
///
/// 汇总入口:登录设备、修改密码、更换手机、微信绑定;
/// 每个入口对应一个独立子页面。
class SecurityPage extends ConsumerWidget {
const SecurityPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final wechatAsync = ref.watch(wechatBindingProvider);
final wechatValue = wechatAsync.maybeWhen(
data: (b) => b.bound ? l10n.wechatBound : l10n.wechatUnbound,
orElse: () => null,
);
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
if (context.canPop()) {
context.pop();
} else {
context.go('/profile');
}
},
child: CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text(l10n.securityTitle),
leading: CupertinoButton(
padding: EdgeInsets.zero,
child: const Icon(CupertinoIcons.back),
onPressed: () => context.pop(),
),
),
child: SafeArea(
child: ListView(
padding: const EdgeInsets.only(bottom: 24),
children: [
SectionTitle(l10n.securityTitle),
CardBox(
padding: EdgeInsets.zero,
child: Column(
children: [
SettingsRow(
emoji: '💻',
color: AppColors.blue,
title: l10n.securityLoginDevices,
subtitle: l10n.securityLoginDevicesSub,
onTap: () => context.push('/security/login-devices'),
),
const AppDivider(),
SettingsRow(
emoji: '🔑',
color: AppColors.orange,
title: l10n.securityChangePassword,
subtitle: l10n.securityChangePasswordSub,
onTap: () => context.push('/security/password'),
),
const AppDivider(),
SettingsRow(
emoji: '📱',
color: AppColors.teal,
title: l10n.securityChangeMobile,
subtitle: l10n.securityChangeMobileSub,
onTap: () => context.push('/security/mobile'),
),
const AppDivider(),
SettingsRow(
emoji: '💬',
color: AppColors.green,
title: l10n.securityWechat,
subtitle: l10n.securityWechatSub,
value: wechatValue,
onTap: () => context.push('/security/wechat'),
),
],
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,184 @@
import 'package:cupertino_ui/cupertino_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/widgets/feature_ui.dart';
import '../../../core/widgets/settings_ui.dart';
import '../../../l10n/app_localizations.dart';
import '../data/account_providers.dart';
import 'account_controller.dart';
/// 微信绑定页(账号与安全 → 微信绑定)。
///
/// 展示当前绑定状态:已绑定时提供「解除绑定」,未绑定时提供「绑定微信」。
///
/// 注意:绑定动作需要微信开放平台 SDK 返回的授权 `code`。当前项目尚未接入
/// 微信 SDK,[_bind] 中的 `authCode` 获取处以 TODO 标注,接入后替换即可。
class WechatBindingPage extends ConsumerWidget {
const WechatBindingPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final bindingAsync = ref.watch(wechatBindingProvider);
final action = ref.watch(accountControllerProvider);
ref.listen<AccountActionState>(accountControllerProvider, (prev, next) {
final alert = next.alert;
if (alert == null) return;
ref.read(accountControllerProvider.notifier).consumeAlert();
AppDialogs.alert(context, message: alert, confirmText: l10n.commonConfirm);
});
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text(l10n.wechatTitle),
leading: CupertinoButton(
padding: EdgeInsets.zero,
child: const Icon(CupertinoIcons.back),
onPressed: () => context.pop(),
),
),
child: SafeArea(
child: bindingAsync.when(
loading: () => const Center(child: CupertinoActivityIndicator()),
error: (e, _) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
l10n.commonLoadFailed,
style: const TextStyle(fontSize: 13, color: AppColors.sub),
),
const SizedBox(height: 10),
GestureDetector(
onTap: () => ref.invalidate(wechatBindingProvider),
child: Text(
l10n.commonRetry,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.green,
),
),
),
],
),
),
data: (binding) => ListView(
padding: const EdgeInsets.only(bottom: 24),
children: [
SectionTitle(l10n.securityWechat),
CardBox(
padding: EdgeInsets.zero,
child: SettingsRow(
emoji: '💬',
color: AppColors.green,
title: binding.bound
? (binding.nickname?.isNotEmpty == true
? binding.nickname!
: l10n.wechatBound)
: l10n.wechatUnbound,
subtitle: binding.bound
? binding.bindTime
: l10n.securityWechatSub,
trailing: _StatusBadge(bound: binding.bound, l10n: l10n),
),
),
const SizedBox(height: 8),
if (binding.bound)
PrimaryButton(
text: l10n.wechatUnbind,
color: AppColors.red,
loading: action.submitting,
onPressed: () => _confirmUnbind(context, ref, l10n),
)
else
PrimaryButton(
text: l10n.wechatBind,
loading: action.submitting,
onPressed: () => _bind(context, ref, l10n),
),
],
),
),
),
);
}
/// 绑定微信:需先通过微信 SDK 获取授权 code。
Future<void> _bind(
BuildContext context,
WidgetRef ref,
AppLocalizations l10n,
) async {
// TODO(wechat): 接入微信开放平台 SDK(如 fluwx),
// 调用 sendWeChatAuth 获取授权 code 后传入 bindWechat。
const String? authCode = null;
if (authCode == null) {
await AppDialogs.alert(
context,
message: l10n.wechatSdkUnavailable,
confirmText: l10n.commonConfirm,
);
return;
}
final ok = await ref.read(accountControllerProvider.notifier).bindWechat(authCode);
if (!ok || !context.mounted) return;
await AppDialogs.alert(
context,
message: l10n.wechatBindSuccess,
confirmText: l10n.commonConfirm,
);
}
/// 解绑微信(二次确认)。
Future<void> _confirmUnbind(
BuildContext context,
WidgetRef ref,
AppLocalizations l10n,
) async {
final confirmed = await AppDialogs.confirm(
context,
title: l10n.wechatUnbind,
message: l10n.wechatUnbindConfirm,
confirmText: l10n.wechatUnbind,
cancelText: l10n.commonCancel,
);
if (!confirmed) return;
final ok = await ref.read(accountControllerProvider.notifier).unbindWechat();
if (!ok || !context.mounted) return;
await AppDialogs.alert(
context,
message: l10n.wechatUnbindSuccess,
confirmText: l10n.commonConfirm,
);
}
}
/// 绑定状态徽标。
class _StatusBadge extends StatelessWidget {
const _StatusBadge({required this.bound, required this.l10n});
final bool bound;
final AppLocalizations l10n;
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: bound ? const Color(0xFFECFDF3) : AppColors.track,
borderRadius: BorderRadius.circular(16),
),
child: Text(
bound ? l10n.wechatBound : l10n.wechatUnbound,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: bound ? AppColors.green : AppColors.sub,
),
),
);
}

View File

@@ -0,0 +1,91 @@
import 'package:flutter/foundation.dart';
import 'package:ttstd_family_care/core/network/api_exception.dart';
import 'package:ttstd_family_care/core/network/dio_client.dart';
import 'package:ttstd_family_care/features/contacts/domain/contact_models.dart';
/// 客户端(家属端)联系人接口访问层。
///
/// 后端接口前缀为 `/api/v1/client/sn/contact`,Dio 在 path 不以 `/` 开头时会
/// 直接拼接 baseUrl 的 path 段,故此处使用相对路径 `v1/client/sn/contact/...`。
/// [DioClient.get/post/put/delete] 已统一解析为后台返回体中的 `data` 字段;
/// 业务失败时抛出 [ApiException],本层将其降级为「空数据/错误提示」以适配家属端展示。
class ContactApi {
const ContactApi();
/// 联系人列表(家属端)。
Future<List<Contact>> getContacts(String sn) async {
try {
final data = await DioClient.get(
'v1/client/sn/contact/list',
queryParameters: {'sn': sn},
);
return _parseList(data, Contact.fromJson);
} on ApiException {
return const [];
}
}
/// 新增联系人(家属端)。
///
/// 成功返回 null;失败返回后台错误提示。
Future<String?> addContact(String sn, Contact contact) async {
try {
await DioClient.post(
'v1/client/sn/contact/insert',
queryParameters: {'sn': sn},
data: contact.toFormJson(),
);
debugPrint('[ContactApi] addContact sn=$sn name=${contact.name} success');
return null;
} on ApiException catch (e) {
debugPrint('[ContactApi] addContact sn=$sn name=${contact.name} failed: ${e.message}');
return e.message;
}
}
/// 修改联系人(家属端)。
///
/// 成功返回 null;失败返回后台错误提示。
/// 注意:[DioClient.put] 无 queryParameters 参数,故 id/sn 直接拼入路径。
Future<String?> updateContact(String sn, Contact contact) async {
try {
final path =
'v1/client/sn/contact/update?id=${contact.id}&sn=${Uri.encodeQueryComponent(sn)}';
await DioClient.put(path, data: contact.toFormJson());
debugPrint('[ContactApi] updateContact sn=$sn id=${contact.id} success');
return null;
} on ApiException catch (e) {
debugPrint('[ContactApi] updateContact sn=$sn id=${contact.id} failed: ${e.message}');
return e.message;
}
}
/// 删除联系人(家属端)。
///
/// 成功返回 null;失败返回后台错误提示。
Future<String?> deleteContact(String sn, int id) async {
try {
await DioClient.delete(
'v1/client/sn/contact/delete?sn=${Uri.encodeQueryComponent(sn)}&id=$id',
);
debugPrint('[ContactApi] deleteContact sn=$sn id=$id success');
return null;
} on ApiException catch (e) {
debugPrint('[ContactApi] deleteContact sn=$sn id=$id failed: ${e.message}');
return e.message;
}
}
// ---------------------------------------------------------------------------
static List<T> _parseList<T>(
dynamic data,
T Function(Map<String, dynamic>) fromJson,
) {
if (data is! List) return const [];
return data
.whereType<Map<String, dynamic>>()
.map(fromJson)
.toList(growable: false);
}
}

View File

@@ -0,0 +1,24 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:ttstd_family_care/features/contacts/data/contact_api.dart';
import 'package:ttstd_family_care/features/contacts/data/contact_repository_impl.dart';
import 'package:ttstd_family_care/features/contacts/domain/contact_models.dart';
import 'package:ttstd_family_care/features/contacts/domain/contact_repository.dart';
import 'package:ttstd_family_care/features/device/data/device_providers.dart';
/// 联系人仓储 Provider。
final contactRepositoryProvider = Provider<ContactRepository>((ref) {
const api = ContactApi();
return ContactRepositoryImpl(api);
});
/// 当前选中设备的联系人列表。
///
/// 依赖 [selectedDeviceSnProvider] 选择设备;未绑定设备时返回空列表。
/// 通过 [autoDispose] 避免切换设备后残留旧数据。
final contactsProvider =
FutureProvider.autoDispose<List<Contact>>((ref) async {
final sn = ref.watch(selectedDeviceSnProvider);
if (sn == null || sn.isEmpty) return const [];
final repo = ref.watch(contactRepositoryProvider);
return repo.getContacts(sn);
});

View File

@@ -0,0 +1,25 @@
import 'package:ttstd_family_care/features/contacts/data/contact_api.dart';
import 'package:ttstd_family_care/features/contacts/domain/contact_models.dart';
import 'package:ttstd_family_care/features/contacts/domain/contact_repository.dart';
/// [ContactRepository] 的默认实现,委托给 [ContactApi]。
class ContactRepositoryImpl implements ContactRepository {
const ContactRepositoryImpl(this._api);
final ContactApi _api;
@override
Future<List<Contact>> getContacts(String sn) => _api.getContacts(sn);
@override
Future<String?> addContact(String sn, Contact contact) =>
_api.addContact(sn, contact);
@override
Future<String?> updateContact(String sn, Contact contact) =>
_api.updateContact(sn, contact);
@override
Future<String?> deleteContact(String sn, int id) =>
_api.deleteContact(sn, id);
}

View File

@@ -0,0 +1,98 @@
/// 联系人相关领域实体(家属端对接 client 接口)。
///
/// 以不可变类表达,避免 UI / 仓库直接依赖后台 DTO 结构。
/// 因未运行 build_runner(避免改动依赖与生成产物),此处手写等价实现:
/// 所有字段 final、提供 const 构造,并手动实现 JSON 转换。
/// 联系人视图对象(与后台 ContactVO 对齐)。
class Contact {
const Contact({
required this.id,
required this.name,
this.nickName,
required this.phoneNumber,
this.avatar,
this.position = 0,
this.emergency = false,
this.showDesktop = true,
this.bindPhone,
this.bindSn,
this.createTime,
this.updateTime,
});
final int id;
final String name;
final String? nickName;
final String phoneNumber;
final String? avatar;
final int position;
/// 是否紧急联系人(对应 UI 中的「常用」分组)。
final bool emergency;
/// 是否显示。
final bool showDesktop;
final String? bindPhone;
final String? bindSn;
final int? createTime;
final int? updateTime;
/// 列表展示用标题:昵称优先,缺省回退姓名。
String get displayName => (nickName != null && nickName!.isNotEmpty) ? nickName! : name;
/// 复制并覆盖 id(用于编辑场景,保留其余字段)。
Contact copyWithId(int id) => Contact(
id: id,
name: name,
nickName: nickName,
phoneNumber: phoneNumber,
avatar: avatar,
position: position,
emergency: emergency,
showDesktop: showDesktop,
bindPhone: bindPhone,
bindSn: bindSn,
createTime: createTime,
updateTime: updateTime,
);
factory Contact.fromJson(Map<String, dynamic> json) => Contact(
id: _toInt(json['id']) ?? 0,
name: json['name'] as String? ?? '',
nickName: json['nickName'] as String?,
phoneNumber: json['phoneNumber'] as String? ?? '',
avatar: json['avatar'] as String?,
position: _toInt(json['position']) ?? 0,
emergency: json['emergency'] as bool? ?? false,
showDesktop: json['showDesktop'] as bool? ?? true,
bindPhone: json['bindPhone'] as String?,
bindSn: json['bindSn'] as String?,
createTime: _toInt(json['createTime']),
updateTime: _toInt(json['updateTime']),
);
/// 转回后台表单结构(新增/修改复用)。
Map<String, dynamic> toFormJson() => {
'name': name,
'nickName': nickName,
'phoneNumber': phoneNumber,
'avatar': avatar,
'position': position,
'emergency': emergency,
'showDesktop': showDesktop,
'bindPhone': bindPhone,
'bindSn': bindSn,
};
}
/// 容错整型解析:后台可能以字符串(如 "123")或数字返回整型字段,
/// 直接用 `as int?` 在字符串场景下会抛出类型错误,故统一在此转换。
int? _toInt(dynamic value) {
if (value == null) return null;
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value);
return null;
}

View File

@@ -0,0 +1,22 @@
import 'package:ttstd_family_care/features/contacts/domain/contact_models.dart';
/// 联系人仓储,封装家属端联系人列表/新增/修改/删除/表单获取。
abstract class ContactRepository {
/// 获取指定设备的联系人列表(按 position 升序,由后台保证)。
Future<List<Contact>> getContacts(String sn);
/// 新增联系人。
///
/// 成功返回 null;失败返回后台错误提示(如手机号重复)。
Future<String?> addContact(String sn, Contact contact);
/// 修改联系人。
///
/// 成功返回 null;失败返回后台错误提示。
Future<String?> updateContact(String sn, Contact contact);
/// 删除联系人。
///
/// 成功返回 null;失败返回后台错误提示。
Future<String?> deleteContact(String sn, int id);
}

View File

@@ -0,0 +1,202 @@
import 'package:cupertino_ui/cupertino_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:material_ui/material_ui.dart';
import 'package:ttstd_family_care/core/widgets/feature_ui.dart';
import 'package:ttstd_family_care/core/widgets/settings_ui.dart';
import 'package:ttstd_family_care/features/contacts/domain/contact_models.dart';
import 'package:ttstd_family_care/features/contacts/data/contact_providers.dart';
import 'package:ttstd_family_care/features/contacts/presentation/contact_edit_page.dart';
import 'package:ttstd_family_care/l10n/app_localizations.dart';
import '../../device/data/device_providers.dart';
/// 联系人详情页。
///
/// 顶部「编辑」进入 [ContactEditPage];底部「删除」按钮位于页面内(非弹窗),
/// 点击直接删除并回退到列表。
class ContactDetailPage extends ConsumerStatefulWidget {
const ContactDetailPage({required this.contact, super.key});
final Contact contact;
@override
ConsumerState<ContactDetailPage> createState() => _ContactDetailPageState();
}
class _ContactDetailPageState extends ConsumerState<ContactDetailPage> {
late Contact _contact;
@override
void initState() {
super.initState();
_contact = widget.contact;
}
Future<void> _openEdit() async {
final updated = await context.push<Contact?>(
'/contacts/edit',
extra: _contact,
);
if (updated == null || !mounted) return;
final l10n = AppLocalizations.of(context);
final sn = ref.read(selectedDeviceSnProvider);
if (sn == null || sn.isEmpty) return;
final repo = ref.read(contactRepositoryProvider);
final err = await repo.updateContact(
sn,
updated.copyWithId(_contact.id),
);
if (!mounted) return;
if (err != null) {
AppDialogs.alert(
context,
message: err,
confirmText: l10n.commonConfirm,
);
return;
}
setState(() => _contact = updated.copyWithId(_contact.id));
ref.invalidate(contactsProvider);
}
Future<void> _delete() async {
final l10n = AppLocalizations.of(context);
final sn = ref.read(selectedDeviceSnProvider);
if (sn == null || sn.isEmpty) return;
final repo = ref.read(contactRepositoryProvider);
final err = await repo.deleteContact(sn, _contact.id);
if (!mounted) return;
if (err != null) {
AppDialogs.alert(
context,
message: err,
confirmText: l10n.commonConfirm,
);
return;
}
context.pop();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = _contact;
final url = c.avatar;
final hasAvatar = url != null && url.isNotEmpty;
final avatar = hasAvatar
? CircleAvatar(
radius: 40,
backgroundImage: NetworkImage(url),
onBackgroundImageError: (_, _) {},
)
: CircleAvatar(
radius: 40,
backgroundColor: AppColors.green.withValues(alpha: 0.15),
child: Text(
c.displayName.isNotEmpty ? c.displayName.characters.first : '?',
style: const TextStyle(color: AppColors.green, fontSize: 28),
),
);
return CupertinoPageScaffold(
backgroundColor: AppColors.surface,
navigationBar: CupertinoNavigationBar(
middle: Text(l10n.contactDetailTitle),
trailing: CupertinoButton(
padding: EdgeInsets.zero,
onPressed: _openEdit,
child: Text(l10n.commonEdit),
),
),
child: SafeArea(
child: Column(
children: [
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(vertical: 16),
children: [
Center(child: avatar),
const SizedBox(height: 12),
Center(
child: Text(
c.displayName,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: AppColors.ink,
),
),
),
const SizedBox(height: 24),
_DetailRow(label: l10n.contactName, value: c.name),
_DetailRow(
label: l10n.contactNick,
value: c.nickName ?? '-',
),
_DetailRow(
label: l10n.contactPhone,
value: c.phoneNumber,
),
_DetailRow(
label: l10n.contactIsEmergency,
value: c.emergency
? l10n.contactEmergencyYes
: l10n.contactEmergencyNo,
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: PrimaryButton(
text: l10n.commonDelete,
color: AppColors.red,
onPressed: _delete,
),
),
],
),
),
);
}
}
class _DetailRow extends StatelessWidget {
const _DetailRow({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: AppColors.surface,
border: const Border(
bottom: BorderSide(color: Color(0xFFE6EAF0), width: 0.5),
),
),
child: Row(
children: [
Text(
label,
style: const TextStyle(fontSize: 15, color: AppColors.sub),
),
const SizedBox(width: 16),
Expanded(
child: Text(
value,
textAlign: TextAlign.right,
style: const TextStyle(fontSize: 15, color: AppColors.ink),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,159 @@
import 'package:cupertino_ui/cupertino_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:ttstd_family_care/core/widgets/feature_ui.dart';
import 'package:ttstd_family_care/core/widgets/settings_ui.dart';
import 'package:ttstd_family_care/features/contacts/domain/contact_models.dart';
import 'package:ttstd_family_care/l10n/app_localizations.dart';
/// 新增/编辑联系人整页。
///
/// 表单收集结果后以 [context.pop] 返回 [Contact],由调用方(列表页 / 详情页)
/// 决定新增还是更新。
class ContactEditPage extends ConsumerStatefulWidget {
const ContactEditPage({this.contact, super.key});
final Contact? contact;
@override
ConsumerState<ContactEditPage> createState() => _ContactEditPageState();
}
class _ContactEditPageState extends ConsumerState<ContactEditPage> {
final _nameController = TextEditingController();
final _nickController = TextEditingController();
final _phoneController = TextEditingController();
late bool _emergency;
@override
void initState() {
super.initState();
final c = widget.contact;
_nameController.text = c?.name ?? '';
_nickController.text = c?.nickName ?? '';
_phoneController.text = c?.phoneNumber ?? '';
_emergency = c?.emergency ?? false;
}
@override
void dispose() {
_nameController.dispose();
_nickController.dispose();
_phoneController.dispose();
super.dispose();
}
void _save() {
final name = _nameController.text.trim();
final phone = _phoneController.text.trim();
if (name.isEmpty) {
AppDialogs.alert(
context,
message: AppLocalizations.of(context).nameRequired,
confirmText: AppLocalizations.of(context).commonConfirm,
);
return;
}
if (phone.isEmpty) {
AppDialogs.alert(
context,
message: AppLocalizations.of(context).phoneRequired,
confirmText: AppLocalizations.of(context).commonConfirm,
);
return;
}
final nick = _nickController.text.trim();
final updated = Contact(
id: widget.contact?.id ?? 0,
name: name,
nickName: nick.isEmpty ? null : nick,
phoneNumber: phone,
avatar: widget.contact?.avatar,
position: widget.contact?.position ?? 0,
emergency: _emergency,
showDesktop: widget.contact?.showDesktop ?? true,
bindPhone: widget.contact?.bindPhone,
bindSn: widget.contact?.bindSn,
createTime: widget.contact?.createTime,
updateTime: widget.contact?.updateTime,
);
context.pop(updated);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final isEdit = widget.contact != null;
return CupertinoPageScaffold(
backgroundColor: AppColors.surface,
navigationBar: CupertinoNavigationBar(
middle: Text(isEdit ? l10n.commonEdit : l10n.commonAdd),
leading: CupertinoButton(
padding: EdgeInsets.zero,
onPressed: () => context.pop(),
child: Text(l10n.commonCancel),
),
trailing: CupertinoButton(
padding: EdgeInsets.zero,
onPressed: _save,
child: Text(l10n.commonSave),
),
),
child: SafeArea(
child: ListView(
padding: const EdgeInsets.symmetric(vertical: 16),
children: [
LabeledField(
label: l10n.contactName,
controller: _nameController,
placeholder: l10n.contactName,
),
LabeledField(
label: l10n.contactNick,
controller: _nickController,
placeholder: l10n.contactNick,
),
LabeledField(
label: l10n.contactPhone,
controller: _phoneController,
placeholder: l10n.contactPhone,
keyboardType: TextInputType.phone,
),
const SizedBox(height: 8),
Container(
color: AppColors.surface,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Expanded(
child: Text(
l10n.contactEmergency,
style: const TextStyle(
fontSize: 14.5,
fontWeight: FontWeight.w600,
color: AppColors.ink,
),
),
),
CupertinoSwitch(
value: _emergency,
onChanged: (v) => setState(() => _emergency = v),
),
],
),
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: PrimaryButton(
text: isEdit ? l10n.commonSave : l10n.commonAdd,
onPressed: _save,
),
),
],
),
),
);
}
}

View File

@@ -1,160 +1,279 @@
import 'package:azlistview_plus/azlistview_plus.dart';
import 'package:cupertino_ui/cupertino_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:lpinyin/lpinyin.dart';
import 'package:material_ui/material_ui.dart';
import '../../../core/widgets/feature_ui.dart';
import '../../../l10n/app_localizations.dart';
import 'package:ttstd_family_care/core/widgets/feature_ui.dart';
import 'package:ttstd_family_care/core/widgets/settings_ui.dart';
import 'package:ttstd_family_care/features/contacts/domain/contact_models.dart';
import 'package:ttstd_family_care/features/contacts/data/contact_providers.dart';
import 'package:ttstd_family_care/l10n/app_localizations.dart';
/// 联系人页(管理 → 联系人)。
import '../../device/data/device_providers.dart';
/// 联系人列表的 A-Z 索引包装项。
class _ContactAzItem implements ISuspensionBean {
_ContactAzItem(this.contact);
final Contact contact;
@override
bool isShowSuspension = false;
/// 紧急联系人归入「★ 常用」分组并置顶;其余按姓名首字母(拼音首字母)分组。
@override
String getSuspensionTag() {
if (contact.emergency) return '★';
final name = contact.displayName.trim();
if (name.isEmpty) return '#';
final raw = PinyinHelper.getFirstWordPinyin(name).toUpperCase();
if (raw.isNotEmpty) {
final first = raw[0];
if (first.codeUnitAt(0) >= 65 && first.codeUnitAt(0) <= 90) {
return first;
}
}
return '#';
}
}
/// 联系人页面(家属端)。
///
/// 对应移动端设计稿:搜索栏 + 分组列表(常用/同事/家人)+ 右侧字母索引。
class ContactsPage extends StatelessWidget {
/// 数据来源于 [ClientContactController](`/api/v1/client/sn/contact`)。
/// 分组规则:紧急联系人(`emergency=true`)归入「★ 常用」分组置顶,其余按姓名首字母(拼音首字母)分入 A-Z 索引分组,右侧提供字母索引条快速跳转。
class ContactsPage extends ConsumerStatefulWidget {
const ContactsPage({super.key});
@override
ConsumerState<ContactsPage> createState() => _ContactsPageState();
}
class _ContactsPageState extends ConsumerState<ContactsPage> {
Future<void> _refresh() async {
ref.invalidate(contactsProvider);
await ref.read(contactsProvider.future);
}
void _onTap(Contact contact) {
context.push('/contacts/detail', extra: contact);
}
Future<void> _onAdd() async {
final sn = ref.read(selectedDeviceSnProvider);
if (sn == null || sn.isEmpty) return;
final result = await context.push<Contact?>('/contacts/edit');
if (result == null || !mounted) return;
final l10n = AppLocalizations.of(context);
final repo = ref.read(contactRepositoryProvider);
final err = await repo.addContact(sn, result);
if (!mounted) return;
if (err != null) {
AppDialogs.alert(context, message: err, confirmText: l10n.commonConfirm);
return;
}
AppDialogs.alert(
context,
message: l10n.contactAdded,
confirmText: l10n.commonConfirm,
);
_refresh();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return PopScope(
// 拦截系统返回键:优先出栈;若无法出栈则跳回「管理」页,避免直接退到桌面。
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
if (context.canPop()) {
context.pop();
} else {
context.go('/messages');
}
},
child: CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text(l10n.contactsTitle),
leading: CupertinoButton(
padding: EdgeInsets.zero,
child: const Icon(CupertinoIcons.back),
onPressed: () => context.pop(),
),
trailing: CupertinoButton(
padding: EdgeInsets.zero,
child: const Icon(CupertinoIcons.add, size: 26),
onPressed: () {},
),
final sn = ref.watch(selectedDeviceSnProvider);
final contactsAsync = ref.watch(contactsProvider);
return CupertinoPageScaffold(
backgroundColor: AppColors.surface,
navigationBar: CupertinoNavigationBar(
middle: Text(l10n.contactsTitle),
trailing: CupertinoButton(
padding: EdgeInsets.zero,
onPressed: sn != null && sn.isNotEmpty ? _onAdd : null,
child: const Icon(CupertinoIcons.add, size: 26),
),
child: Stack(
children: [
ListView(
padding: const EdgeInsets.only(bottom: 20),
children: [
// 搜索栏
Container(
margin: const EdgeInsets.fromLTRB(16, 10, 16, 4),
height: 40,
decoration: BoxDecoration(
color: CupertinoColors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: const [BoxShadow(color: Color(0x14141E3C), blurRadius: 8, offset: Offset(0, 2))],
),
child: const Row(
children: [
SizedBox(width: 14),
Icon(CupertinoIcons.search, size: 16, color: AppColors.sub),
SizedBox(width: 8),
Text('搜索姓名、号码', style: TextStyle(fontSize: 14, color: AppColors.sub)),
],
),
),
_group(l10n.grpFavorite, [
_Contact('妈', AppColors.blue, '妈妈', '138 **** 6621'),
_Contact('李', AppColors.green, '李工 · 运维', '159 **** 0248'),
]),
_group(l10n.grpColleague, [
_Contact('王', AppColors.purple, '王经理', '186 **** 7733 · 产品部'),
_Contact('张', AppColors.orange, '张总监', '135 **** 5512 · 技术部'),
_Contact('赵', AppColors.red, '赵设计', '188 **** 2019 · 设计部'),
]),
_group(l10n.grpFamily, [
_Contact('爸', AppColors.teal, '爸爸', '137 **** 0098'),
_Contact('妹', const Color(0xFFEC4899), '妹妹', '199 **** 3344'),
]),
],
),
Positioned(
right: 5,
top: 150,
),
child: SafeArea(
child: contactsAsync.when(
loading: () => const Center(child: CupertinoActivityIndicator()),
error: (e, _) => Center(
child: Column(
children: const [
Text('★', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: AppColors.sub)),
SizedBox(height: 3),
Text('同', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: AppColors.sub)),
SizedBox(height: 3),
Text('家', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: AppColors.sub)),
SizedBox(height: 3),
Text('A', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: AppColors.sub)),
SizedBox(height: 3),
Text('B', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: AppColors.sub)),
SizedBox(height: 3),
Text('C', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: AppColors.sub)),
SizedBox(height: 3),
Text('D', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: AppColors.sub)),
mainAxisSize: MainAxisSize.min,
children: [
Text('$e'),
const SizedBox(height: 12),
CupertinoButton(
onPressed: _refresh,
child: Text(l10n.commonRetry),
),
],
),
),
],
data: (contacts) {
if (sn == null || sn.isEmpty) {
return Center(child: Text(l10n.contactNoDevice));
}
if (contacts.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.contactListEmpty),
const SizedBox(height: 12),
CupertinoButton(
onPressed: _onAdd,
child: Text(l10n.commonAdd),
),
],
),
);
}
final items = contacts
.map((c) => _ContactAzItem(c))
.toList()
..sort((a, b) {
// 「★ 常用」置顶,其余按分组标签(字母)排序。
if (a.getSuspensionTag() == '★') return -1;
if (b.getSuspensionTag() == '★') return 1;
return a.getSuspensionTag().compareTo(b.getSuspensionTag());
});
SuspensionUtil.setShowSuspensionStatus(items);
final indexBarData = _buildIndexBarData(items);
return AzListView(
padding: const EdgeInsets.symmetric(vertical: 8),
data: items,
itemCount: items.length,
indexBarData: indexBarData,
indexBarMargin: const EdgeInsets.symmetric(vertical: 8),
susItemHeight: 36,
itemBuilder: (ctx, index) => _ContactTile(
contact: items[index].contact,
onTap: () => _onTap(items[index].contact),
),
susItemBuilder: (ctx, index) =>
_buildSusHeader(context, items[index].getSuspensionTag()),
);
},
),
),
);
}
}
Widget _group(String title, List<_Contact> items) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(18, 14, 18, 4),
child: Text(title,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: AppColors.sub)),
),
CardBox(
padding: EdgeInsets.zero,
child: Column(
children: [
for (var i = 0; i < items.length; i++) ...[
if (i > 0) const Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: AppDivider()),
_row(items[i]),
],
],
/// 根据索引项实际出现的分组标签,生成右侧 IndexBar 数据(★ 置顶,A-Z 居中,# 兜底)。
List<String> _buildIndexBarData(List<_ContactAzItem> items) {
final tags = items.map((e) => e.getSuspensionTag()).toSet();
const letters = <String>[
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M',
'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'
];
final ordered = <String>[];
if (tags.contains('★')) ordered.add('★');
for (final l in letters) {
if (tags.contains(l)) ordered.add(l);
}
if (tags.contains('#')) ordered.add('#');
return ordered;
}
/// 分组头(吸顶),★ 分组显示「★ 常用」文案。
Widget _buildSusHeader(BuildContext context, String tag) {
final title = tag == '★' ? '★ 常用' : tag;
return SizedBox(
width: MediaQuery.of(context).size.width,
height: 36,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
alignment: Alignment.centerLeft,
color: AppColors.surface,
child: Text(
title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.sub,
),
),
),
);
}
/// 单个联系人条目(Cupertino 风格),点击进入详情页。
class _ContactTile extends StatelessWidget {
const _ContactTile({
required this.contact,
required this.onTap,
});
final Contact contact;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final url = contact.avatar;
final hasAvatar = url != null && url.isNotEmpty;
final avatar = hasAvatar
? CircleAvatar(
radius: 22,
backgroundImage: NetworkImage(url),
onBackgroundImageError: (_, _) {},
)
: CircleAvatar(
radius: 22,
backgroundColor: AppColors.green.withValues(alpha: 0.15),
child: Text(
contact.displayName.isNotEmpty
? contact.displayName.characters.first
: '?',
style: const TextStyle(color: AppColors.green, fontSize: 16),
),
),
],
);
);
Widget _row(_Contact c) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
return GestureDetector(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(color: c.color, borderRadius: BorderRadius.circular(13)),
child: Center(child: Text(c.initial, style: const TextStyle(fontSize: 17, color: CupertinoColors.white))),
),
avatar,
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(c.name, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.ink)),
Text(c.phone, style: const TextStyle(fontSize: 12, color: AppColors.sub)),
Text(
contact.displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14.5,
fontWeight: FontWeight.w600,
color: AppColors.ink,
),
),
const SizedBox(height: 2),
Text(
contact.phoneNumber,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, color: AppColors.sub),
),
],
),
),
const Text('›', style: TextStyle(fontSize: 16, color: Color(0xFFCBD2DC))),
],
),
);
}
class _Contact {
const _Contact(this.initial, this.color, this.name, this.phone);
final String initial;
final Color color;
final String name;
final String phone;
),
);
}
}

View File

@@ -26,6 +26,41 @@ class DeviceApi {
}
}
/// 绑定设备(手动输入 SN 或扫码得到 SN 后调用)。
///
/// 成功返回 null;失败返回后台错误提示。
Future<String?> bindDevice(String sn, {String? remark}) async {
try {
final data = await DioClient.post(
'v1/client/sn/bind',
queryParameters: {'sn': sn},
data: remark != null && remark.isNotEmpty ? {'remark': remark} : null,
);
debugPrint('[DeviceApi] bindDevice sn=$sn data=$data');
return null;
} on ApiException catch (e) {
debugPrint('[DeviceApi] bindDevice sn=$sn failed: ${e.message}');
return e.message;
}
}
/// 解绑设备。
///
/// 成功返回 null;失败返回后台错误提示。
Future<String?> unbindDevice(String sn) async {
try {
final data = await DioClient.post(
'v1/client/sn/unbind',
queryParameters: {'sn': sn},
);
debugPrint('[DeviceApi] unbindDevice sn=$sn data=$data');
return null;
} on ApiException catch (e) {
debugPrint('[DeviceApi] unbindDevice sn=$sn failed: ${e.message}');
return e.message;
}
}
/// 获取已绑定设备基本信息(无上报数据时返回 null)。
Future<DeviceSystemInfo?> getDeviceInfo(String sn) async {
try {
@@ -160,4 +195,36 @@ class DeviceApi {
);
}).toList(growable: false);
}
/// 删除单张设备截图(家属端 client 接口)。
///
/// [sn] 设备序列号,[id] 截图ID。成功返回 null;失败返回后台错误提示。
Future<String?> deleteScreenshot(String sn, int id) async {
try {
await DioClient.delete(
'v1/client/sn/ops/screenshots/$id?sn=${Uri.encodeQueryComponent(sn)}',
);
debugPrint('[DeviceApi] deleteScreenshot sn=$sn id=$id success');
return null;
} on ApiException catch (e) {
debugPrint('[DeviceApi] deleteScreenshot sn=$sn id=$id failed: ${e.message}');
return e.message;
}
}
/// 清空设备全部截图(家属端 client 接口)。
///
/// [sn] 设备序列号。成功返回 null;失败返回后台错误提示。
Future<String?> clearScreenshots(String sn) async {
try {
await DioClient.delete(
'v1/client/sn/ops/screenshots?sn=${Uri.encodeQueryComponent(sn)}',
);
debugPrint('[DeviceApi] clearScreenshots sn=$sn success');
return null;
} on ApiException catch (e) {
debugPrint('[DeviceApi] clearScreenshots sn=$sn failed: ${e.message}');
return e.message;
}
}
}

View File

@@ -12,6 +12,13 @@ class DeviceRepositoryImpl implements DeviceRepository {
@override
Future<List<DeviceBrief>> getMyDevices() => _api.getMyDevices();
@override
Future<String?> bindDevice(String sn, {String? remark}) =>
_api.bindDevice(sn, remark: remark);
@override
Future<String?> unbindDevice(String sn) => _api.unbindDevice(sn);
@override
Future<DeviceSystemInfo?> getDeviceInfo(String sn) => _api.getDeviceInfo(sn);
@@ -32,4 +39,11 @@ class DeviceRepositoryImpl implements DeviceRepository {
@override
Future<String?> sendAppOp(String sn, String op, String packageName) =>
_api.sendAppOp(sn, op, packageName);
@override
Future<String?> deleteScreenshot(String sn, int id) =>
_api.deleteScreenshot(sn, id);
@override
Future<String?> clearScreenshots(String sn) => _api.clearScreenshots(sn);
}

View File

@@ -5,6 +5,16 @@ abstract class DeviceRepository {
/// 当前用户已绑定的设备列表。
Future<List<DeviceBrief>> getMyDevices();
/// 绑定设备(手动输入 SN 或扫码获取 SN)。
///
/// 成功返回 null;失败返回错误提示。
Future<String?> bindDevice(String sn, {String? remark});
/// 解绑设备。
///
/// 成功返回 null;失败返回错误提示。
Future<String?> unbindDevice(String sn);
/// 已绑定设备基本信息(无上报数据时返回 null)。
Future<DeviceSystemInfo?> getDeviceInfo(String sn);
@@ -28,4 +38,14 @@ abstract class DeviceRepository {
/// [op] 为后端操作标识:`launch` / `stop` / `uninstall` / `clear_data`。
/// 成功返回 null;失败返回错误提示。
Future<String?> sendAppOp(String sn, String op, String packageName);
/// 删除单张设备截图(家属端 client 接口)。
///
/// [sn] 设备序列号,[id] 截图ID。成功返回 null;失败返回错误提示。
Future<String?> deleteScreenshot(String sn, int id);
/// 清空设备全部截图(家属端 client 接口)。
///
/// [sn] 设备序列号。成功返回 null;失败返回错误提示。
Future<String?> clearScreenshots(String sn);
}

View File

@@ -0,0 +1,141 @@
import 'package:cupertino_ui/cupertino_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/widgets/feature_ui.dart';
import '../../../core/widgets/settings_ui.dart';
import '../../../l10n/app_localizations.dart';
import 'device_manage_controller.dart';
/// 绑定设备页(手动输入 SN)。
///
/// 提供 SN / 备注名输入、「确认绑定」与「扫码绑定」两个入口。
/// 扫码入口跳转 `/device/scan`,扫描结果回填到 SN 输入框。
class DeviceBindPage extends ConsumerStatefulWidget {
const DeviceBindPage({super.key, this.initialSn});
/// 由扫码页回传的初始 SN(可选)。
final String? initialSn;
@override
ConsumerState<DeviceBindPage> createState() => _DeviceBindPageState();
}
class _DeviceBindPageState extends ConsumerState<DeviceBindPage> {
late final TextEditingController _snController;
final TextEditingController _remarkController = TextEditingController();
@override
void initState() {
super.initState();
_snController = TextEditingController(text: widget.initialSn ?? '');
}
@override
void dispose() {
_snController.dispose();
_remarkController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final action = ref.watch(deviceManageControllerProvider);
// 一次性错误提示:展示后立即消费。
ref.listen<DeviceManageState>(deviceManageControllerProvider, (prev, next) {
final alert = next.alert;
if (alert == null) return;
ref.read(deviceManageControllerProvider.notifier).consumeAlert();
AppDialogs.alert(context, message: alert, confirmText: l10n.commonConfirm);
});
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text(l10n.deviceBindTitle),
leading: CupertinoButton(
padding: EdgeInsets.zero,
child: const Icon(CupertinoIcons.back),
onPressed: () => context.pop(),
),
),
child: SafeArea(
child: ListView(
padding: const EdgeInsets.only(top: 8, bottom: 24),
children: [
CardBox(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
children: [
LabeledField(
label: l10n.deviceBindSnLabel,
controller: _snController,
placeholder: l10n.deviceBindSnHint,
keyboardType: TextInputType.text,
),
LabeledField(
label: l10n.deviceBindRemarkLabel,
controller: _remarkController,
placeholder: l10n.deviceBindRemarkHint,
maxLength: 20,
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(22, 6, 22, 2),
child: Text(
l10n.deviceBindTip,
style: const TextStyle(fontSize: 12, color: AppColors.sub, height: 1.5),
),
),
const SizedBox(height: 6),
PrimaryButton(
text: l10n.deviceBindConfirm,
loading: action.submitting,
onPressed: () => _submit(l10n),
),
SecondaryButton(
text: l10n.deviceBindScan,
icon: CupertinoIcons.qrcode_viewfinder,
onPressed: action.submitting ? null : () => _openScanner(l10n),
),
],
),
),
);
}
/// 校验 SN 后提交绑定,成功则提示并返回上一页。
Future<void> _submit(AppLocalizations l10n) async {
final sn = _snController.text.trim();
if (sn.isEmpty) {
await AppDialogs.alert(
context,
message: l10n.deviceBindSnEmpty,
confirmText: l10n.commonConfirm,
);
return;
}
final ok = await ref.read(deviceManageControllerProvider.notifier).bind(
sn,
remark: _remarkController.text,
);
if (!ok || !mounted) return;
await AppDialogs.alert(
context,
message: l10n.deviceBindSuccess,
confirmText: l10n.commonConfirm,
);
if (mounted) context.pop();
}
/// 打开扫码页;扫描成功后把 SN 回填输入框。
Future<void> _openScanner(AppLocalizations l10n) async {
final code = await context.push<String>('/device/scan');
if (code == null || code.isEmpty || !mounted) return;
setState(() => _snController.text = code);
}
}

View File

@@ -0,0 +1,96 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:ttstd_family_care/core/network/error_message.dart';
import 'package:ttstd_family_care/features/device/data/device_providers.dart';
/// 设备管理(绑定 / 解绑)操作状态。
///
/// [submitting] 表示正在请求中,用于禁用按钮并展示 loading;
/// [alert] 为一次性提示文案,UI 展示后必须调用 [DeviceManageController.consumeAlert] 消费;
/// [lastSuccess] 标记最近一次操作是否成功,供 UI 决定是否关闭页面。
class DeviceManageState {
const DeviceManageState({
this.submitting = false,
this.alert,
this.lastSuccess = false,
});
final bool submitting;
final String? alert;
final bool lastSuccess;
DeviceManageState copyWith({
bool? submitting,
String? alert,
bool? lastSuccess,
}) =>
DeviceManageState(
submitting: submitting ?? this.submitting,
alert: alert,
lastSuccess: lastSuccess ?? this.lastSuccess,
);
}
/// 设备绑定 / 解绑控制器。
///
/// 页面级控制器,**不使用** keepAlive,随页面销毁自动释放(见 AGENTS.md §4)。
/// 操作成功后主动 `ref.invalidate(myDevicesProvider)` 刷新绑定列表。
class DeviceManageController extends Notifier<DeviceManageState> {
@override
DeviceManageState build() => const DeviceManageState();
/// 绑定设备。[sn] 为设备序列号,[remark] 为可选备注名。
///
/// SN 为空等输入校验由 UI 层负责(便于使用本地化文案),此处只做兜底裁剪。
/// 返回是否绑定成功。
Future<bool> bind(String sn, {String? remark}) =>
_run(() => ref.read(deviceRepositoryProvider).bindDevice(
sn.trim(),
remark: remark?.trim(),
));
/// 解绑设备。返回是否解绑成功。
Future<bool> unbind(String sn) =>
_run(() => ref.read(deviceRepositoryProvider).unbindDevice(sn));
/// 消费一次性提示,避免重复弹窗。
void consumeAlert() {
if (state.alert != null) {
state = state.copyWith(alert: null, lastSuccess: state.lastSuccess);
}
}
/// 统一执行绑定/解绑请求:处理 submitting 开关、错误文案与列表刷新。
Future<bool> _run(Future<String?> Function() action) async {
if (state.submitting) return false;
state = state.copyWith(submitting: true, alert: null, lastSuccess: false);
try {
final error = await action();
if (error != null) {
state = state.copyWith(
submitting: false,
alert: error,
lastSuccess: false,
);
return false;
}
// 绑定关系变化,刷新已绑定设备列表。
ref.invalidate(myDevicesProvider);
state = state.copyWith(submitting: false, lastSuccess: true);
return true;
} catch (e) {
// 统一文案映射,禁止直接 toString()(见 AGENTS.md §12)。
state = state.copyWith(
submitting: false,
alert: userMessageOf(e),
lastSuccess: false,
);
return false;
}
}
}
/// 设备管理控制器 Provider(页面级,不 keepAlive)。
final deviceManageControllerProvider =
NotifierProvider<DeviceManageController, DeviceManageState>(
DeviceManageController.new,
);

View File

@@ -0,0 +1,252 @@
import 'package:cupertino_ui/cupertino_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/widgets/feature_ui.dart';
import '../../../core/widgets/settings_ui.dart';
import '../../../l10n/app_localizations.dart';
import '../data/device_providers.dart';
import '../domain/device_models.dart';
import 'device_manage_controller.dart';
/// 设备管理页(我的 → 绑定设备 → 管理)。
///
/// 列出当前账号已绑定的全部设备,每项尾部提供「解绑」按钮;
/// 右上角提供「添加设备」入口跳转到绑定页。
class DeviceManagePage extends ConsumerWidget {
const DeviceManagePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final devicesAsync = ref.watch(myDevicesProvider);
final action = ref.watch(deviceManageControllerProvider);
// 一次性提示:展示后立即消费,避免重建时重复弹窗。
ref.listen<DeviceManageState>(deviceManageControllerProvider, (prev, next) {
final alert = next.alert;
if (alert == null) return;
ref.read(deviceManageControllerProvider.notifier).consumeAlert();
AppDialogs.alert(
context,
message: alert,
confirmText: l10n.commonConfirm,
);
});
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
if (context.canPop()) {
context.pop();
} else {
context.go('/profile');
}
},
child: CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text(l10n.deviceManageTitle),
leading: CupertinoButton(
padding: EdgeInsets.zero,
child: const Icon(CupertinoIcons.back),
onPressed: () => context.pop(),
),
trailing: CupertinoButton(
padding: EdgeInsets.zero,
child: const Icon(CupertinoIcons.add, size: 26),
onPressed: () => context.push('/device/bind'),
),
),
child: SafeArea(
child: Stack(
children: [
ListView(
padding: const EdgeInsets.only(bottom: 24),
children: [
SectionTitle(l10n.deviceBound),
devicesAsync.when(
loading: () => const CardBox(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 20),
child: Center(child: CupertinoActivityIndicator()),
),
),
error: (e, _) => CardBox(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.deviceLoadFailed,
style: const TextStyle(fontSize: 13, color: AppColors.sub),
),
const SizedBox(height: 10),
// 局部错误:内联展示 + retry(见 AGENTS.md §12)。
GestureDetector(
onTap: () => ref.invalidate(myDevicesProvider),
child: Text(
l10n.commonRetry,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.green,
),
),
),
],
),
),
data: (devices) => devices.isEmpty
? CardBox(
child: Text(
l10n.deviceNone,
style: const TextStyle(fontSize: 13, color: AppColors.sub),
),
)
: CardBox(
padding: EdgeInsets.zero,
child: Column(
children: [
for (var i = 0; i < devices.length; i++) ...[
if (i > 0) const AppDivider(),
_DeviceRow(
device: devices[i],
onUnbind: () =>
_confirmUnbind(context, ref, devices[i], l10n),
),
],
],
),
),
),
const SizedBox(height: 8),
SecondaryButton(
text: l10n.deviceManageAdd,
icon: CupertinoIcons.add_circled,
onPressed: () => context.push('/device/bind'),
),
],
),
// 解绑请求进行中:全屏蒙层拦截重复点击。
if (action.submitting)
const Positioned.fill(
child: ColoredBox(
color: Color(0x33000000),
child: Center(child: CupertinoActivityIndicator(radius: 14)),
),
),
],
),
),
),
);
}
/// 解绑二次确认,确认后调用控制器并提示结果。
Future<void> _confirmUnbind(
BuildContext context,
WidgetRef ref,
DeviceBrief device,
AppLocalizations l10n,
) async {
final name = device.snName?.isNotEmpty == true ? device.snName! : device.serialno;
final confirmed = await AppDialogs.confirm(
context,
title: l10n.deviceUnbindConfirmTitle,
message: l10n.deviceUnbindConfirmMessage(name),
confirmText: l10n.deviceUnbind,
cancelText: l10n.commonCancel,
);
if (!confirmed) return;
final ok = await ref
.read(deviceManageControllerProvider.notifier)
.unbind(device.serialno);
if (!ok || !context.mounted) return;
await AppDialogs.alert(
context,
message: l10n.deviceUnbindSuccess,
confirmText: l10n.commonConfirm,
);
}
}
/// 设备列表项:左侧图标 + 名称/SN,右侧解绑按钮。
class _DeviceRow extends StatelessWidget {
const _DeviceRow({required this.device, required this.onUnbind});
final DeviceBrief device;
final VoidCallback onUnbind;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final title =
device.snName?.isNotEmpty == true ? device.snName! : device.serialno;
final subtitle = device.snModel?.isNotEmpty == true
? '${device.serialno} · ${device.snModel}'
: device.serialno;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: AppColors.green,
borderRadius: BorderRadius.circular(11),
),
child: const Center(child: Text('📱', style: TextStyle(fontSize: 17))),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14.5,
fontWeight: FontWeight.w600,
color: AppColors.ink,
),
),
const SizedBox(height: 2),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, color: AppColors.sub),
),
],
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: onUnbind,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
decoration: BoxDecoration(
color: const Color(0xFFFEF2F2),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: AppColors.red.withValues(alpha: 0.4)),
),
child: Text(
l10n.deviceUnbind,
style: const TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w700,
color: AppColors.red,
),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,228 @@
import 'package:cupertino_ui/cupertino_ui.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import '../../../core/widgets/feature_ui.dart';
import '../../../core/widgets/settings_ui.dart';
import '../../../l10n/app_localizations.dart';
/// 二维码扫描页。
///
/// 支持两种识别方式:
/// 1. 相机实时扫码([MobileScanner] 的 `onDetect` 回调);
/// 2. 从相册选取图片后离线解析([MobileScannerController.analyzeImage])。
///
/// 识别成功后通过 `context.pop(code)` 把结果回传给调用方(绑定设备页)。
class DeviceScanPage extends StatefulWidget {
const DeviceScanPage({super.key});
@override
State<DeviceScanPage> createState() => _DeviceScanPageState();
}
class _DeviceScanPageState extends State<DeviceScanPage> {
final MobileScannerController _controller = MobileScannerController(
detectionSpeed: DetectionSpeed.noDuplicates,
formats: const [BarcodeFormat.qrCode],
);
/// 防止连续回调导致多次 pop。
bool _handled = false;
/// 相册解析中,避免重复选图。
bool _pickingImage = false;
bool _torchOn = false;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text(l10n.scanTitle),
leading: CupertinoButton(
padding: EdgeInsets.zero,
child: const Icon(CupertinoIcons.back),
onPressed: () => context.pop(),
),
trailing: CupertinoButton(
padding: EdgeInsets.zero,
onPressed: _toggleTorch,
child: Icon(
_torchOn ? CupertinoIcons.lightbulb_fill : CupertinoIcons.lightbulb,
size: 24,
),
),
),
child: SafeArea(
child: Column(
children: [
Expanded(
child: Stack(
fit: StackFit.expand,
children: [
MobileScanner(
controller: _controller,
onDetect: (capture) => _onDetect(capture, l10n),
errorBuilder: (context, error, child) => _PermissionHint(
message: l10n.scanPermissionDenied,
),
),
// 取景框 + 提示文案
IgnorePointer(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 230,
height: 230,
decoration: BoxDecoration(
border: Border.all(
color: AppColors.green,
width: 3,
),
borderRadius: BorderRadius.circular(20),
),
),
const SizedBox(height: 18),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 40),
child: Text(
l10n.scanHint,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 13.5,
color: CupertinoColors.white,
height: 1.5,
),
),
),
],
),
),
),
if (_pickingImage)
const Positioned.fill(
child: ColoredBox(
color: Color(0x66000000),
child: Center(child: CupertinoActivityIndicator(radius: 14)),
),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: SecondaryButton(
text: l10n.scanFromGallery,
icon: CupertinoIcons.photo_on_rectangle,
onPressed: _pickingImage ? null : () => _pickFromGallery(l10n),
),
),
],
),
),
);
}
/// 相机识别回调:取首个非空条码值后回传并关闭页面。
void _onDetect(BarcodeCapture capture, AppLocalizations l10n) {
if (_handled) return;
final code = _firstValue(capture);
if (code == null) return;
_handled = true;
context.pop(code);
}
/// 从相册选取图片并离线解析二维码。
Future<void> _pickFromGallery(AppLocalizations l10n) async {
setState(() => _pickingImage = true);
try {
final picked = await ImagePicker().pickImage(source: ImageSource.gallery);
if (picked == null) return;
final capture = await _controller.analyzeImage(picked.path);
final code = capture == null ? null : _firstValue(capture);
if (!mounted) return;
if (code == null) {
await AppDialogs.alert(
context,
message: l10n.scanNoCodeFound,
confirmText: l10n.commonConfirm,
);
return;
}
if (!mounted) return;
_handled = true;
context.pop(code);
} finally {
if (mounted) setState(() => _pickingImage = false);
}
}
/// 切换闪光灯,失败时静默忽略(部分设备/模拟器不支持)。
Future<void> _toggleTorch() async {
try {
await _controller.toggleTorch();
if (mounted) setState(() => _torchOn = !_torchOn);
} catch (_) {
// 不支持闪光灯,忽略。
}
}
/// 提取扫描结果中第一个有效的二维码文本。
String? _firstValue(BarcodeCapture capture) {
for (final barcode in capture.barcodes) {
final value = barcode.rawValue;
if (value != null && value.trim().isNotEmpty) return value.trim();
}
return null;
}
}
/// 相机不可用(权限被拒或初始化失败)时的占位提示。
class _PermissionHint extends StatelessWidget {
const _PermissionHint({required this.message});
final String message;
@override
Widget build(BuildContext context) => ColoredBox(
color: const Color(0xFF111827),
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 40),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
CupertinoIcons.video_camera,
size: 46,
color: CupertinoColors.white,
),
const SizedBox(height: 14),
Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14,
color: CupertinoColors.white,
height: 1.5,
),
),
],
),
),
),
);
}

View File

@@ -195,6 +195,21 @@ class LocationController extends Notifier<LocationState> {
stop();
}
/// 重新启动连续定位(供地图「定位到本机」按钮手动触发)。
///
/// 若之前已停止(拿到首个结果后自动停止),可再次启动以获取最新位置;
/// 已启动时调用仅重新注册回调、不重复初始化。
void restart() {
if (_plugin == null) {
// 插件尚未初始化(极端时序下),从头走初始化流程。
_started = false;
_init();
return;
}
_started = true;
_plugin?.startLocation();
}
/// 停止连续定位(保留插件实例,可再次 [startLocation])。
///
/// 本机定位拿到首个有效结果后即停止,符合「有结果且非定位错误就停止定位」的约束。

View File

@@ -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)),
],

View File

@@ -27,6 +27,8 @@ class MapPage extends ConsumerStatefulWidget {
class _MapPageState extends ConsumerState<MapPage> {
BMFMapController? _mapController;
bool _markerAdded = false;
// 是否正在「定位到我」:点击定位按钮后置 true,拿到本机定位后归 false。
bool _isLocatingSelf = false;
@override
void dispose() {
@@ -55,13 +57,19 @@ class _MapPageState extends ConsumerState<MapPage> {
);
// 监听本机定位结果:定位成功后把蓝色定位点更新到地图(BMFUserLocation)。
// 若正处于「定位到我」流程,则把地图中心同步到本机位置并复位标记。
ref.listen<LocationState>(
locationControllerProvider,
(prev, next) {
if (next is LocationSuccess) {
_updateUserLocation(next.location);
if (_isLocatingSelf) {
_moveToUserLocation(next.location);
setState(() => _isLocatingSelf = false);
}
} else if (next is LocationFailed) {
debugPrint('[Location] 全屏地图:定位失败 -> ${next.message}');
if (_isLocatingSelf) setState(() => _isLocatingSelf = false);
}
},
);
@@ -86,6 +94,7 @@ class _MapPageState extends ConsumerState<MapPage> {
),
),
_buildOverlay(locationAsync, l10n),
_buildLocateButton(l10n),
],
),
),
@@ -141,6 +150,63 @@ class _MapPageState extends ConsumerState<MapPage> {
controller.updateLocationData(userLocation);
}
/// 把地图中心移动到本机定位位置(不带动画也行,这里用动画平滑过渡)。
void _moveToUserLocation(DeviceCurrentLocation location) {
final controller = _mapController;
if (controller == null) return;
controller.setCenterCoordinate(
BMFCoordinate(location.latitude, location.longitude),
true,
);
}
/// 右下角「定位到我」浮动按钮:点击后重新启动本机定位,
/// 定位成功会在 listen 回调中把地图中心移到本机位置。
Widget _buildLocateButton(AppLocalizations l10n) {
final isLocating = _isLocatingSelf;
return Positioned(
right: 6,
bottom: 100,
child: CupertinoButton(
padding: EdgeInsets.zero,
onPressed: isLocating ? null : _onLocateMe,
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: CupertinoColors.systemBackground.withValues(alpha: 0.92),
shape: BoxShape.circle,
boxShadow: const [
BoxShadow(
color: Color(0x33000000),
blurRadius: 6,
offset: Offset(0, 2),
),
],
),
child: isLocating
? const CupertinoActivityIndicator(radius: 11)
: const Icon(
CupertinoIcons.location_fill,
size: 22,
color: CupertinoColors.activeBlue,
),
),
),
);
}
/// 点击「定位到我」:标记进入定位中,重新启动手机本机定位。
void _onLocateMe() {
// 若已经拿到过本机位置,先立即把中心移过去(定位回调通常很快,提升即时反馈)。
final current = ref.read(locationControllerProvider);
if (current is LocationSuccess) {
_moveToUserLocation(current.location);
}
setState(() => _isLocatingSelf = true);
ref.read(locationControllerProvider.notifier).restart();
}
/// 根据最新定位刷新地图标记与中心。
void _syncLocation(DeviceLocation? location) {
final controller = _mapController;

View File

@@ -1,41 +1,38 @@
import 'package:cupertino_ui/cupertino_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:material_ui/material_ui.dart';
import '../../../core/utils/time_format.dart';
import '../../../core/widgets/feature_ui.dart';
import '../../../l10n/app_localizations.dart';
import '../../device/data/device_providers.dart';
import '../../device/domain/device_models.dart';
/// 截图查看页。
///
/// 单张居中展示设备截图,可通过左右滑动(PageView)切换上一张 / 下一张,
/// 顶部展示当前序号与总数,底部展示截图时间。从首页截图卡片的"查看全部"
/// 或点击某张缩略图进入,并定位到对应索引。
class ScreenshotPage extends StatefulWidget {
class ScreenshotPage extends ConsumerStatefulWidget {
const ScreenshotPage({
super.key,
required this.screenshots,
required this.initialIndex,
required this.sn,
this.initialIndex = 0,
});
/// 全部截图列表。
final List<ScreenshotVO> screenshots;
/// 初始展示的截图索引。
final String sn;
final int initialIndex;
@override
State<ScreenshotPage> createState() => _ScreenshotPageState();
ConsumerState<ScreenshotPage> createState() => _ScreenshotPageState();
}
class _ScreenshotPageState extends State<ScreenshotPage> {
class _ScreenshotPageState extends ConsumerState<ScreenshotPage> {
late final PageController _pageController;
late List<ScreenshotVO> _screenshots;
late int _currentIndex;
@override
void initState() {
super.initState();
_currentIndex = widget.initialIndex;
_pageController = PageController(initialPage: widget.initialIndex);
_screenshots = List<ScreenshotVO>.from(widget.screenshots);
_currentIndex = widget.initialIndex.clamp(0, _screenshots.length - 1);
_pageController = PageController(initialPage: _currentIndex);
}
@override
@@ -44,66 +41,297 @@ class _ScreenshotPageState extends State<ScreenshotPage> {
super.dispose();
}
/// 刷新截图列表:本地移除后立即失效源 Provider,
/// 返回首页时最近截图列表会自动重新拉取。
Future<void> _refreshSource() async {
ref.invalidate(recentScreenshotsProvider(widget.sn));
}
void _deleteCurrent() {
final l10n = AppLocalizations.of(context);
showCupertinoDialog<bool>(
context: context,
builder: (dialogContext) => CupertinoAlertDialog(
title: Text(l10n.homeScreenshotDeleteCurrent),
content: Text(l10n.homeScreenshotDeleteConfirm),
actions: [
CupertinoDialogAction(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(l10n.cancel),
),
CupertinoDialogAction(
isDestructiveAction: true,
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(l10n.homeScreenshotDelete),
),
],
),
).then((confirmed) async {
if (confirmed != true) return;
final target = _screenshots[_currentIndex];
final id = target.id;
if (id == null) return;
// 先调用后台 client 接口删除单条截图。
final err = await ref
.read(deviceRepositoryProvider)
.deleteScreenshot(widget.sn, id);
if (!mounted) return;
if (err != null) {
_showError(l10n, err);
return;
}
setState(() {
_screenshots.removeAt(_currentIndex);
if (_currentIndex > 0) _currentIndex -= 1;
});
_refreshSource();
if (_screenshots.isEmpty) {
if (mounted) Navigator.of(context).pop();
return;
}
final safeIndex = _currentIndex.clamp(0, _screenshots.length - 1);
if (safeIndex != _pageController.page?.round()) {
_pageController.jumpToPage(safeIndex);
}
if (mounted) _toast(l10n.homeScreenshotDeleted);
});
}
void _clearAll() {
final l10n = AppLocalizations.of(context);
showCupertinoDialog<bool>(
context: context,
builder: (dialogContext) => CupertinoAlertDialog(
title: Text(l10n.homeScreenshotClearAll),
content: Text(l10n.homeScreenshotClearConfirm),
actions: [
CupertinoDialogAction(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(l10n.cancel),
),
CupertinoDialogAction(
isDestructiveAction: true,
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(l10n.homeScreenshotClearAll),
),
],
),
).then((confirmed) async {
if (confirmed != true) return;
if (_screenshots.isEmpty) return;
// 先调用后台 client 接口清空全部截图。
final err = await ref
.read(deviceRepositoryProvider)
.clearScreenshots(widget.sn);
if (!mounted) return;
if (err != null) {
_showError(l10n, err);
return;
}
setState(() {
_screenshots.clear();
});
_refreshSource();
if (mounted) {
Navigator.of(context).pop();
_toast(l10n.homeScreenshotCleared);
}
});
}
void _showError(AppLocalizations l10n, String message) {
if (!mounted) return;
showCupertinoDialog<void>(
context: context,
builder: (dialogContext) => CupertinoAlertDialog(
content: Text(message),
actions: [
CupertinoDialogAction(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(l10n.commonConfirm),
),
],
),
);
}
void _toast(String message) {
final overlay = Overlay.of(context);
if (overlay == null) return;
final entry = OverlayEntry(
builder: (ctx) => Positioned(
bottom: 90,
left: 24,
right: 24,
child: IgnorePointer(
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: CupertinoColors.systemGrey.withValues(alpha: 0.92),
borderRadius: BorderRadius.circular(8),
),
child: Text(
message,
style: const TextStyle(color: CupertinoColors.white),
textAlign: TextAlign.center,
),
),
),
),
),
);
overlay.insert(entry);
Future.delayed(const Duration(seconds: 2), () => entry.remove());
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final list = widget.screenshots;
return CupertinoPageScaffold(
backgroundColor: CupertinoColors.black,
navigationBar: CupertinoNavigationBar(
middle: Text(
'${l10n.homeScreenshots} · ${_currentIndex + 1}/${list.length}',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
backgroundColor: CupertinoColors.black.withValues(alpha: 0.85),
brightness: Brightness.dark,
middle: _screenshots.isEmpty
? null
: Text(
'${_currentIndex + 1} / ${_screenshots.length}',
style: const TextStyle(color: CupertinoColors.white),
),
leading: CupertinoNavigationBarBackButton(
color: CupertinoColors.white,
onPressed: () => Navigator.of(context).pop(),
),
previousPageTitle: l10n.back,
trailing: _screenshots.isNotEmpty
? CupertinoButton(
padding: EdgeInsets.zero,
onPressed: _clearAll,
child: Text(
l10n.homeScreenshotClearAll,
style: const TextStyle(
color: CupertinoColors.white,
fontSize: 15,
),
),
)
: null,
),
child: SafeArea(
child: Column(
children: [
Expanded(
child: PageView.builder(
controller: _pageController,
itemCount: list.length,
onPageChanged: (i) => setState(() => _currentIndex = i),
itemBuilder: (_, i) {
final item = list[i];
return Center(
child: InteractiveViewer(
maxScale: 4,
child: Image.network(
item.url ?? '',
fit: BoxFit.contain,
loadingBuilder: (ctx, child, progress) => progress == null
? child
: const Center(
child: CupertinoActivityIndicator(radius: 14)),
errorBuilder: (ctx, err, _) => const Center(
child: Icon(CupertinoIcons.photo,
size: 48, color: AppColors.sub),
),
child: _screenshots.isEmpty
? Center(
child: Text(
l10n.homeScreenshotEmpty,
style:
const TextStyle(color: CupertinoColors.systemGrey),
),
)
: PageView.builder(
controller: _pageController,
itemCount: _screenshots.length,
onPageChanged: (index) {
setState(() {
_currentIndex = index;
});
},
itemBuilder: (context, index) {
final item = _screenshots[index];
final url = item.url;
return InteractiveViewer(
child: Center(
child: url != null
? Image.network(
url,
fit: BoxFit.contain,
errorBuilder: (ctx, error, stack) => Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
CupertinoIcons.photo,
size: 64,
color: CupertinoColors.systemGrey,
),
const SizedBox(height: 8),
Text(
l10n.commonLoadFailed,
style: const TextStyle(
color:
CupertinoColors.systemGrey,
),
),
],
),
)
: const Icon(
CupertinoIcons.photo,
size: 64,
color: CupertinoColors.systemGrey,
),
),
);
},
),
),
if (_screenshots.isNotEmpty)
SafeArea(
top: false,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
color: CupertinoColors.black.withValues(alpha: 0.85),
border: Border(
top: BorderSide(
color: CupertinoColors.separator
.withValues(alpha: 0.3),
width: 0.5,
),
),
);
},
),
child: CupertinoButton(
padding: const EdgeInsets.symmetric(vertical: 12),
color: CupertinoColors.destructiveRed,
borderRadius: BorderRadius.circular(10),
onPressed: _deleteCurrent,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
CupertinoIcons.delete,
color: CupertinoColors.white,
size: 20,
),
const SizedBox(width: 8),
Text(
l10n.homeScreenshotDelete,
style: const TextStyle(
color: CupertinoColors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
),
),
// 底部:截图时间信息(统一格式化时间戳)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Builder(builder: (context) {
final formatted =
formatTimeString(list[_currentIndex].uploadTime);
return Text(
formatted.isNotEmpty
? '🕐 $formatted'
: (list[_currentIndex].fileName ?? ''),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style:
const TextStyle(fontSize: 13, color: AppColors.sub),
);
}),
),
],
),
),

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 => '加载失败';
}

View File

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

View File

@@ -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": "通用:加载失败"
}
}

View File

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

View File

@@ -6,11 +6,15 @@
#include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
#include <mmkv_linux/mmkv_linux_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
flutter_secure_storage_linux
flutter_webrtc
mmkv_linux

View File

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

View File

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

View File

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

View File

@@ -6,12 +6,15 @@
#include "generated_plugin_registrant.h"
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
#include <mmkv_win32/mmkv_win32_plugin.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
FlutterWebRTCPluginRegisterWithRegistrar(

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
flutter_secure_storage_windows
flutter_webrtc
mmkv_win32