diff --git a/assets/images/login-logo.png b/assets/images/login-logo.png new file mode 100644 index 00000000..5b63c94e Binary files /dev/null and b/assets/images/login-logo.png differ diff --git a/assets/images/logo_me_bg.png b/assets/images/logo_me_bg.png new file mode 100644 index 00000000..d72945bc Binary files /dev/null and b/assets/images/logo_me_bg.png differ diff --git a/assets/images/logo_sata.png b/assets/images/logo_sata.png new file mode 100644 index 00000000..1e78a05e Binary files /dev/null and b/assets/images/logo_sata.png differ diff --git a/assets/images/logo_stata-en.png b/assets/images/logo_stata-en.png new file mode 100644 index 00000000..5e99f3c0 Binary files /dev/null and b/assets/images/logo_stata-en.png differ diff --git a/assets/images/me_left_logo.png b/assets/images/me_left_logo.png new file mode 100644 index 00000000..dc7b3d23 Binary files /dev/null and b/assets/images/me_left_logo.png differ diff --git a/assets/images/robot_logo_login.png b/assets/images/robot_logo_login.png new file mode 100644 index 00000000..68f010e0 Binary files /dev/null and b/assets/images/robot_logo_login.png differ diff --git a/assets/images/shebei-logo.png b/assets/images/shebei-logo.png new file mode 100644 index 00000000..17b5dac3 Binary files /dev/null and b/assets/images/shebei-logo.png differ diff --git a/assets/images/shebei_bg.png b/assets/images/shebei_bg.png new file mode 100644 index 00000000..bb0649d1 Binary files /dev/null and b/assets/images/shebei_bg.png differ diff --git a/lib/features/auth/presentation/pages/login_page_v2.dart b/lib/features/auth/presentation/pages/login_page_v2.dart new file mode 100644 index 00000000..b728c4fd --- /dev/null +++ b/lib/features/auth/presentation/pages/login_page_v2.dart @@ -0,0 +1,739 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; +import 'package:maibu_satabot_v2/core/localization/locale_cubit.dart'; +import 'package:maibu_satabot_v2/components/language_selection_dialog.dart'; +import 'package:maibu_satabot_v2/components/capsule_toast.dart'; + +import '../../../../core/router/route_paths.dart'; +import '../bloc/login_cubit.dart'; +import '../bloc/login_state.dart'; + +/// 登录页 V2 —— 按最新设计稿实现(账号 + 密码登录)。 +/// +/// 说明: +/// - 视觉:背景纵向浅蓝渐变、主按钮横向蓝色渐变胶囊、顶部 logo + 语言胶囊、 +/// 中部机器人 Hero 卡片、白色表单卡片内含「登录 / 注册」分段 Tab。 +/// - 逻辑:完全复用现有 [LoginCubit](账号密码登录,sourceType=2),未改动数据层。 +/// - 「注册」暂不支持:点击注册 Tab 仅弹出胶囊提示,不跳转。 +/// - 旧登录页 login_page.dart 原样保留,路由默认指向本页。 +class LoginPageV2 extends StatefulWidget { + const LoginPageV2({super.key}); + + @override + State createState() => _LoginPageV2State(); +} + +class _LoginPageV2State extends State + with SingleTickerProviderStateMixin { + final _userCtrl = TextEditingController(); + final _pwdCtrl = TextEditingController(); + bool _isAgreed = false; // 协议勾选状态 + bool _obscurePwd = true; // 密码可见性 + int _selectedTab = 0; // 0=登录, 1=注册(注册仅提示,不切换内容) + + // 进场动画控制器 + late final AnimationController _entranceController; + + // ===== 设计稿配色 ===== + static const _bgTop = Color(0xFFFAFCFF); + static const _bgMid = Color(0xFFF0F6FE); + // 底部蓝比设计稿基准色 #EDF6FE 加深:原稿中/底两色几乎无差,渐变看不出层次 + static const _bgBottom = Color(0xFFD6E9FB); + static const _navy = Color(0xFF1A2B4A); // 主标题/标签深色 + static const _grayText = Color(0xFF86909C); // 次要文字 + static const _primaryBlue = Color(0xFF1C96F6); + static const _inputBorder = Color(0xFFE5EDF7); + static const _tabBg = Color(0xFFEEF3FA); + static const _tabUnselectedText = Color(0xFF4E5969); + + @override + void initState() { + super.initState(); + _entranceController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + ); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _maybeShowLanguageDialog(); + Future.delayed(const Duration(milliseconds: 120), () { + if (mounted) _entranceController.forward(); + }); + }); + } + + /// 进入登录页:弹出语言选择框,由用户当场决定(与旧页保持一致的强制选择体验)。 + void _maybeShowLanguageDialog() { + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const LanguageSelectionDialog(mandatory: true), + ); + } + + @override + void dispose() { + _entranceController.dispose(); + _userCtrl.dispose(); + _pwdCtrl.dispose(); + super.dispose(); + } + + Animation _interval(double begin, double end) { + return CurvedAnimation( + parent: _entranceController, + curve: Interval(begin, end, curve: Curves.easeOut), + ); + } + + // 执行登录逻辑(复用现有 LoginCubit:账号 + 密码,sourceType=2) + void _handleLogin() { + context.read().login(_userCtrl.text, _pwdCtrl.text, 2); + } + + void _onTabTap(int index) { + if (index == 1) { + // 注册暂不支持:仅提示,不切换内容 + CapsuleToast.show('注册功能暂未开放', showCheck: false); + return; + } + setState(() => _selectedTab = 0); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: _bgTop, + body: DecoratedBox( + // 底色:纵向浅蓝渐变 + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [_bgTop, _bgMid, _bgBottom], + stops: [0.0, 0.4, 1.0], + ), + ), + child: DecoratedBox( + // 左上角蓝色晕(对应设计稿 logo 一侧的淡蓝) + decoration: const BoxDecoration( + gradient: RadialGradient( + center: Alignment(-1.1, -1.0), + radius: 1.1, + colors: [Color(0xFFD6E8FC), Color(0x00D6E8FC)], + ), + ), + child: DecoratedBox( + // 右上角蓝色晕(对应设计稿顶部右侧的淡蓝) + decoration: const BoxDecoration( + gradient: RadialGradient( + center: Alignment(1.1, -0.9), + radius: 1.25, + colors: [Color(0xFFC7DFF8), Color(0x00C7DFF8)], + ), + ), + child: SafeArea( + bottom: false, + child: BlocListener( + listener: (context, state) { + if (state is LoginSuccess) { + context.go(RoutePaths.home); + } else if (state is LoginFailure) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${AppLocalizations.of(context).translate('login.login_failed')},${state.message}', + ), + ), + ); + } + }, + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: ConstrainedBox( + constraints: + BoxConstraints(minHeight: constraints.maxHeight), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + // 顶部:logo + 语言胶囊 + _Entrance( + animation: _interval(0.00, 0.35), + child: _buildTopBar(), + ), + const SizedBox(height: 12), + // 欢迎标题 + 副标题 + _Entrance( + animation: _interval(0.08, 0.43), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '欢迎使用飒沓机器人', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: _navy, + height: 1.2, + ), + ), + const SizedBox(height: 4), + const Text( + '统一管理您的机器人设备', + style: TextStyle( + fontSize: 13, + color: _grayText, + ), + ), + ], + ), + ), + const SizedBox(height: 12), + // 机器人 Hero 卡片 + _Entrance( + animation: _interval(0.16, 0.51), + child: _buildHeroCard(), + ), + const SizedBox(height: 12), + // 白色表单卡片 + _Entrance( + animation: _interval(0.26, 0.63), + child: _buildFormCard(), + ), + const SizedBox(height: 12), + // 帮助与客服 + _Entrance( + animation: _interval(0.40, 0.75), + child: Center(child: _buildHelpRow()), + ), + const SizedBox(height: 16), + ], + ), + ), + ); + }, + ), + ), + ), + ), + ), + ), + ); + } + + // ===== 顶部栏:logo(左)+ 语言胶囊(右)===== + // 注意:本页 LayoutBuilder 的 builder 在布局回调阶段执行, + // 不能在其子树里直接调用 context.select/watch(Provider 会断言失败)。 + // 因此用 BlocBuilder 包裹 LocaleCubit 的读取,让它在正常 build 阶段取值。 + Widget _buildTopBar() { + return BlocBuilder( + builder: (context, locale) { + final langName = AppLanguages.instance.nameOf(locale); + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Image.asset( + // 中文用国内版 logo,英文用英文专版 logo(随语言切换实时更新) + locale.languageCode == 'zh' + ? 'assets/images/logo_sata.png' + : 'assets/images/logo_stata-en.png', + height: 40, + fit: BoxFit.contain, + ), + GestureDetector( + onTap: () { + showDialog( + context: context, + builder: (_) => + const LanguageSelectionDialog(mandatory: false), + ); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(100), + border: Border.all(color: _inputBorder), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.language, size: 16, color: _primaryBlue), + const SizedBox(width: 6), + Text( + langName, + style: const TextStyle(fontSize: 13, color: _navy), + ), + const SizedBox(width: 4), + const Icon(Icons.expand_more, size: 16, color: _grayText), + ], + ), + ), + ), + ], + ); + }, + ); + } + + // ===== 机器人 Hero 卡片 ===== + Widget _buildHeroCard() { + return ClipRRect( + borderRadius: BorderRadius.circular(16), + child: SizedBox( + height: 130, + width: double.infinity, + child: Stack( + fit: StackFit.expand, + children: [ + Image.asset( + 'assets/images/robot_logo_login.png', + fit: BoxFit.cover, + ), + // 底部渐变遮罩,衬托文字 + DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.center, + colors: [ + Colors.black.withValues(alpha: 0.35), + Colors.transparent, + ], + ), + ), + ), + const Positioned( + left: 12, + bottom: 8, + child: Text( + 'SMART ROBOTICS · CONNECTED', + style: TextStyle( + color: Colors.white, + fontSize: 11, + letterSpacing: 1.5, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ); + } + + // ===== 白色表单卡片 ===== + Widget _buildFormCard() { + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(16, 16, 16, 18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: const Color(0xFF1C96F6).withValues(alpha: 0.06), + blurRadius: 20, + offset: const Offset(0, 6), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSegmentTab(), + const SizedBox(height: 14), + // 账号 + _buildFieldLabel('账号'), + const SizedBox(height: 6), + _buildTextField( + controller: _userCtrl, + icon: Icons.person_outline, + hint: AppLocalizations.of(context).translate('login.username_hint'), + ), + const SizedBox(height: 12), + // 密码 + _buildFieldLabel('密码'), + const SizedBox(height: 6), + _buildTextField( + controller: _pwdCtrl, + icon: Icons.lock_outline, + hint: AppLocalizations.of(context).translate('login.password_hint'), + obscureText: _obscurePwd, + suffix: IconButton( + icon: Icon( + _obscurePwd + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + size: 20, + color: _grayText, + ), + onPressed: () => setState(() => _obscurePwd = !_obscurePwd), + ), + ), + const SizedBox(height: 12), + _buildProtocolSection(), + const SizedBox(height: 4), + const Padding( + padding: EdgeInsets.only(left: 32), + child: Text( + '新账户注册前须主动勾选确认', + style: TextStyle(fontSize: 12, color: _grayText), + ), + ), + const SizedBox(height: 14), + // 登录按钮 + BlocBuilder( + builder: (context, state) { + final isLoading = state is LoginLoading; + return _GradientButton( + isLoading: isLoading, + onPressed: () { + if (!_isAgreed) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context) + .translate('login.agree_first')), + duration: const Duration(seconds: 1), + ), + ); + return; + } + _handleLogin(); + }, + ); + }, + ), + ], + ), + ); + } + + // ===== 登录 / 注册 分段 Tab ===== + Widget _buildSegmentTab() { + return Container( + height: 42, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: _tabBg, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Expanded(child: _buildTabItem(0, '登录')), + Expanded(child: _buildTabItem(1, '注册')), + ], + ), + ); + } + + Widget _buildTabItem(int index, String label) { + final selected = _selectedTab == index; + return GestureDetector( + onTap: () => _onTabTap(index), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + alignment: Alignment.center, + decoration: BoxDecoration( + gradient: selected + ? const LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [Color(0xFF2BAFFE), Color(0xFF0B7DEE)], + ) + : null, + borderRadius: BorderRadius.circular(9), + ), + child: Text( + label, + style: TextStyle( + fontSize: 15, + fontWeight: selected ? FontWeight.w600 : FontWeight.w400, + color: selected ? Colors.white : _tabUnselectedText, + ), + ), + ), + ); + } + + Widget _buildFieldLabel(String text) { + return Text( + text, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: _navy, + ), + ); + } + + Widget _buildTextField({ + required TextEditingController controller, + required IconData icon, + required String hint, + bool obscureText = false, + Widget? suffix, + }) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(fontSize: 14, color: _navy), + cursorColor: _primaryBlue, + decoration: InputDecoration( + hintText: hint, + hintStyle: const TextStyle(color: _grayText, fontSize: 14), + filled: true, + fillColor: Colors.white, + prefixIcon: Icon(icon, size: 20, color: _primaryBlue), + prefixIconConstraints: + const BoxConstraints(minWidth: 44, minHeight: 48), + suffixIcon: suffix, + contentPadding: const EdgeInsets.symmetric(vertical: 12), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: _inputBorder), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: _primaryBlue, width: 1.2), + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: _inputBorder), + ), + ), + ); + } + + // ===== 协议勾选 ===== + Widget _buildProtocolSection() { + final loc = AppLocalizations.of(context); + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 24, + width: 24, + child: Checkbox( + value: _isAgreed, + activeColor: _primaryBlue, + checkColor: Colors.white, + side: const BorderSide(color: _inputBorder), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onChanged: (val) => setState(() => _isAgreed = val!), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + loc.translate('login.agree_prefix'), + style: const TextStyle(fontSize: 13, color: _grayText), + ), + _protocolText(loc.translate('login.user_agreement_link'), + () => _showProtocolDetail(loc.translate('login.user_agreement_link'))), + Text( + loc.translate('login.agree_and'), + style: const TextStyle(fontSize: 13, color: _grayText), + ), + _protocolText(loc.translate('login.privacy_policy_link'), + () => _showProtocolDetail(loc.translate('login.privacy_policy_link'))), + ], + ), + ), + ], + ); + } + + Widget _protocolText(String text, VoidCallback onTap) { + return GestureDetector( + onTap: onTap, + child: Text( + text, + style: const TextStyle( + fontSize: 13, + color: _primaryBlue, + fontWeight: FontWeight.w600, + ), + ), + ); + } + + Widget _buildHelpRow() { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.help_outline, size: 16, color: _grayText), + const SizedBox(width: 4), + GestureDetector( + onTap: () => CapsuleToast.show('帮助与客服功能开发中', showCheck: false), + child: const Text( + '帮助与客服', + style: TextStyle(fontSize: 13, color: _grayText), + ), + ), + ], + ); + } + + void _showProtocolDetail(String title) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => Container( + padding: const EdgeInsets.all(24), + height: MediaQuery.of(context).size.height * 0.7, + child: Column( + children: [ + Text( + title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: _navy, + ), + ), + const Divider(height: 32, color: _inputBorder), + Expanded( + child: SingleChildScrollView( + child: Text( + (AppLocalizations.of(context) + .translate('login.protocol_detail_text') + .replaceAll('%s', title) + + '\n') * + 20, + style: const TextStyle( + color: _tabUnselectedText, + height: 1.5, + ), + ), + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => Navigator.pop(context), + style: ElevatedButton.styleFrom( + backgroundColor: _primaryBlue, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text(AppLocalizations.of(context) + .translate('login.understood')), + ), + ), + ], + ), + ), + ); + } +} + +/// 主按钮:横向蓝色渐变胶囊(#2BAFFE → #1C96F6 → #0B7DEE),带前置箭头。 +class _GradientButton extends StatelessWidget { + const _GradientButton({required this.onPressed, required this.isLoading}); + + final VoidCallback onPressed; + final bool isLoading; + + @override + Widget build(BuildContext context) { + final enabled = !isLoading; + return Opacity( + opacity: enabled ? 1 : 0.7, + child: GestureDetector( + onTap: enabled ? onPressed : null, + child: Container( + width: double.infinity, + height: 46, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + gradient: const LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [Color(0xFF2BAFFE), Color(0xFF1C96F6), Color(0xFF0B7DEE)], + stops: [0.0, 0.5, 1.0], + ), + boxShadow: [ + BoxShadow( + color: const Color(0xFF1C96F6).withValues(alpha: 0.30), + blurRadius: 14, + offset: const Offset(0, 6), + ), + ], + ), + child: isLoading + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.4, + color: Colors.white, + ), + ) + : const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.arrow_forward_rounded, + size: 20, color: Colors.white), + SizedBox(width: 8), + Text( + '登录', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, + letterSpacing: 1, + ), + ), + ], + ), + ), + ), + ); + } +} + +/// 进场动画包装:淡入 + 上滑。 +class _Entrance extends StatelessWidget { + const _Entrance({required this.animation, required this.child}); + + final Animation animation; + final Widget child; + + @override + Widget build(BuildContext context) { + final curved = + CurvedAnimation(parent: animation, curve: Curves.easeOutCubic); + final slideAnim = Tween( + begin: const Offset(0, 0.12), + end: Offset.zero, + ).animate(curved); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: slideAnim, child: child), + ); + } +} diff --git a/lib/features/auth/presentation/routes/auth_routes.dart b/lib/features/auth/presentation/routes/auth_routes.dart index d0b72fda..b26e3aec 100644 --- a/lib/features/auth/presentation/routes/auth_routes.dart +++ b/lib/features/auth/presentation/routes/auth_routes.dart @@ -1,6 +1,8 @@ import 'package:go_router/go_router.dart'; import 'package:maibu_satabot_v2/core/router/route_paths.dart'; -import 'package:maibu_satabot_v2/features/auth/presentation/pages/login_page.dart'; +import 'package:maibu_satabot_v2/features/auth/presentation/pages/login_page_v2.dart'; +// 旧登录页保留备用:如需回退,将下方 LoginPageV2 换回 LoginPage 并恢复对应 import。 +// import 'package:maibu_satabot_v2/features/auth/presentation/pages/login_page.dart'; import '../pages/register_page.dart'; import '../pages/splash_page.dart'; @@ -14,7 +16,7 @@ class AuthRoutes { ), GoRoute( path: RoutePaths.login, - builder: (context, state) => const LoginPage(), + builder: (context, state) => const LoginPageV2(), ), GoRoute( path: RoutePaths.register, diff --git a/lib/features/main_container/presentation/main_wrapper.dart b/lib/features/main_container/presentation/main_wrapper.dart index 16311664..26beba21 100644 --- a/lib/features/main_container/presentation/main_wrapper.dart +++ b/lib/features/main_container/presentation/main_wrapper.dart @@ -6,13 +6,18 @@ import 'package:maibu_satabot_v2/features/main_container/domain/tab_config.dart' import 'package:maibu_satabot_v2/features/home/presentation/pages/home_page.dart'; import 'package:maibu_satabot_v2/features/v2/home/presentation/pages/home_v2_page.dart'; import 'package:maibu_satabot_v2/features/v2/home/presentation/bloc/home_v2_bloc.dart'; -import 'package:maibu_satabot_v2/features/v2/device_list/presentation/pages/device_status_page.dart'; +// 旧设备页保留备用:如需回退,换回 DeviceStatusPage 并恢复下方 import。 +// import 'package:maibu_satabot_v2/features/v2/device_list/presentation/pages/device_status_page.dart'; +import 'package:maibu_satabot_v2/features/v2/device_list/presentation/pages/device_status_page_v2.dart'; import 'package:maibu_satabot_v2/features/ai/presentation/pages/ai_page.dart'; import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/pages/alarm_center_page.dart'; import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/bloc/alarm_cubit.dart'; import 'package:maibu_satabot_v2/features/my/presentation/pages/my_page.dart'; import 'package:maibu_satabot_v2/features/v2/my/presentation/pages/my_page.dart' as my_v2; +// 新版「我的」页(M01 设计稿);旧版 my_page.dart 保留,如需回退换回 my_v2.MyPage 即可。 +import 'package:maibu_satabot_v2/features/v2/my/presentation/pages/my_page_v2.dart' + as my_v2_new; import 'package:maibu_satabot_v2/features/v2/workorder/presentation/pages/workorder_page.dart'; import 'package:maibu_satabot_v2/features/v2/report/presentation/pages/report_page.dart'; import 'package:maibu_satabot_v2/features/v2/device_list/presentation/float_bar/view/float_bar_widget.dart'; @@ -91,7 +96,7 @@ class _MainWrapperState extends State { case 'home': return const HomePage(); case 'device': - return const DeviceStatusPage(); + return const DeviceStatusPageV2(); case 'ai': return const AiPage(); case 'warning': @@ -106,7 +111,7 @@ class _MainWrapperState extends State { case 'my': return const MyPage(); case 'me': - return const my_v2.MyPage(); + return const my_v2_new.MyPageV2(); default: return const HomePage(); } diff --git a/lib/features/main_container/presentation/pages/custom_main_container.dart b/lib/features/main_container/presentation/pages/custom_main_container.dart index c1d15e45..c8a6bc82 100644 --- a/lib/features/main_container/presentation/pages/custom_main_container.dart +++ b/lib/features/main_container/presentation/pages/custom_main_container.dart @@ -9,7 +9,9 @@ import 'package:maibu_satabot_v2/features/my/presentation/pages/my_page.dart'; import 'package:maibu_satabot_v2/features/main_container/presentation/cubit/tab_config_cubit.dart'; import 'package:maibu_satabot_v2/features/main_container/presentation/widgets/custom_bottom_nav_bar.dart'; import 'package:maibu_satabot_v2/features/v2/home/presentation/pages/home_v2_page.dart'; -import 'package:maibu_satabot_v2/features/v2/device_list/presentation/pages/device_status_page.dart'; +// 旧设备页保留备用:如需回退,换回 DeviceStatusPage 并恢复下方 import。 +// import 'package:maibu_satabot_v2/features/v2/device_list/presentation/pages/device_status_page.dart'; +import 'package:maibu_satabot_v2/features/v2/device_list/presentation/pages/device_status_page_v2.dart'; import 'package:maibu_satabot_v2/features/v2/workorder/presentation/pages/workorder_page.dart'; import 'package:maibu_satabot_v2/features/v2/report/presentation/pages/report_page.dart'; import 'package:maibu_satabot_v2/features/v2/device_list/presentation/float_bar/view/float_bar_widget.dart'; @@ -142,7 +144,7 @@ class _CustomMainContainerState extends State return const HomeV2Page(key: ValueKey('home_v2')); case 'device': debugPrint('✅ 匹配到 device'); - return const DeviceStatusPage(key: ValueKey('device')); + return const DeviceStatusPageV2(key: ValueKey('device')); case 'ai': debugPrint('✅ 匹配到 ai'); return const AiPage(key: ValueKey('ai')); diff --git a/lib/features/v2/device_list/presentation/pages/device_status_page.dart b/lib/features/v2/device_list/presentation/pages/device_status_page.dart index 8d0b507c..35a9255b 100644 --- a/lib/features/v2/device_list/presentation/pages/device_status_page.dart +++ b/lib/features/v2/device_list/presentation/pages/device_status_page.dart @@ -35,6 +35,7 @@ import 'drone_station_detail_page.dart'; import '../../../../../core/network/mqtt/data/datasources/drone_osd_datasource.dart'; import 'qr_scanner_page.dart'; import 'bind_device_page.dart'; +import 'no_device_page_v2.dart'; import '../../../local_camera/presentation/pages/local_camera_page.dart'; import '../../../usb_camera/presentation/pages/usb_camera_page.dart'; import '../../../../remote_control/presentation/bloc/remote_control_cubit.dart'; @@ -190,50 +191,70 @@ class _DeviceStatusViewState extends State { @override Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; + // 无设备时整页是固定浅色渐变(与我的页 V2 同款),状态栏图标须恒为深色; + // 若跟随深色主题给白色图标,会消失在浅蓝背景上。 + final isNoDevice = _isNoDeviceState( + context.watch().state, + ); return AnnotatedRegion( value: SystemUiOverlayStyle( statusBarColor: Colors.transparent, - statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark, + statusBarIconBrightness: isNoDevice + ? Brightness.dark + : (Theme.of(context).brightness == Brightness.dark + ? Brightness.light + : Brightness.dark), ), child: Scaffold( backgroundColor: context.appColors.pageBackground, - body: SafeArea( - child: - BlocConsumer< - DeviceListBloc.DeviceStatusBloc, - DeviceListState.DeviceStatusState - >( - listener: (context, state) { - // 🔥 监听错误状态,显示友好提示 - if (state is DeviceListState.DeviceStatusError && - state.shouldShowError) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(state.message), - duration: const Duration(seconds: 2), - behavior: SnackBarBehavior.floating, - backgroundColor: context.appColors.warning, - ), - ); - } - }, - builder: (context, state) { - return Column( - children: [ - _buildAppBar(context), - _buildSearchBar(context), - _buildTypeFilterBar(context), - Expanded(child: _buildContent(context, state)), - ], - ); - }, + // SafeArea 下移到常规列表分支:无设备页渐变需铺进状态栏(与我的页一致) + body: BlocConsumer< + DeviceListBloc.DeviceStatusBloc, + DeviceListState.DeviceStatusState + >( + listener: (context, state) { + // 🔥 监听错误状态,显示友好提示 + if (state is DeviceListState.DeviceStatusError && + state.shouldShowError) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.message), + duration: const Duration(seconds: 2), + behavior: SnackBarBehavior.floating, + backgroundColor: context.appColors.warning, + ), + ); + } + }, + builder: (context, state) { + // 无设备:该场站设备接口返回空时,整页(含顶栏)切换到 + // 「01_无设备首页」设计稿页面;逻辑链保持 场站→siteId→设备列表 不变。 + if (_isNoDeviceState(state)) { + return const NoDevicePageV2(); + } + return SafeArea( + child: Column( + children: [ + _buildAppBar(context), + _buildSearchBar(context), + _buildTypeFilterBar(context), + Expanded(child: _buildContent(context, state)), + ], ), + ); + }, ), ), ); } + /// 无设备判据:「全部」标签下设备接口返回空 + bool _isNoDeviceState(DeviceListState.DeviceStatusState state) { + return state is DeviceListState.DeviceStatusLoaded && + state.selectedType == 'all' && + state.devices.isEmpty; + } + Widget _buildAppBar(BuildContext context) { // 🔥 个人角色(roleKey == 'personal')隐藏场站选择器 final roleKey = context.watch().state.user?.roleKey; diff --git a/lib/features/v2/device_list/presentation/pages/device_status_page_v2.dart b/lib/features/v2/device_list/presentation/pages/device_status_page_v2.dart new file mode 100644 index 00000000..a39c010b --- /dev/null +++ b/lib/features/v2/device_list/presentation/pages/device_status_page_v2.dart @@ -0,0 +1,2466 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; +import 'package:maibu_satabot_v2/core/theme/AppTheme.dart'; +import '../../../../../core/di/injection.dart'; +import '../../../../../core/app/app_user_cubit.dart'; +import '../../../../../core/bluetooth/ble_manager.dart'; +import '../../../../../components/tcp_status_indicator.dart'; +import '../../../../../components/device_status_modal.dart'; +import '../../../../v2/site/presentation/cubit/site_cubit.dart'; +import '../../../site/presentation/widgets/site_selector_widget.dart'; +import '../../../../main_container/domain/tab_config.dart'; +import '../bloc/device_status_bloc.dart' as DeviceListBloc; +import '../bloc/device_status_event.dart' as DeviceListEvent; +import '../bloc/device_status_state.dart' as DeviceListState; +import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_status_bloc.dart'; +import '../bloc/drone_station_bloc.dart'; +import '../bloc/drone_station_event.dart'; +import '../bloc/drone_station_state.dart'; +import 'ble_device_detail_page.dart'; +import '../bloc/robot_list_bloc.dart'; // 🔥 添加 RobotListBloc 导入 +import '../bloc/robot_list_event.dart'; // 🔥 添加 RobotListEvent 导入 +import '../bloc/robot_list_state.dart'; // 🔥 添加 RobotListState 导入 +import '../widgets/device_item_widget.dart'; +import '../widgets/drone_station_item_card.dart'; +import '../widgets/robot_item_card.dart'; // 🔥 添加 RobotItemCard 导入 +import '../widgets/bluetooth_scan_modal.dart'; +import '../../domain/entities/drone_station_entity.dart'; // 🔥 添加 DroneStationEntity 导入 +import 'robot_list_page.dart'; +import 'robot_control_page.dart'; +import 'drone_station_detail_page.dart'; +import '../../../../../core/network/mqtt/data/datasources/drone_osd_datasource.dart'; +import 'qr_scanner_page.dart'; +import 'bind_device_page.dart'; +import 'no_device_page_v2.dart'; +import 'package:maibu_satabot_v2/components/capsule_toast.dart'; +import '../widgets/device_card_v2.dart'; +import '../../data/models/robot_data_model.dart'; +import '../../../local_camera/presentation/pages/local_camera_page.dart'; +import '../../../usb_camera/presentation/pages/usb_camera_page.dart'; +import '../../../../remote_control/presentation/bloc/remote_control_cubit.dart'; +import '../../../../devices/domain/entities/device_entity.dart'; +import 'package:get_it/get_it.dart'; + +/// 设备状态页面 V2 —— 按「02_设备卡片总览」设计稿换皮。 +/// +/// 由 device_status_page.dart 整体复制而来:数据链(场站→siteId→设备列表)、 +/// 卡片点击跳转、蓝牙连接流程等逻辑与旧页逐行一致,仅替换视觉层; +/// 旧页保留可随时回退(main_wrapper 切回 DeviceStatusPage 即可)。 +class DeviceStatusPageV2 extends StatelessWidget { + const DeviceStatusPageV2({super.key}); + + @override + Widget build(BuildContext context) { + final selectedSite = sl().state.selectedSite; + final siteId = selectedSite?.id; + + return BlocProvider( + create: (_) => + sl() + ..add(DeviceListEvent.DeviceStatusLoadData(siteId: siteId)), + child: const DeviceStatusViewV2(), + ); + } +} + +/// 设备状态视图 V2 +class DeviceStatusViewV2 extends StatefulWidget { + const DeviceStatusViewV2({super.key}); + + @override + State createState() => _DeviceStatusViewV2State(); +} + +class _DeviceStatusViewV2State extends State { + final _searchController = TextEditingController(); + + /// 🔥 监听电站切换:只要电站发生变化,就根据当前选中的标签栏自动刷新对应接口 + StreamSubscription? _siteSub; + + /// 当前缓存的 siteId,用于判断是否真的发生了变化 + int? _currentSiteId; + + /// 🔥 下拉刷新计数器:变更时联动重建"全部"标签下的机器人/无人机场内嵌列表, + /// 使其与统计卡片(DeviceStatusBloc)同步刷新,避免统计数字与实际卡片对不上 + int _refreshTick = 0; + + @override + void initState() { + super.initState(); + // 🔥 个人角色(roleKey == 'personal')只有一个场站,自动默认选中第一个 + final roleKey = sl().state.user?.roleKey; + if (roleKey == TabConfig.personalRoleKey) { + final siteCubit = sl(); + if (siteCubit.state.sites.isEmpty) { + // 场站列表为空则先加载 + siteCubit.loadSites(); + } else if (siteCubit.state.selectedSite == null) { + // 还没有选中场站则自动选中第一个 + final firstSite = siteCubit.state.sites.first; + debugPrint( + '🔥 [DeviceStatus] 个人角色自动选中首个场站: ${firstSite.siteName} (id=${firstSite.id})', + ); + siteCubit.selectSite(firstSite); + } + } + _currentSiteId = sl().state.selectedSite?.id; + _siteSub = sl().stream.listen((siteState) { + if (!mounted) return; + final newSiteId = siteState.selectedSite?.id; + // 只有 siteId 真正变化时才刷新(避免无意义的重复请求) + if (newSiteId != _currentSiteId) { + _currentSiteId = newSiteId; + _refreshCurrentTabForSiteChange(newSiteId); + } + }); + } + + @override + void dispose() { + _siteSub?.cancel(); + _searchController.dispose(); + super.dispose(); + } + + /// 🔥 电站切换后,根据当前选中的标签栏自动刷新对应接口 + void _refreshCurrentTabForSiteChange(int? newSiteId) { + final bloc = context.read(); + final state = bloc.state; + // 取出当前选中的标签类型;若 BLoC 还未加载完成则默认 'all' + final selectedType = (state is DeviceListState.DeviceStatusLoaded) + ? state.selectedType + : 'all'; + + debugPrint( + '🔄 [DeviceStatus] 电站切换 → siteId=$newSiteId, 当前标签=$selectedType, 自动刷新', + ); + + // 1. 主设备列表(all / inverter / combiner_box / module / monitor 共用 DeviceStatusBloc) + // 直接派发 Refresh 事件并带上新 siteId + bloc.add(DeviceListEvent.DeviceStatusRefresh(siteId: newSiteId)); + + // 2. robot / drone_station 由各自 BlocProvider 创建,通过 setState + ValueKey 重建子树 + // 使其用新 siteId 重新加载数据 + if (selectedType == 'robot' || + selectedType == 'drone_station' || + selectedType == 'all') { + setState(() {}); + } + } + + /// 🔥 机器人型号家族前缀(小写,startsWith 匹配,覆盖 MC700/MC700STD/MC700PLUS/MCAIR/RCHWD/RCHETD 等全系列) + static const _robotPrefixes = ['mc', 'rch', 'rcetd', 'rchetd']; + + /// 🔥 机器人类型中文名关键字(接口 deviceTypeName 可能是纯中文) + static const _robotTypeKeywords = ['除草', '巡检', '清洗', '割草', '机器人']; + + /// 🔥 类型 tab 的 code → 别名集合。 + /// 筛选条用英文 code(inverter),但接口 deviceTypeName 可能返回中文名(逆变器), + /// 只做 `device.type == code` 的精确比较会让这四个 tab 恒为空,所以两套写法都认。 + static const Map> _typeAliases = { + 'inverter': {'inverter', '逆变器'}, + 'combiner_box': {'combiner_box', '汇流箱'}, + 'module': {'module', '组件'}, + 'monitor': {'monitor', '监控'}, + }; + + /// 设备类型是否命中当前 tab(大小写与首尾空格不敏感) + static bool _matchTypeTab(String deviceType, String selectedType) { + final normalized = deviceType.trim().toLowerCase(); + final aliases = _typeAliases[selectedType]; + if (aliases != null) { + return aliases.any((alias) => alias.toLowerCase() == normalized); + } + return normalized == selectedType.toLowerCase(); + } + + /// 🔥 通用判断:是否机器人(型号前缀 + 中文关键字双重匹配,新型号也能自动识别) + static bool _isRobotDevice(String type, String name, String deviceId) { + final haystacks = [ + type.toLowerCase(), + name.toLowerCase(), + deviceId.toLowerCase(), + ]; + for (final s in haystacks) { + for (final p in _robotPrefixes) { + if (s.startsWith(p)) return true; + } + } + for (final k in _robotTypeKeywords) { + if (type.contains(k) || name.contains(k)) return true; + } + return false; + } + + @override + Widget build(BuildContext context) { + // 无设备页/卡片总览均为固定浅色设计,状态栏图标须恒为深色; + // 若跟随深色主题给白色图标,会消失在浅蓝背景上。 + final blocState = context.watch().state; + final fixedLightUi = + _isNoDeviceState(blocState) || + blocState is DeviceListState.DeviceStatusLoaded; + return AnnotatedRegion( + value: SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: fixedLightUi + ? Brightness.dark + : (Theme.of(context).brightness == Brightness.dark + ? Brightness.light + : Brightness.dark), + ), + child: Scaffold( + backgroundColor: context.appColors.pageBackground, + // SafeArea 下移到常规列表分支:无设备页渐变需铺进状态栏(与我的页一致) + body: BlocConsumer< + DeviceListBloc.DeviceStatusBloc, + DeviceListState.DeviceStatusState + >( + listener: (context, state) { + // 🔥 监听错误状态,显示友好提示 + if (state is DeviceListState.DeviceStatusError && + state.shouldShowError) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.message), + duration: const Duration(seconds: 2), + behavior: SnackBarBehavior.floating, + backgroundColor: context.appColors.warning, + ), + ); + } + }, + builder: (context, state) { + // 无设备:该场站设备接口返回空时,整页(含顶栏)切换到 + // 「01_无设备首页」设计稿页面;逻辑链保持 场站→siteId→设备列表 不变。 + if (_isNoDeviceState(state)) { + return const NoDevicePageV2(); + } + // 有数据:整页切到「02_设备卡片总览」设计稿 UI。 + if (state is DeviceListState.DeviceStatusLoaded) { + return _buildV2Loaded(context, state); + } + // Initial / Loading:走 V2 骨架屏 + 旋转动画,避免切「设备」tab + // 的一瞬间闪现旧版页面;数据到达后无缝切到卡片列表或无设备页。 + if (state is! DeviceListState.DeviceStatusError) { + return _buildV2Skeleton(context); + } + // Error:保留旧布局兜底(含错误提示/重试),点击与数据逻辑完全一致。 + return SafeArea( + child: Column( + children: [ + _buildAppBar(context), + _buildSearchBar(context), + _buildTypeFilterBar(context), + Expanded(child: _buildContent(context, state)), + ], + ), + ); + }, + ), + ), + ); + } + + /// 无设备判据:「全部」标签下设备接口返回空 + bool _isNoDeviceState(DeviceListState.DeviceStatusState state) { + return state is DeviceListState.DeviceStatusLoaded && + state.selectedType == 'all' && + state.devices.isEmpty; + } + + // ==================== V2 设计稿 UI(02_设备卡片总览) ==================== + // 仅替换视觉层:数据源仍是 DeviceStatusBloc + RobotListBloc + DroneStationBloc, + // 点击分发(hasHost→蓝牙流程 / 机器人控制页 / 机场详情页)与旧列表逐行一致。 + // 版本/SN/在线态/电量等后端未返回字段用设备 ID 稳定伪随机占位(DeviceV2Display)。 + + /// 状态过滤药丸:all / online / working / offline + String _statusFilter = 'all'; + + static const _v2Navy = Color(0xFF1A2B4A); + static const _v2Gray = Color(0xFF86909C); + static const _v2Blue = Color(0xFF1C96F6); + static const _v2Border = Color(0xFFE9F1FA); + static const _v2BtnGradient = LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [Color(0xFF2BAFFE), Color(0xFF1C96F6), Color(0xFF0B7DEE)], + stops: [0.0, 0.5, 1.0], + ); + + Widget _buildV2Loaded( + BuildContext context, + DeviceListState.DeviceStatusLoaded state, + ) { + final siteId = _currentSiteId; + return MultiBlocProvider( + // 场站切换/下拉刷新时重建两个列表 Bloc(与旧内嵌列表 ValueKey 机制一致) + key: ValueKey('v2_lists_${siteId}_$_refreshTick'), + providers: [ + BlocProvider( + create: (_) => + sl()..add(RobotListLoadData(siteId: siteId)), + ), + BlocProvider( + create: (_) { + final bloc = sl(); + if (siteId != null) { + bloc.add(DroneStationLoadData(siteId)); + } + return bloc; + }, + ), + ], + child: BlocConsumer( + listener: (context, s) { + if (s is RobotListError && s.shouldShowError) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(s.message), + duration: const Duration(seconds: 2), + behavior: SnackBarBehavior.floating, + backgroundColor: context.appColors.warning, + ), + ); + } + }, + builder: (context, robotState) => + BlocConsumer( + listener: (context, s) { + if (s is DroneStationError && s.shouldShowError) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(s.message), + duration: const Duration(seconds: 2), + behavior: SnackBarBehavior.floating, + backgroundColor: context.appColors.warning, + ), + ); + } + }, + builder: (context, stationState) => _buildV2Scaffold( + context, + robotState, + stationState, + ), + ), + ), + ); + } + + /// Initial/Loading 骨架屏:V2 同款背景 + 顶栏 + 场站入口 + 标题, + /// 中间放旋转加载动画。与加载完成后的页面布局一致,避免切 tab 瞬间闪现旧版页面。 + Widget _buildV2Skeleton(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFFAFCFF), + body: Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: Image.asset( + 'assets/images/shebei_bg.png', + fit: BoxFit.cover, + alignment: Alignment.topCenter, + ), + ), + SafeArea( + bottom: false, + child: Column( + children: [ + _buildV2TopBar(context), + _buildV2SiteEntry(context), + _buildV2Header(), + const Expanded( + child: Center( + child: CircularProgressIndicator(color: _v2Blue), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildV2Scaffold( + BuildContext context, + RobotListState robotState, + DroneStationState stationState, + ) { + // 汇总展示项:机器人 + 无人机机场(与旧「全部」标签渲染范围一致) + final items = <_V2DeviceItem>[]; + if (robotState is RobotListLoaded) { + for (final r in robotState.robots) { + items.add( + _V2DeviceItem( + name: (r.alias != null && r.alias!.isNotEmpty) ? r.alias! : r.name, + modelText: '${r.type} · ${DeviceV2Display.editionOf(r.id)}', + snKey: r.id, + status: _robotV2Status(r), + battery: r.isOnline ? r.battery.round() : null, + robot: r, + ), + ); + } + } + if (stationState is DroneStationLoaded) { + for (final s in stationState.stations) { + final sn = s.gatewaySn; + items.add( + _V2DeviceItem( + name: s.callsign.isNotEmpty ? s.callsign : sn, + modelText: '无人机机场 · ${DeviceV2Display.editionOf(sn)}', + snKey: sn, + status: s.isOnline + ? DeviceV2Status.online + : DeviceV2Status.offline, + battery: s.isOnline ? s.capacityPercent?.round() : null, + station: s, + ), + ); + } + } + + // 药丸计数:设计稿口径「在线 ⊇ 作业中」 + final onlineCount = items + .where((e) => e.status != DeviceV2Status.offline) + .length; + final workingCount = items + .where((e) => e.status == DeviceV2Status.working) + .length; + final offlineCount = items + .where((e) => e.status == DeviceV2Status.offline) + .length; + final visible = switch (_statusFilter) { + 'online' => items + .where((e) => e.status != DeviceV2Status.offline) + .toList(), + 'working' => items + .where((e) => e.status == DeviceV2Status.working) + .toList(), + 'offline' => items + .where((e) => e.status == DeviceV2Status.offline) + .toList(), + _ => items, + }; + + final stillLoading = + items.isEmpty && + (robotState is RobotListLoading || stationState is DroneStationLoading); + + return Scaffold( + backgroundColor: const Color(0xFFFAFCFF), + body: Stack( + fit: StackFit.expand, + children: [ + // 背景:与无设备页同一张地图背景切图 + Positioned.fill( + child: Image.asset( + 'assets/images/shebei_bg.png', + fit: BoxFit.cover, + alignment: Alignment.topCenter, + ), + ), + SafeArea( + bottom: false, + child: Column( + children: [ + _buildV2TopBar(context), + _buildV2SiteEntry(context), + _buildV2Header(), + _buildViewToggle(), + _buildV2SearchBox(), + _buildStatusPills( + items.length, + onlineCount, + workingCount, + offlineCount, + ), + Expanded( + child: RefreshIndicator( + onRefresh: () async { + // 与旧「全部」下拉刷新一致:统计 + 两个列表一起重拉 + context + .read() + .add(const DeviceListEvent.DeviceStatusRefresh()); + setState(() => _refreshTick++); + }, + color: _v2Blue, + child: stillLoading + ? ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: const [ + SizedBox(height: 120), + Center( + child: CircularProgressIndicator( + color: _v2Blue, + ), + ), + ], + ) + : ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 6, 16, 24), + children: [ + if (visible.isEmpty) + const Padding( + padding: EdgeInsets.all(40), + child: Center( + child: Text( + '没有匹配的设备', + style: TextStyle( + fontSize: 13, + color: _v2Gray, + ), + ), + ), + ) + else + ...visible.map( + (e) => Padding( + padding: const EdgeInsets.only( + bottom: 14, + ), + child: DeviceCardV2( + name: e.name, + modelText: e.modelText, + snKey: e.snKey, + status: e.status, + batteryLevel: e.battery, + onTap: () => _v2ItemTap(context, e), + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + /// 机器人真实在线态映射(不造假,与旧 RobotItemCard 口径一致): + /// • onlineStatus=0 → 离线;• 在线且 task 非「待机中/充电中」→ 作业中;• 其余在线 → 在线。 + DeviceV2Status _robotV2Status(RobotDataModel r) { + if (!r.isOnline) return DeviceV2Status.offline; + final t = r.task.trim(); + if (t.isEmpty || t == '待机中' || t == '充电中') { + return DeviceV2Status.online; + } + return DeviceV2Status.working; + } + + /// 卡片点击分发:与旧列表 onTap 逐行一致(机器人→hasHost 流程,机场→详情页) + void _v2ItemTap(BuildContext context, _V2DeviceItem item) { + final robot = item.robot; + if (robot != null) { + debugPrint( + '🔴🔴🔴 [V2-选中机器人] name: ${robot.name}, id: ${robot.id}, type: ${robot.type}', + ); + debugPrint('🔴🔴🔴 [V2-选中机器人] hasHost: ${robot.hasHost}'); + + // 🔥 hasHost 字段判定:false → 蓝牙连接流程 + if (!robot.hasHost) { + debugPrint('⚠️ [V2] hasHost=false,进入蓝牙连接流程'); + + // 从设备名中提取时间戳 + final timestamp = _extractTimestamp(robot.name); + if (timestamp == null || timestamp.isEmpty) { + debugPrint('❌ [V2] 未提取到时间戳'); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('设备名称异常,暂时无法连接'), + duration: Duration(seconds: 2), + ), + ); + return; + } + + debugPrint('✅ [V2] 提取到时间戳: $timestamp'); + _connectToDeviceViaBluetooth(context, robot.name, timestamp); + return; + } + + // hasHost == true → 走原来的逻辑 + debugPrint('✅ [V2] hasHost=true,走正常控制流程'); + + final device = DeviceEntity( + deviceName: robot.name, + productId: -1, + productName: robot.type, + tenantId: 0, + tenantName: '', + status: robot.isOnline ? 1 : 0, + onlineStatus: robot.isOnline ? 1 : 0, + ); + + final remoteCubit = GetIt.I(); + remoteCubit.setTargetDevice(device); + + final robotMap = { + 'name': robot.name, + 'id': robot.id, + 'alias': robot.alias, + 'type': robot.type, + 'status': robot.status, + 'battery': robot.battery, + 'task': robot.task, + }; + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => RobotControlPage(robot: robotMap), + ), + ); + return; + } + + final station = item.station; + if (station != null) { + // 预启动 OSD 订阅:与旧机场卡片 onTap 一致 + sl().startListening( + deviceSn: station.deviceSn, + gatewaySn: station.gatewaySn, + ); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => DroneStationDetailPage(station: station), + ), + ); + } + } + + // ===== V2 顶栏:logo + 铃铛/蓝色⊕(与无设备页同款) ===== + Widget _buildV2TopBar(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Image.asset( + 'assets/images/me_left_logo.png', + height: 44, + fit: BoxFit.contain, + ), + Row( + children: [ + _buildV2TopIconButton(Icons.notifications_none, onTap: _v2Todo), + const SizedBox(width: 10), + _buildV2TopIconButton( + Icons.add, + onTap: () => _showV2AddSheet(context), + primary: true, + ), + ], + ), + ], + ), + ); + } + + Widget _buildV2TopIconButton( + IconData icon, { + required VoidCallback onTap, + bool primary = false, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: primary ? null : Colors.white, + gradient: primary ? _v2BtnGradient : null, + borderRadius: BorderRadius.circular(14), + border: primary ? null : Border.all(color: _v2Border), + boxShadow: [ + BoxShadow( + color: _v2Blue.withValues(alpha: primary ? 0.3 : 0.05), + blurRadius: primary ? 10 : 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Icon(icon, size: 22, color: primary ? Colors.white : _v2Navy), + ), + ); + } + + void _v2Todo() { + CapsuleToast.show('功能暂未开发', showCheck: false); + } + + // ===== 切换场站入口:右上角按钮下方小字(场站名) ===== + Widget _buildV2SiteEntry(BuildContext context) { + final isPersonal = + context.watch().state.user?.roleKey == + TabConfig.personalRoleKey; + return BlocBuilder( + bloc: sl(), + builder: (context, siteState) { + final name = siteState.selectedSite?.siteName ?? '未选择场站'; + return Align( + alignment: Alignment.centerRight, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _onV2SiteTap(context, siteState, isPersonal), + child: Padding( + padding: const EdgeInsets.only(top: 6, right: 20, bottom: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + isPersonal ? '($name)· 个人账号' : '($name)', + style: const TextStyle(fontSize: 12, color: _v2Gray), + ), + if (!isPersonal) + const Icon( + Icons.keyboard_arrow_down, + size: 16, + color: _v2Gray, + ), + ], + ), + ), + ), + ); + }, + ); + } + + void _onV2SiteTap( + BuildContext context, + SiteState siteState, + bool isPersonal, + ) { + if (isPersonal) { + CapsuleToast.show('个人账号仅绑定一个场站', showCheck: false); + return; + } + if (siteState.sites.isEmpty) { + CapsuleToast.show('暂无场站数据', showCheck: false); + return; + } + final siteCubit = sl(); + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (sheetCtx) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.symmetric(vertical: 14), + child: Center( + child: Text( + '选择场站', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: _v2Navy, + ), + ), + ), + ), + const Divider(height: 1, color: Color(0xFFF0F4FA)), + Flexible( + child: ListView.builder( + shrinkWrap: true, + itemCount: siteState.sites.length, + itemBuilder: (_, i) { + final site = siteState.sites[i]; + final selected = + siteCubit.state.selectedSite?.id == site.id; + return ListTile( + title: Text( + site.siteName, + style: TextStyle( + fontSize: 15, + fontWeight: selected + ? FontWeight.w600 + : FontWeight.w400, + color: selected ? _v2Blue : _v2Navy, + ), + ), + trailing: selected + ? const Icon( + Icons.check, + size: 20, + color: _v2Blue, + ) + : null, + onTap: () { + // 选中后 _siteSub 监听会自动重拉全部数据 + siteCubit.selectSite(site); + Navigator.pop(sheetCtx); + }, + ); + }, + ), + ), + ], + ), + ); + }, + ); + } + + // ===== 标题区 ===== + Widget _buildV2Header() { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + // SizedBox 撑满宽:否则 Column 会收缩到文本宽并被外层 Column 居中, + // 导致「我的机器人」标题与底部副标题整体不左对齐。 + child: SizedBox( + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Text( + '我的机器人', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: _v2Navy, + ), + ), + SizedBox(height: 6), + Text( + '多机型统一管理 · 按设备 SN 动态加载功能', + style: TextStyle(fontSize: 13, color: _v2Gray), + ), + ], + ), + ), + ); + } + + // ===== 卡片视图 / 集中概览 切换(集中概览二级未开发) ===== + Widget _buildViewToggle() { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Container( + height: 44, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: _v2Border), + ), + child: Row( + children: [ + // 选中项:蓝渐变填充 + 白字,与全局主色按钮一致 + Expanded( + child: Container( + height: double.infinity, + decoration: BoxDecoration( + gradient: _v2BtnGradient, + borderRadius: BorderRadius.circular(11), + boxShadow: [ + BoxShadow( + color: _v2Blue.withValues(alpha: 0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.grid_view_rounded, + size: 16, + color: Colors.white, + ), + SizedBox(width: 6), + Text( + '卡片视图', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ], + ), + ), + ), + // 未选中项:透明底 + 灰字 + Expanded( + child: GestureDetector( + onTap: _v2Todo, + behavior: HitTestBehavior.opaque, + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.bar_chart_rounded, size: 16, color: _v2Gray), + SizedBox(width: 6), + Text( + '集中概览', + style: TextStyle(fontSize: 13, color: _v2Gray), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + // ===== 搜索框:纯展示。后端设备列表缺对应字段,暂不接搜索 ===== + Widget _buildV2SearchBox() { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: IgnorePointer( + child: Container( + height: 44, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: _v2Border), + ), + child: const Row( + children: [ + SizedBox(width: 14), + Icon(Icons.search, size: 20, color: Color(0xFFC9CDD4)), + SizedBox(width: 10), + Text( + '搜索设备名称、型号或 SN', + style: TextStyle(fontSize: 14, color: Color(0xFFB9C2CF)), + ), + ], + ), + ), + ), + ); + } + + // ===== 状态过滤药丸:真实生效 ===== + Widget _buildStatusPills( + int all, + int online, + int working, + int offline, + ) { + final pills = [ + ('all', '全部', all), + ('online', '在线', online), + ('working', '作业中', working), + ('offline', '离线', offline), + ]; + return Padding( + padding: const EdgeInsets.fromLTRB(0, 10, 0, 2), + child: SizedBox( + height: 30, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: pills.length, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (context, i) { + final (code, label, count) = pills[i]; + final selected = _statusFilter == code; + return GestureDetector( + onTap: () => setState(() => _statusFilter = code), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + alignment: Alignment.center, + decoration: BoxDecoration( + color: selected ? Colors.white : Colors.white.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: selected ? _v2Blue : _v2Border, + ), + ), + child: Text( + '$label ($count)', + style: TextStyle( + fontSize: 12, + fontWeight: selected ? FontWeight.w600 : FontWeight.w400, + color: selected ? _v2Blue : _v2Gray, + ), + ), + ), + ); + }, + ), + ), + ); + } + + // ===== 添加机器人底部弹窗:与无设备页同一套,各项保持原跳转 ===== + void _showV2AddSheet(BuildContext context) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (sheetCtx) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 8, 4), + child: Row( + children: [ + const Expanded( + child: Center( + child: Text( + '添加机器人', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: _v2Navy, + ), + ), + ), + ), + IconButton( + icon: const Icon( + Icons.close, + size: 20, + color: _v2Gray, + ), + onPressed: () => Navigator.pop(sheetCtx), + ), + ], + ), + ), + _v2SheetItem( + icon: Icons.qr_code_scanner, + label: '扫一扫', + onTap: () async { + Navigator.pop(sheetCtx); + final result = await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const QrScannerPage()), + ); + if (result != null && context.mounted) { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => BindDevicePage(scanResult: result), + ), + ); + // 绑定后重拉:统计 + 机器人/机场列表一起刷新 + if (context.mounted) { + context + .read() + .add(const DeviceListEvent.DeviceStatusRefresh()); + setState(() => _refreshTick++); + } + } + }, + ), + const Divider(height: 1, indent: 20, color: Color(0xFFF0F4FA)), + _v2SheetItem( + icon: Icons.add_circle_outline, + label: '添加设备', + onTap: () { + Navigator.pop(sheetCtx); + // 原菜单该项即为 TODO,保持不跳转 + _v2Todo(); + }, + ), + const Divider(height: 1, indent: 20, color: Color(0xFFF0F4FA)), + _v2SheetItem( + icon: Icons.videocam_outlined, + label: '本地视频', + onTap: () { + Navigator.pop(sheetCtx); + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const LocalCameraPage()), + ); + }, + ), + const Divider(height: 1, indent: 20, color: Color(0xFFF0F4FA)), + _v2SheetItem( + icon: Icons.usb, + label: 'USB 摄像头', + onTap: () { + Navigator.pop(sheetCtx); + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const UsbCameraPage()), + ); + }, + ), + const SizedBox(height: 8), + ], + ), + ); + }, + ); + } + + Widget _v2SheetItem({ + required IconData icon, + required String label, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 15), + child: Row( + children: [ + Icon(icon, size: 20, color: _v2Navy), + const SizedBox(width: 12), + Text( + label, + style: const TextStyle(fontSize: 15, color: _v2Navy), + ), + ], + ), + ), + ); + } + + Widget _buildAppBar(BuildContext context) { + // 🔥 个人角色(roleKey == 'personal')隐藏场站选择器 + final roleKey = context.watch().state.user?.roleKey; + final showSiteSelector = roleKey != TabConfig.personalRoleKey; + + return Container( + height: 44.0, + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Row( + children: [ + Expanded( + child: Row( + children: [ + if (showSiteSelector) ...[ + const Flexible(child: SiteSelectorWidget(compact: true)), + const SizedBox(width: 8), + ], + Container( + width: 1, + height: 20, + color: context.appColors.divider, + ), + const SizedBox(width: 8), + Text( + AppLocalizations.of( + context, + ).translate('device_list_v2.title'), + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: context.appColors.textTertiary, + ), + ), + ], + ), + ), + Row( + children: [ + // 🔥 注释掉原有的 TCP 指示灯 + // const Text( + // 'TCP', + // style: TextStyle(fontSize: 12, color: Color(0xFF86909C)), + // ), + // const SizedBox(width: 4), + // Container( + // padding: const EdgeInsets.all(4), + // decoration: BoxDecoration( + // shape: BoxShape.circle, + // color: Colors.blue.withOpacity(0.1), + // ), + // child: TcpStatusIndicator( + // size: 12, + // onTap: () => _showDeviceStatusModal(context), + // ), + // ), + GestureDetector( + onTap: () => _showAddMenu(context), + child: Container( + width: 30, + height: 30, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: context.appColors.divider, + width: 1, + ), + color: context.appColors.cardBackground, + ), + child: Icon( + Icons.add, + color: context.appColors.textSecondary, + size: 18, + ), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildSearchBar(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16.0, 8, 16.0, 8), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: AppLocalizations.of( + context, + ).translate('device_list_v2.search_hint'), + hintStyle: TextStyle( + color: context.appColors.textTertiary, + fontSize: 14, + ), + prefixIcon: Icon( + Icons.search, + color: context.appColors.textTertiary, + size: 24, + ), + suffixIcon: IconButton( + onPressed: () { + context.read().add( + DeviceListEvent.DeviceStatusSearch( + _searchController.text, + ), + ); + }, + icon: Icon( + Icons.search, + color: context.appColors.primary, + size: 22, + ), + ), + filled: true, + fillColor: context.appColors.fillBackground, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24.0), + borderSide: BorderSide( + color: context.appColors.divider, + width: 1, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(24.0), + borderSide: BorderSide( + color: context.appColors.divider, + width: 1, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(24.0), + borderSide: BorderSide( + color: context.appColors.primary, + width: 1, + ), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + style: TextStyle( + fontSize: 14, + color: context.appColors.textPrimary, + ), + onSubmitted: (value) { + context.read().add( + DeviceListEvent.DeviceStatusSearch(value), + ); + }, + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + builder: (context) => const BluetoothScanModal(), + ).whenComplete(() { + // 弹窗关闭时立即停止扫描(无论以什么方式关闭) + BleManager.instance.stopScan(); + }); + }, + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: context.appColors.primary.withOpacity(0.1), + ), + child: Icon( + Icons.bluetooth, + color: context.appColors.primary, + size: 22, + ), + ), + ), + ], + ), + ); + } + + Widget _buildTypeFilterBar(BuildContext context) { + // 🔥 个人角色(roleKey == 'personal')只显示「全部、机器人、无人机」3 个 tab + final roleKey = context.watch().state.user?.roleKey; + final isPersonal = roleKey == TabConfig.personalRoleKey; + + List typeCodes; + List typeLabels; + + if (isPersonal) { + typeCodes = ['all', 'robot', 'drone_station']; + typeLabels = [ + AppLocalizations.of(context).translate('device_list_v2.all'), + AppLocalizations.of(context).translate('device_list_v2.robot'), + AppLocalizations.of(context).translate('device_list_v2.drone_station'), + ]; + } else { + typeCodes = [ + 'all', + 'robot', + 'drone_station', + 'inverter', + 'combiner_box', + 'module', + 'monitor', + ]; + typeLabels = [ + AppLocalizations.of(context).translate('device_list_v2.all'), + AppLocalizations.of(context).translate('device_list_v2.robot'), + AppLocalizations.of(context).translate('device_list_v2.drone_station'), + AppLocalizations.of(context).translate('device_list_v2.inverter'), + AppLocalizations.of(context).translate('device_list_v2.combiner_box'), + AppLocalizations.of(context).translate('device_list_v2.module'), + AppLocalizations.of(context).translate('device_list_v2.monitor'), + ]; + } + + return BlocBuilder< + DeviceListBloc.DeviceStatusBloc, + DeviceListState.DeviceStatusState + >( + builder: (context, state) { + String selectedType = 'all'; + if (state is DeviceListState.DeviceStatusLoaded) { + selectedType = state.selectedType; + } + + // 获取当前选中的索引 + int selectedIndex = typeCodes.indexOf(selectedType); + if (selectedIndex == -1) selectedIndex = 0; // 默认为 'all' + + return SizedBox( + height: 40, + child: NotificationListener( + onNotification: (ScrollNotification notification) { + // 🔥 监听滑动结束事件,自动切换选中状态 + if (notification is ScrollEndNotification && + notification.metrics.axis == Axis.horizontal) { + final position = notification.metrics.pixels; + final itemWidth = 56.0; // 预估每个 tab 的宽度(包括间距) + final newIndex = (position / itemWidth).round(); + + if (newIndex >= 0 && + newIndex < typeCodes.length && + newIndex != selectedIndex) { + final newTypeCode = typeCodes[newIndex]; + context.read().add( + DeviceListEvent.DeviceStatusChangeType(newTypeCode), + ); + } + } + return false; + }, + child: ListView.separated( + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 16.0), + itemCount: typeCodes.length, + separatorBuilder: (context, index) => const SizedBox(width: 24.0), + itemBuilder: (context, index) { + final typeCode = typeCodes[index]; + final typeLabel = typeLabels[index]; + final isSelected = selectedType == typeCode; + + return GestureDetector( + onTap: () { + context.read().add( + DeviceListEvent.DeviceStatusChangeType(typeCode), + ); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + typeLabel, + style: TextStyle( + fontSize: 15, + color: isSelected + ? context.appColors.primary + : context.appColors.textSecondary, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + const SizedBox(height: 4), + Container( + width: 20, + height: 2, + decoration: BoxDecoration( + color: isSelected + ? context.appColors.primary + : Colors.transparent, + borderRadius: BorderRadius.circular(1), + ), + ), + ], + ), + ); + }, + ), + ), + ); + }, + ); + } + + Widget _buildContent( + BuildContext context, + DeviceListState.DeviceStatusState state, + ) { + // 🔥 用当前 siteId 作为 Key,电站切换时强制重建子树(重建对应 BlocProvider) + final siteId = _currentSiteId; + + if (state is DeviceListState.DeviceStatusLoaded && + state.selectedType == 'robot') { + return RobotListPage(key: ValueKey('robot_$siteId')); + } + + if (state is DeviceListState.DeviceStatusLoaded && + state.selectedType == 'drone_station') { + return _buildDroneStationList( + context, + key: ValueKey('drone_station_$siteId'), + ); + } + + return _buildDeviceList(context, state); + } + + Widget _buildDeviceList( + BuildContext context, + DeviceListState.DeviceStatusState state, + ) { + if (state is DeviceListState.DeviceStatusLoading) { + return Center( + child: CircularProgressIndicator(color: context.appColors.primary), + ); + } + + // 🔥 错误状态不再显示全屏错误页面,而是通过 listener 显示 SnackBar + // 页面保持当前内容,用户可以继续操作 + if (state is DeviceListState.DeviceStatusError) { + // 如果是从 Loaded 状态变成 Error,返回空容器保持页面 + return const SizedBox.shrink(); + } + + if (state is DeviceListState.DeviceStatusLoaded) { + List filteredByType = state.devices; + if (state.selectedType != 'all') { + filteredByType = state.devices.where((device) { + return _matchTypeTab(device.type, state.selectedType); + }).toList(); + } else { + // 🔥 "全部"模式下不渲染通用设备段: + // 机器人段(RobotListBloc → /iot/device/getSiteList)零过滤,已渲染该接口全量设备, + // 通用段数据源是同一个接口,恒为机器人段的子集 → 必然重复渲染。 + // 原先靠 _isRobotDevice 字符串猜测来排除,后端 deviceTypeName 缺失时判据即失效。 + // 类型 tab(逆变器/汇流箱/组件/监控)走上面的 if 分支,不受影响。 + filteredByType = const []; + } + + // 🔥 再根据搜索关键词过滤 + final filteredDevices = filteredByType.where((device) { + if (state.searchKeyword.isEmpty) return true; + return device.name.toLowerCase().contains( + state.searchKeyword.toLowerCase(), + ) || + device.deviceId.toLowerCase().contains( + state.searchKeyword.toLowerCase(), + ); + }).toList(); + + return RefreshIndicator( + onRefresh: () async { + // 🔥 同步刷新:统计卡片(DeviceStatusBloc) + 下方机器人列表(RobotListBloc) + // + 无人机场列表(DroneStationBloc)。三者数据源相互独立,只刷统计会导致 + // 数字与实际卡片对不上,故用 _refreshTick 变更内嵌列表的 ValueKey 强制 + // 重建对应 BlocProvider,使其重新拉取数据。 + context.read().add( + const DeviceListEvent.DeviceStatusRefresh(), + ); + if (state.selectedType == 'all') { + setState(() => _refreshTick++); + } + }, + color: context.appColors.primary, + child: ListView( + padding: const EdgeInsets.only(bottom: 16), + children: [ + _buildStatusCard(context, state), + const SizedBox(height: 20.0), + // 🔥 机器人列表(直接复用 RobotListPage 的布局) + // 传入搜索关键词:通用段移除后,"全部"模式的搜索由机器人段承接 + if (state.selectedType == 'all') + _buildEmbeddedRobotList( + context, + searchKeyword: state.searchKeyword, + ), + // 🔥 无人机机场列表(直接复用 _buildDroneStationList 的布局) + // 传 key:电站切换时强制重建 BlocProvider,否则不会用新 siteId 重新加载 + if (state.selectedType == 'all') + _buildDroneStationList( + context, + embedded: true, + key: ValueKey('embedded_drone_station_${_currentSiteId}_$_refreshTick'), + ), + // 🔥 普通设备列表(仅类型 tab 生效,"全部"模式下 filteredByType 为空) + ...filteredDevices + .map( + (device) => DeviceItemWidget( + device: device, + onTap: () => _navigateToDeviceDetail(context, device), + ), + ) + .toList(), + ], + ), + ); + } + + return Center( + child: Text( + AppLocalizations.of(context).translate('device_list_v2.no_data'), + ), + ); + } + + /// 🔥 嵌入式机器人列表(直接复用 RobotListPage 的布局) + Widget _buildEmbeddedRobotList( + BuildContext context, { + String searchKeyword = '', + }) { + final selectedSite = sl().state.selectedSite; + final siteId = selectedSite?.id; + + debugPrint('🔍 [EmbeddedRobotList] 开始加载机器人数据, siteId: $siteId'); + + return BlocProvider( + // 🔥 电站切换时通过 key 变化强制重建 BlocProvider,重新用新 siteId 加载 + key: ValueKey('embedded_robot_${siteId}_$_refreshTick'), + create: (_) => + sl()..add(RobotListLoadData(siteId: siteId)), + child: BlocConsumer( + listener: (context, state) { + if (state is RobotListError && state.shouldShowError) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.message), + duration: const Duration(seconds: 2), + behavior: SnackBarBehavior.floating, + backgroundColor: context.appColors.warning, + ), + ); + } + }, + builder: (context, state) { + debugPrint('🔍 [EmbeddedRobotList] 当前状态: ${state.runtimeType}'); + + if (state is RobotListLoading) { + return Padding( + padding: const EdgeInsets.all(32), + child: Center( + child: CircularProgressIndicator( + color: context.appColors.primary, + ), + ), + ); + } + + if (state is RobotListLoaded) { + debugPrint( + '✅ [EmbeddedRobotList] 加载成功,机器人数量: ${state.robots.length}', + ); + + // 🔥 搜索联动:按 设备名 / deviceId / 别名 过滤 + final keyword = searchKeyword.trim().toLowerCase(); + final robots = keyword.isEmpty + ? state.robots + : state.robots.where((robot) { + return robot.name.toLowerCase().contains(keyword) || + robot.id.toLowerCase().contains(keyword) || + (robot.alias ?? '').toLowerCase().contains(keyword); + }).toList(); + + if (robots.isEmpty) { + return const SizedBox.shrink(); + } + + // 🔥 直接复用 RobotListPage 的布局结构(不显示标题) + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...robots.map((robot) { + debugPrint( + '📍 [EmbeddedRobotList] 机器人 - name: ${robot.name}, id: ${robot.id}', + ); + return RobotItemCard( + name: robot.name, + id: robot.id, + alias: robot.alias, + type: robot.type, + status: robot.status, + battery: robot.battery, + task: robot.task, + onTap: () async { + debugPrint( + '🔴🔴🔴 [全部-选中机器人] name: ${robot.name}, id: ${robot.id}, type: ${robot.type}', + ); + debugPrint( + '🔴🔴🔴 [全部-选中机器人] hasHost: ${robot.hasHost}', + ); + + // 🔥 hasHost 字段判定:false → 蓝牙连接流程 + if (!robot.hasHost) { + debugPrint('⚠️ [全部-EmbeddedRobotList] hasHost=false,进入蓝牙连接流程'); + + // 从设备名中提取时间戳(如 MC700PRO-CN-JS-1751525588120-0000000A-9527 → 1751525588120) + final timestamp = _extractTimestamp(robot.name); + if (timestamp == null || timestamp.isEmpty) { + debugPrint('❌ [全部-EmbeddedRobotList] 未提取到时间戳'); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('设备名称异常,暂时无法连接'), + duration: Duration(seconds: 2), + ), + ); + return; + } + + debugPrint('✅ [全部-EmbeddedRobotList] 提取到时间戳: $timestamp'); + + // 调用蓝牙连接流程 + _connectToDeviceViaBluetooth(context, robot.name, timestamp); + return; + } + + // hasHost == true → 走原来的逻辑 + debugPrint('✅ [全部-EmbeddedRobotList] hasHost=true,走正常控制流程'); + + // 1. 将当前机器人设置为全局待控制设备 + final device = DeviceEntity( + deviceName: robot.name, + productId: -1, + productName: robot.type, + tenantId: 0, + tenantName: '', + status: robot.isOnline ? 1 : 0, + onlineStatus: robot.isOnline ? 1 : 0, + ); + + final remoteCubit = GetIt.I(); + remoteCubit.setTargetDevice(device); + debugPrint( + '✅ [全部-EmbeddedRobotList] setTargetDevice 已调用', + ); + + // 2. 跳转到机器人控制页面 + final robotMap = { + 'name': robot.name, + 'id': robot.id, + 'alias': robot.alias, + 'type': robot.type, + 'status': robot.status, + 'battery': robot.battery, + 'task': robot.task, + }; + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + RobotControlPage(robot: robotMap), + ), + ); + }, + ); + }), + ], + ); + } + + if (state is RobotListError) { + debugPrint('❌ [EmbeddedRobotList] 加载失败: ${state.message}'); + } + + return const SizedBox.shrink(); + }, + ), + ); + } + + Widget _buildDroneStationList( + BuildContext context, { + bool embedded = false, + Key? key, + }) { + // 从全局 SiteCubit 获取选中的场站 ID + final selectedSite = sl().state.selectedSite; + + if (selectedSite == null) { + // 🔥 嵌入式分组不输出占位内容,避免"全部"列表中间夹一段提示 + if (embedded) return const SizedBox.shrink(); + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.location_off, + size: 48, + color: context.appColors.textTertiary, + ), + const SizedBox(height: 16), + Text( + AppLocalizations.of( + context, + ).translate('device_list_v2.select_site_first'), + style: TextStyle( + fontSize: 14, + color: context.appColors.textSecondary, + ), + ), + ], + ), + ); + } + + return BlocProvider( + key: key, + create: (_) => + sl()..add(DroneStationLoadData(selectedSite.id)), + child: BlocConsumer( + listener: (context, state) { + // 🔥 监听错误状态,显示友好提示 + if (state is DroneStationError && state.shouldShowError) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.message), + duration: const Duration(seconds: 2), + behavior: SnackBarBehavior.floating, + backgroundColor: context.appColors.warning, + ), + ); + } + }, + builder: (context, state) { + if (state is DroneStationLoading) { + return Center( + child: CircularProgressIndicator( + color: context.appColors.primary, + ), + ); + } + + // 🔥 错误状态不再显示全屏错误页面,而是通过 listener 显示 SnackBar + if (state is DroneStationError) { + // 返回空容器保持页面 + return const SizedBox.shrink(); + } + + if (state is DroneStationLoaded) { + final stations = state.stations; + + if (stations.isEmpty) { + // 🔥 "全部"标签下的嵌入式分组为空时不输出文案, + // 避免列表中间夹一行"该场站暂无无人机机场" + if (embedded) return const SizedBox.shrink(); + return Center( + child: Text( + AppLocalizations.of( + context, + ).translate('device_list_v2.no_drone_stations'), + style: TextStyle( + fontSize: 14, + color: context.appColors.textSecondary, + ), + ), + ); + } + + return RefreshIndicator( + onRefresh: () async { + context.read().add( + DroneStationRefresh(selectedSite.id), + ); + }, + color: context.appColors.primary, + child: ListView.builder( + shrinkWrap: embedded, + physics: embedded + ? const NeverScrollableScrollPhysics() + : const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.only(top: 16, bottom: 16), + itemCount: stations.length, + itemBuilder: (context, index) { + final station = stations[index]; + return DroneStationItemCard( + station: station, + onTap: () { + // 预启动 OSD 订阅:进入详情页前就让 MQTT 数据流跑起来 + // 这样详情页一进来就能从 stream 读到数据,无需等待首帧推送 + sl().startListening( + deviceSn: station.deviceSn, + gatewaySn: station.gatewaySn, + ); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + DroneStationDetailPage(station: station), + ), + ); + }, + ); + }, + ), + ); + } + + return Center( + child: Text( + AppLocalizations.of(context).translate('device_list_v2.no_data'), + ), + ); + }, + ), + ); + } + + Widget _buildStatusCard( + BuildContext context, + DeviceListState.DeviceStatusLoaded state, + ) { + // 从实际设备列表计算统计数据(设备状态仅有"在线/离线"两种,接口不会返回"异常", + // 故不再统计恒为 0 的"告警"项) + final devices = state.devices; + final totalCount = devices.length; + final onlineCount = devices.where((d) => d.status == '在线').length; + final offlineCount = totalCount - onlineCount; + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16.0), + padding: const EdgeInsets.all(16.0), + decoration: BoxDecoration( + color: context.appColors.cardBackground, + borderRadius: BorderRadius.circular(16.0), + boxShadow: [ + BoxShadow( + color: context.appColors.cardShadow, + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + _buildStatItem( + value: totalCount.toString(), + label: AppLocalizations.of( + context, + ).translate('device_list_v2.total_devices'), + dotColor: context.appColors.textTertiary, + textColor: context.appColors.textTertiary, + ), + Container(width: 1, height: 40, color: context.appColors.divider), + _buildStatItem( + value: onlineCount.toString(), + label: AppLocalizations.of( + context, + ).translate('device_list_v2.online'), + dotColor: context.appColors.success, + textColor: context.appColors.success, + ), + Container(width: 1, height: 40, color: context.appColors.divider), + _buildStatItem( + value: offlineCount.toString(), + label: AppLocalizations.of( + context, + ).translate('device_list_v2.offline'), + dotColor: context.appColors.danger, + textColor: context.appColors.danger, + ), + ], + ), + ); + } + + Widget _buildStatItem({ + required String value, + required String label, + required Color dotColor, + required Color? textColor, // 新增字体颜色 + }) { + return Expanded( + child: Column( + children: [ + Text( + value, + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: textColor ?? context.appColors.textPrimary, + ), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: dotColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 4), + Flexible( + child: Text( + label, + style: TextStyle( + fontSize: 12, + color: textColor ?? context.appColors.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + ), + ], + ), + ], + ), + ); + } + + // 显示设备状态模态框 - 从底部滑出 + /// 显示添加菜单(扫一扫、添加设备) + void _showAddMenu(BuildContext context) { + showDialog( + context: context, + barrierColor: Colors.black38, + builder: (ctx) => Stack( + children: [ + // 点击遮罩关闭 + GestureDetector( + onTap: () => Navigator.pop(ctx), + child: Container(color: Colors.transparent), + ), + Positioned( + top: MediaQuery.of(context).padding.top + 50, + right: 16, + child: Material( + borderRadius: BorderRadius.circular(12), + color: ctx.appColors.cardBackground, + elevation: 8, + shadowColor: ctx.appColors.cardShadow, + child: SizedBox( + width: 150, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildMenuOption( + ctx, + icon: Icons.qr_code_scanner, + label: '扫一扫', + onTap: () async { + Navigator.pop(ctx); + final result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const QrScannerPage(), + ), + ); + if (result != null && mounted) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + BindDevicePage(scanResult: result), + ), + ); + } + }, + ), + Divider(height: 1, color: ctx.appColors.divider), + _buildMenuOption( + ctx, + icon: Icons.add_circle_outline, + label: '添加设备', + onTap: () { + Navigator.pop(ctx); + // TODO: 添加设备功能 + }, + ), + Divider(height: 1, color: ctx.appColors.divider), + _buildMenuOption( + ctx, + icon: Icons.videocam_outlined, + label: '本地视频', + onTap: () { + Navigator.pop(ctx); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const LocalCameraPage(), + ), + ); + }, + ), + Divider(height: 1, color: ctx.appColors.divider), + _buildMenuOption( + ctx, + icon: Icons.usb, + label: 'USB 摄像头', + onTap: () { + Navigator.pop(ctx); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const UsbCameraPage(), + ), + ); + }, + ), + ], + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildMenuOption( + BuildContext context, { + required IconData icon, + required String label, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Icon(icon, color: context.appColors.textSecondary, size: 20), + const SizedBox(width: 12), + Text( + label, + style: TextStyle( + fontSize: 15, + color: context.appColors.textPrimary, + ), + ), + ], + ), + ), + ); + } + + void _showDeviceStatusModal(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + builder: (BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: context.read()), + BlocProvider.value(value: sl()), + ], + child: const DeviceStatusModal(), + ); + }, + ); + } + + /// 🔥 根据设备类型导航到不同的详情页 + void _navigateToDeviceDetail(BuildContext context, dynamic device) { + final deviceName = device.name ?? ''; + final deviceId = device.deviceId ?? ''; + final deviceType = device.type ?? ''; // 🔥 使用接口返回的设备类型字段 + + debugPrint('🔍 [DeviceStatusPage] 设备名称: $deviceName'); + debugPrint('🔍 [DeviceStatusPage] 设备ID: $deviceId'); + debugPrint('🔍 [DeviceStatusPage] 设备类型: $deviceType'); + + // 🔥 判断是否为机器人:用通用匹配(型号前缀 + 中文关键字) + bool isRobot = _isRobotDevice(deviceType, deviceName, deviceId); + + // 🔥 判断是否为无人机机场(根据设备类型包含关键字) + final droneStationKeywords = [ + 'DJI Dock3', + 'DOCK3', + 'DOCK-2', + '采集车', + '机场', + '无人机机场', + ]; + bool isDroneStation = droneStationKeywords.any( + (keyword) => deviceType.contains(keyword), + ); + + // 🔥 兜底逻辑:如果 type 字段是"未知设备"或空,则使用设备名称进行二次判断 + if (!isRobot && + !isDroneStation && + (deviceType.isEmpty || deviceType == '未知设备' || deviceType == '未知类型')) { + debugPrint('⚠️ [DeviceStatusPage] type 字段无效,使用设备名称进行二次判断'); + + // 根据通用匹配重新判断(已统一用 lowercase) + isRobot = _isRobotDevice(deviceType, deviceName, deviceId); + + // 检查每个无人机关键字是否匹配 + for (final keyword in droneStationKeywords) { + if (deviceName.contains(keyword)) { + debugPrint('🔍 [DeviceStatusPage] 无人机关键字匹配: "$keyword"'); + } + } + + // 根据设备名称包含关键字判断无人机机场 + isDroneStation = droneStationKeywords.any( + (keyword) => deviceName.contains(keyword), + ); + + // 🔥 重要:如果同时匹配机器人和无人机机场,优先判断为无人机机场 + if (isRobot && isDroneStation) { + debugPrint('⚠️ [DeviceStatusPage] 同时匹配机器人和无人机机场,优先判断为无人机机场'); + isRobot = false; + isDroneStation = true; + } + + debugPrint('🔍 [DeviceStatusPage] 二次判断 - 是否机器人: $isRobot'); + debugPrint('🔍 [DeviceStatusPage] 二次判断 - 是否无人机机场: $isDroneStation'); + } + + debugPrint('🔍 [DeviceStatusPage] 最终判断 - 是否机器人: $isRobot'); + debugPrint('🔍 [DeviceStatusPage] 最终判断 - 是否无人机机场: $isDroneStation'); + + if (isRobot) { + // 跳转到机器人控制页面 + debugPrint('🚀 [DeviceStatusPage] 跳转到机器人控制页面'); + final deviceAlias = device.deviceAlias ?? ''; + final robotMap = { + 'name': deviceName, + 'id': deviceId, + 'alias': deviceAlias, + 'type': deviceType, + 'status': device.status ?? '在线', + 'battery': 100.0, // DeviceEntity 没有 battery 字段,使用默认值 + 'task': '待机中', // DeviceEntity 没有 task 字段,使用默认值 + }; + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => RobotControlPage(robot: robotMap), + ), + ); + } else if (isDroneStation) { + // 跳转到无人机机场详情页面 + debugPrint('🚀 [DeviceStatusPage] 跳转到无人机机场详情页面'); + + // 🔥 从 DroneStationBloc 中获取已加载的机场列表 + final droneStationBloc = sl(); + DroneStationEntity? matchedStation; + + if (droneStationBloc.state is DroneStationLoaded) { + final loadedState = droneStationBloc.state as DroneStationLoaded; + // 根据 deviceId 或 deviceName 匹配 + for (final station in loadedState.stations) { + if (station.gatewaySn == deviceId || station.callsign == deviceName) { + matchedStation = station; + debugPrint( + '✅ 匹配到机场 - gatewaySn: ${station.gatewaySn}, deviceSn: ${station.deviceSn}', + ); + break; + } + } + } + + if (matchedStation != null) { + // 预启动 OSD 订阅:进入详情页前就让 MQTT 数据流跑起来 + sl().startListening( + deviceSn: matchedStation.deviceSn, + gatewaySn: matchedStation.gatewaySn, + ); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + DroneStationDetailPage(station: matchedStation!), + ), + ); + } else { + debugPrint('⚠️ 未找到匹配的机场,请从无人机机场标签页进入'); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('请从"无人机机场"标签页查看详情'))); + } + } else { + // 其他设备类型,显示提示 + debugPrint('⚠️ [DeviceStatusPage] 未知设备类型: $deviceType'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('该设备类型暂不支持查看详情: $deviceType'), + duration: const Duration(seconds: 2), + ), + ); + } + } + + /// 🔥 从设备名中提取时间戳 + String? _extractTimestamp(String deviceName) { + final regex = RegExp(r'(\d{13})'); + final match = regex.firstMatch(deviceName); + return match?.group(1); + } + + /// 🔥 蓝牙连接流程 + Future _connectToDeviceViaBluetooth( + BuildContext context, + String deviceName, + String timestamp, + ) async { + final bleManager = BleManager.instance; + + // 🔥 0. 优先检查:如果该设备已经连接,直接跳转详情页,不再扫描 + final connectedDevice = bleManager.connectedDevice; + if (connectedDevice != null) { + final connectedName = bleManager.connectedDeviceName ?? ''; + debugPrint('🔍 [蓝牙连接] 已连接设备名: $connectedName,匹配时间戳: $timestamp'); + if (connectedName.contains(timestamp)) { + debugPrint('✅ [蓝牙连接] 设备已连接,直接跳转详情页: $connectedName'); + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => BleDeviceDetailPage( + device: connectedDevice, + displayName: deviceName, + ), + ), + ); + return; + } + } + + // 1. 检查蓝牙状态 + final isBluetoothOn = await bleManager.checkBluetooth(); + if (!isBluetoothOn) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('请先打开手机蓝牙'), + duration: Duration(seconds: 2), + ), + ); + return; + } + + // 2. 显示加载圈 + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('正在搜索蓝牙设备...', style: TextStyle(color: Colors.white)), + ], + ), + ), + ); + + // 3. 启动蓝牙扫描 + await bleManager.startScan(continuous: false); + await Future.delayed(const Duration(milliseconds: 1500)); + + // 4. 关闭加载圈 + Navigator.of(context).pop(); + + // 5. 获取扫描结果并查找匹配设备 + final scanResults = bleManager.getScanResults(); + BluetoothDevice? matchedDevice; + for (final result in scanResults) { + final devName = result.device.platformName.isNotEmpty + ? result.device.platformName + : result.device.advName; + if (devName.contains(timestamp)) { + matchedDevice = result.device; + debugPrint('✅ [蓝牙连接] 找到匹配设备: $devName'); + break; + } + } + + if (matchedDevice != null) { + debugPrint('🚀 [蓝牙连接] 跳转到 BLE 详情页并连接'); + final device = matchedDevice; + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => BleDeviceDetailPage( + device: device, + displayName: deviceName, + ), + ), + ); + } else { + debugPrint('⚠️ [蓝牙连接] 未在扫描列表中找到设备,弹窗提示并后台监测'); + _showNotFoundDialogAndMonitor(context, deviceName, timestamp); + } + } + + /// 🔥 弹窗提示并后台持续监测设备(单例模式,防止重复弹窗) + void _showNotFoundDialogAndMonitor( + BuildContext context, + String deviceName, + String timestamp, + ) { + // 🔥 检查是否已有弹窗存在,如果有则先关闭 + if (Navigator.of(context).canPop()) { + debugPrint('⚠️ [后台监测] 检测到已有弹窗,先关闭上一个'); + Navigator.of(context).pop(); + } + + bool dialogShown = true; + StreamSubscription? subscription; + + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + dialogShown = true; + return AlertDialog( + title: Text(AppLocalizations.of(dialogContext).translate('device_list_v2.device_not_found_title')), + content: Text(AppLocalizations.of(dialogContext).translate('device_list_v2.device_not_found_content')), + actions: [ + TextButton( + onPressed: () { + dialogShown = false; + Navigator.pop(dialogContext); + }, + child: Text(AppLocalizations.of(dialogContext).translate('common.cancel')), + ), + TextButton( + onPressed: () { + dialogShown = false; + Navigator.pop(dialogContext); + }, + child: Text(AppLocalizations.of(dialogContext).translate('common.confirm')), + ), + ], + ); + }, + ); + + // 启动后台监测 + final bleManager = BleManager.instance; + subscription = bleManager.scanResults.listen((results) { + if (!dialogShown) { + subscription?.cancel(); + return; + } + + for (final result in results) { + final devName = result.device.platformName.isEmpty + ? result.device.advName + : result.device.platformName; + + if (devName.contains(timestamp)) { + debugPrint('✅ [后台监测] 匹配成功!设备名: $devName'); + subscription?.cancel(); + + // 🔥 弹窗可能已关闭,需要先 pop 当前弹窗再跳转 + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => BleDeviceDetailPage( + device: result.device, + displayName: deviceName, + ), + ), + ); + break; + } + } + }); + } +} + +/// V2 卡片列表的统一展示项:机器人 / 无人机机场二选一 +class _V2DeviceItem { + const _V2DeviceItem({ + required this.name, + required this.modelText, + required this.snKey, + required this.status, + this.battery, + this.robot, + this.station, + }) : assert(robot != null || station != null); + + final String name; + final String modelText; + final String snKey; + final DeviceV2Status status; + final int? battery; + final RobotDataModel? robot; + final DroneStationEntity? station; +} diff --git a/lib/features/v2/device_list/presentation/pages/no_device_page_v2.dart b/lib/features/v2/device_list/presentation/pages/no_device_page_v2.dart new file mode 100644 index 00000000..994a062f --- /dev/null +++ b/lib/features/v2/device_list/presentation/pages/no_device_page_v2.dart @@ -0,0 +1,575 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:get_it/get_it.dart'; +import 'package:maibu_satabot_v2/components/capsule_toast.dart'; +import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart'; +import 'package:maibu_satabot_v2/features/main_container/domain/tab_config.dart'; +import 'package:maibu_satabot_v2/features/v2/local_camera/presentation/pages/local_camera_page.dart'; +import 'package:maibu_satabot_v2/features/v2/site/presentation/cubit/site_cubit.dart'; +import 'package:maibu_satabot_v2/features/v2/usb_camera/presentation/pages/usb_camera_page.dart'; +import '../bloc/device_status_bloc.dart'; +import '../bloc/device_status_event.dart'; +import 'bind_device_page.dart'; +import 'qr_scanner_page.dart'; + +/// 无设备首页 —— 按「01_无设备首页」设计稿实现。 +/// +/// 触发条件(逻辑链不变):SiteCubit 取所有场站 → 第一个场站拉设备列表 → +/// DeviceStatusLoaded 且 devices 为空时,由 DeviceStatusView 整页切换到本页。 +/// +/// 说明: +/// - 顶栏按设计稿换新:SATA logo + 铃铛/⊕ 白底按钮; +/// - 右上角按钮下方小字「(场站名)」为切换场站入口,个人角色追加「个人账号」说明; +/// - 「添加机器人」与卡片蓝色 ⊕ 均弹出底部添加弹窗,内部各项保持原跳转; +/// - 「新手入门」三条二级内容未开发,统一 Toast 提示; +/// - 背景用设计稿地图背景切图 shebei_bg.png,车图为透明底 shebei-logo.png, +/// 车与背景分层不粘连。 +class NoDevicePageV2 extends StatelessWidget { + const NoDevicePageV2({super.key}); + + // ===== 固定视觉配色(与登录页/我的页 V2 同系) ===== + static const _bgTop = Color(0xFFFAFCFF); + static const _navy = Color(0xFF1A2B4A); + static const _grayText = Color(0xFF86909C); + static const _primaryBlue = Color(0xFF1C96F6); + static const _cardBorder = Color(0xFFE9F1FA); + static const _btnGradient = LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [Color(0xFF2BAFFE), Color(0xFF1C96F6), Color(0xFF0B7DEE)], + stops: [0.0, 0.5, 1.0], + ); + + static void _todoTap() { + CapsuleToast.show('功能暂未开发', showCheck: false); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: _bgTop, + body: Stack( + fit: StackFit.expand, + children: [ + // 背景:设计稿地图背景切图,顶对齐裁切(替代此前手绘三层渐变) + Positioned.fill( + child: Image.asset( + 'assets/images/shebei_bg.png', + fit: BoxFit.cover, + alignment: Alignment.topCenter, + ), + ), + SafeArea( + bottom: false, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + _buildTopBar(context), + _buildSiteEntry(context), + const SizedBox(height: 18), + _buildHeroCard(context), + const SizedBox(height: 26), + const Center( + child: Text( + '还没有添加机器人', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: _navy, + ), + ), + ), + const SizedBox(height: 10), + const Center( + child: Text( + '您可以先了解 APP 的功能,\n收到机器人后随时绑定使用。', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + color: _grayText, + height: 1.6, + ), + ), + ), + const SizedBox(height: 20), + _buildAddButton(context), + const SizedBox(height: 30), + const Text( + '新手入门', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: _navy, + ), + ), + const SizedBox(height: 12), + _buildGuideCard( + icon: Icons.list_alt, + title: '产品操作指南', + subtitle: '了解机器人基础功能和使用流程', + ), + const SizedBox(height: 10), + _buildGuideCard( + icon: Icons.videocam_outlined, + title: '观看产品视频', + subtitle: '快速上手遥控、图传及作业功能', + ), + const SizedBox(height: 10), + _buildGuideCard( + icon: Icons.help_outline, + title: '帮助与售后服务', + subtitle: '常见问题、报修及客服支持', + ), + const SizedBox(height: 24), + ], + ), + ), + ), + ], + ), + ); + } + + // ===== 顶栏:logo + 铃铛/⊕(设计稿新样式) ===== + Widget _buildTopBar(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Image.asset( + 'assets/images/me_left_logo.png', + height: 44, + fit: BoxFit.contain, + ), + Row( + children: [ + _buildTopIconButton(Icons.notifications_none, onTap: _todoTap), + const SizedBox(width: 10), + // 设计稿:右上角 ⊕ 是蓝底白号主色按钮,与铃铛的白底按钮区分 + _buildTopIconButton( + Icons.add, + onTap: () => _showAddSheet(context), + primary: true, + ), + ], + ), + ], + ); + } + + Widget _buildTopIconButton( + IconData icon, { + required VoidCallback onTap, + bool primary = false, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: primary ? null : Colors.white, + gradient: primary ? _btnGradient : null, + borderRadius: BorderRadius.circular(14), + border: primary ? null : Border.all(color: _cardBorder), + boxShadow: [ + BoxShadow( + color: primary + ? _primaryBlue.withValues(alpha: 0.3) + : _primaryBlue.withValues(alpha: 0.05), + blurRadius: primary ? 10 : 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Icon( + icon, + size: 22, + color: primary ? Colors.white : _navy, + ), + ), + ); + } + + // ===== 切换场站入口:右上角按钮下方小字(场站名) ===== + Widget _buildSiteEntry(BuildContext context) { + final isPersonal = + context.watch().state.user?.roleKey == + TabConfig.personalRoleKey; + return BlocBuilder( + bloc: GetIt.I(), + builder: (context, siteState) { + final name = siteState.selectedSite?.siteName ?? '未选择场站'; + return Align( + alignment: Alignment.centerRight, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _onSiteTap(context, siteState, isPersonal), + child: Padding( + padding: const EdgeInsets.only(top: 6, right: 4, bottom: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + isPersonal ? '($name)· 个人账号' : '($name)', + style: const TextStyle(fontSize: 12, color: _grayText), + ), + if (!isPersonal) + const Icon( + Icons.keyboard_arrow_down, + size: 16, + color: _grayText, + ), + ], + ), + ), + ), + ); + }, + ); + } + + void _onSiteTap(BuildContext context, SiteState siteState, bool isPersonal) { + if (isPersonal) { + CapsuleToast.show('个人账号仅绑定一个场站', showCheck: false); + return; + } + if (siteState.sites.isEmpty) { + CapsuleToast.show('暂无场站数据', showCheck: false); + return; + } + final siteCubit = GetIt.I(); + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (sheetCtx) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.symmetric(vertical: 14), + child: Center( + child: Text( + '选择场站', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: _navy, + ), + ), + ), + ), + const Divider(height: 1, color: Color(0xFFF0F4FA)), + Flexible( + child: ListView.builder( + shrinkWrap: true, + itemCount: siteState.sites.length, + itemBuilder: (_, i) { + final site = siteState.sites[i]; + final selected = + siteCubit.state.selectedSite?.id == site.id; + return ListTile( + title: Text( + site.siteName, + style: TextStyle( + fontSize: 15, + fontWeight: selected + ? FontWeight.w600 + : FontWeight.w400, + color: selected ? _primaryBlue : _navy, + ), + ), + trailing: selected + ? const Icon( + Icons.check, + size: 20, + color: _primaryBlue, + ) + : null, + onTap: () { + // 选中后 SiteCubit 流会触发设备列表重拉; + // 若新场站有设备,本页会自动退回正常列表。 + siteCubit.selectSite(site); + Navigator.pop(sheetCtx); + }, + ); + }, + ), + ), + ], + ), + ); + }, + ); + } + + // ===== Hero 卡:透明底车图 + 右上角蓝色 ⊕ ===== + Widget _buildHeroCard(BuildContext context) { + return Container( + height: 230, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.72), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: _cardBorder), + boxShadow: [ + BoxShadow( + color: _primaryBlue.withValues(alpha: 0.06), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Stack( + children: [ + Positioned.fill( + child: Padding( + padding: const EdgeInsets.all(20), + child: Image.asset( + 'assets/images/shebei-logo.png', + fit: BoxFit.contain, + ), + ), + ), + Positioned( + top: 14, + right: 14, + child: GestureDetector( + onTap: () => _showAddSheet(context), + child: Container( + width: 40, + height: 40, + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: _btnGradient, + ), + child: const Icon(Icons.add, color: Colors.white, size: 22), + ), + ), + ), + ], + ), + ); + } + + // ===== 添加机器人主按钮 ===== + Widget _buildAddButton(BuildContext context) { + return GestureDetector( + onTap: () => _showAddSheet(context), + child: Container( + height: 48, + decoration: BoxDecoration( + gradient: _btnGradient, + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + color: _primaryBlue.withValues(alpha: 0.3), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.add, color: Colors.white, size: 20), + SizedBox(width: 8), + Text( + '添加机器人', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ], + ), + ), + ); + } + + // ===== 添加机器人底部弹窗:各项保持原跳转 ===== + void _showAddSheet(BuildContext context) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (sheetCtx) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 8, 4), + child: Row( + children: [ + const Expanded( + child: Center( + child: Text( + '添加机器人', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: _navy, + ), + ), + ), + ), + IconButton( + icon: const Icon( + Icons.close, + size: 20, + color: _grayText, + ), + onPressed: () => Navigator.pop(sheetCtx), + ), + ], + ), + ), + _sheetItem( + icon: Icons.qr_code_scanner, + label: '扫一扫', + onTap: () async { + Navigator.pop(sheetCtx); + final result = await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const QrScannerPage()), + ); + if (result != null && context.mounted) { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => BindDevicePage(scanResult: result), + ), + ); + // 绑定流程结束后重拉设备列表,有设备则自动退回正常列表页 + if (context.mounted) { + context.read().add( + const DeviceStatusRefresh(), + ); + } + } + }, + ), + _sheetDivider(), + _sheetItem( + icon: Icons.add_circle_outline, + label: '添加设备', + onTap: () { + Navigator.pop(sheetCtx); + // 原菜单该项即为 TODO,保持不跳转 + _todoTap(); + }, + ), + _sheetDivider(), + _sheetItem( + icon: Icons.videocam_outlined, + label: '本地视频', + onTap: () { + Navigator.pop(sheetCtx); + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const LocalCameraPage()), + ); + }, + ), + _sheetDivider(), + _sheetItem( + icon: Icons.usb, + label: 'USB 摄像头', + onTap: () { + Navigator.pop(sheetCtx); + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const UsbCameraPage()), + ); + }, + ), + const SizedBox(height: 8), + ], + ), + ); + }, + ); + } + + Widget _sheetItem({ + required IconData icon, + required String label, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 15), + child: Row( + children: [ + Icon(icon, size: 20, color: _navy), + const SizedBox(width: 12), + Text( + label, + style: const TextStyle(fontSize: 15, color: _navy), + ), + ], + ), + ), + ); + } + + Widget _sheetDivider() { + return const Divider(height: 1, indent: 20, color: Color(0xFFF0F4FA)); + } + + // ===== 新手入门卡片:二级未开发,统一提示 ===== + Widget _buildGuideCard({ + required IconData icon, + required String title, + required String subtitle, + }) { + return GestureDetector( + onTap: _todoTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: _cardBorder), + ), + child: Row( + children: [ + Icon(icon, size: 24, color: _primaryBlue), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: _navy, + ), + ), + const SizedBox(height: 3), + Text( + subtitle, + style: const TextStyle(fontSize: 12, color: _grayText), + ), + ], + ), + ), + const Icon( + Icons.chevron_right, + size: 20, + color: Color(0xFFC9CDD4), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/v2/device_list/presentation/widgets/device_card_v2.dart b/lib/features/v2/device_list/presentation/widgets/device_card_v2.dart new file mode 100644 index 00000000..0e1b55ea --- /dev/null +++ b/lib/features/v2/device_list/presentation/widgets/device_card_v2.dart @@ -0,0 +1,344 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:maibu_satabot_v2/components/capsule_toast.dart'; + +/// 设备状态 V2 —— 卡片视图设计稿组件。 +/// +/// 纯展示组件:点击回调、在线状态、电量均由页面传入真实数据 +/// (在线态来自 onlineStatus,电量来自 battery / capacityPercent,与旧卡片一致)。 +/// 仅版本 / SN 掩码等后端暂未返回的字段用设备 ID 做**稳定伪随机**占位 +/// (同一设备每次渲染结果一致,不会闪变),等后端补齐字段后替换为真值即可。 + +/// 设备展示状态(对应设计稿徽章:作业中 / 在线 / 离线) +enum DeviceV2Status { working, online, offline } + +/// 占位数据生成器:以设备唯一串为种子,保证同设备结果稳定 +class DeviceV2Display { + DeviceV2Display._(); + + static const _editions = ['PLUS 智能版', 'STD 标准版', 'PRO 旗舰版']; + + static int _seed(String key) => key.hashCode & 0x7FFFFFFF; + + /// 版本后缀:PLUS 智能版 / STD 标准版 / PRO 旗舰版 + static String editionOf(String key) => _editions[_seed(key) % _editions.length]; + + /// SN 掩码展示:前4 · · · 后2 + static String maskedSn(String key) { + if (key.length <= 6) return key.toUpperCase(); + return '${key.substring(0, 4)} · · · ${key.substring(key.length - 2)}' + .toUpperCase(); + } + + /// 底部作业态文案 + static String workTextOf(DeviceV2Status status) { + return switch (status) { + DeviceV2Status.working => '自主作业中', + DeviceV2Status.online => '待机', + DeviceV2Status.offline => '暂时无法连接', + }; + } +} + +/// 设计稿设备卡片:缩略图 + 名称/徽章 + 型号版本 + SN + 底部作业态/电量 +class DeviceCardV2 extends StatelessWidget { + const DeviceCardV2({ + super.key, + required this.name, + required this.modelText, + required this.snKey, + required this.status, + required this.onTap, + this.batteryLevel, + }); + + /// 设备名称(别名优先,页面已处理) + final String name; + + /// 型号 · 版本,如 "MC700 · PLUS 智能版" + final String modelText; + + /// 用于 SN 掩码/复制与占位随机的设备唯一串 + final String snKey; + + final DeviceV2Status status; + + /// 真实电量百分比(离线或无数据时传 null,卡片显示 --) + final int? batteryLevel; + + /// 卡片点击:与旧卡片同一跳转逻辑 + final VoidCallback onTap; + + // ===== 固定视觉配色(与登录/我的/无设备页 V2 同系) ===== + static const _navy = Color(0xFF1A2B4A); + static const _grayText = Color(0xFF86909C); + static const _primaryBlue = Color(0xFF1C96F6); + static const _cardBorder = Color(0xFFE9F1FA); + static const _workingOrange = Color(0xFFF5A623); + static const _workingBg = Color(0xFFFEF3E3); + static const _onlineGreen = Color(0xFF2FBF6B); + static const _onlineBg = Color(0xFFE5F8EC); + static const _offlineGray = Color(0xFF6B7280); + static const _offlineBg = Color(0xFFF0F2F5); + + @override + Widget build(BuildContext context) { + final offline = status == DeviceV2Status.offline; + final battery = offline ? null : batteryLevel; + + return GestureDetector( + onTap: onTap, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: _cardBorder), + boxShadow: [ + BoxShadow( + color: _primaryBlue.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + padding: const EdgeInsets.all(12), + child: Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildThumb(offline), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.bold, + color: _navy, + ), + ), + ), + // 编辑(改名):后端能力未接,统一提示 + GestureDetector( + onTap: () => + CapsuleToast.show('功能暂未开发', showCheck: false), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 6), + child: Icon( + Icons.edit_outlined, + size: 15, + color: _grayText, + ), + ), + ), + _buildBadge(), + ], + ), + const SizedBox(height: 6), + Text( + modelText, + style: const TextStyle(fontSize: 13, color: _grayText), + ), + const SizedBox(height: 6), + Row( + children: [ + Flexible( + child: Text( + 'SN: ${DeviceV2Display.maskedSn(snKey)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + color: _grayText, + ), + ), + ), + // 复制完整 SN + GestureDetector( + onTap: () async { + await Clipboard.setData( + ClipboardData(text: snKey), + ); + CapsuleToast.show('SN 已复制到剪贴板'); + }, + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 6), + child: Icon( + Icons.copy_rounded, + size: 15, + color: _grayText, + ), + ), + ), + ], + ), + ], + ), + ), + Column( + children: [ + // 更多操作:二级未开发 + GestureDetector( + onTap: () => + CapsuleToast.show('功能暂未开发', showCheck: false), + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: const Color(0xFFF3F6FA), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.more_horiz, + size: 18, + color: _navy, + ), + ), + ), + const SizedBox(height: 14), + const Icon( + Icons.chevron_right, + size: 20, + color: Color(0xFFC9CDD4), + ), + ], + ), + ], + ), + const Padding( + padding: EdgeInsets.symmetric(vertical: 10), + child: Divider(height: 1, color: Color(0xFFF0F4FA)), + ), + Row( + children: [ + Icon( + offline + ? Icons.link_off + : Icons.monitor_heart_outlined, + size: 18, + color: offline ? _offlineGray : _primaryBlue, + ), + const SizedBox(width: 6), + Text( + DeviceV2Display.workTextOf(status), + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: offline ? _offlineGray : _navy, + ), + ), + const Icon( + Icons.keyboard_arrow_right, + size: 16, + color: Color(0xFFC9CDD4), + ), + const Spacer(), + _buildBattery(battery), + ], + ), + ], + ), + ), + ); + } + + /// 缩略图:透明底车图,离线时置灰 + Widget _buildThumb(bool offline) { + return Container( + width: 96, + height: 78, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFEAF3FE), Color(0xFFD9EAFB)], + ), + ), + child: () { + final car = Image.asset( + 'assets/images/shebei-logo.png', + fit: BoxFit.cover, + ); + // 离线置灰;在线不能套任何滤镜(transparent 滤镜会把图洗没) + if (!offline) return car; + return ColorFiltered( + colorFilter: const ColorFilter.matrix([ + 0.33, 0.33, 0.33, 0, 40, // + 0.33, 0.33, 0.33, 0, 40, // + 0.33, 0.33, 0.33, 0, 40, // + 0, 0, 0, 1, 0, // + ]), + child: car, + ); + }(), + ); + } + + /// 状态徽章:作业中(橙) / 在线(绿) / 离线(灰) + Widget _buildBadge() { + final (color, bg, label) = switch (status) { + DeviceV2Status.working => (_workingOrange, _workingBg, '作业中'), + DeviceV2Status.online => (_onlineGreen, _onlineBg, '在线'), + DeviceV2Status.offline => (_offlineGray, _offlineBg, '离线'), + }; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(20)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 5, + height: 5, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 4), + Text( + label, + style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: color), + ), + ], + ), + ); + } + + /// 电量:离线显示空电池 + -- + Widget _buildBattery(int? level) { + if (level == null) { + return const Row( + children: [ + Icon(Icons.battery_unknown, size: 18, color: _offlineGray), + SizedBox(width: 4), + Text('--', style: TextStyle(fontSize: 13, color: _offlineGray)), + ], + ); + } + final icon = switch (level) { + >= 80 => Icons.battery_full, + >= 50 => Icons.battery_5_bar, + >= 25 => Icons.battery_3_bar, + _ => Icons.battery_2_bar, + }; + return Row( + children: [ + Icon(icon, size: 18, color: _onlineGreen), + const SizedBox(width: 4), + Text( + '$level%', + style: const TextStyle(fontSize: 13, color: _navy), + ), + ], + ); + } +} diff --git a/lib/features/v2/local_camera/presentation/cubit/local_camera_cubit.dart b/lib/features/v2/local_camera/presentation/cubit/local_camera_cubit.dart index 1f8810bb..a88d678d 100644 --- a/lib/features/v2/local_camera/presentation/cubit/local_camera_cubit.dart +++ b/lib/features/v2/local_camera/presentation/cubit/local_camera_cubit.dart @@ -145,7 +145,10 @@ class LocalCameraCubit extends Cubit if (!ok) { final detail = await _describeNetworks(); final header = Platform.isIOS - ? '未检测到局域网连接。请让 iPhone/iPad 与摄像头连入同一 WiFi 后重试' + ? '未检测到局域网网卡。\n' + '• iPad 纯网线:插上 USB‑C 转以太网适配器并接好摄像头网线,' + '到「设置 → 以太网」把 IP 配成与摄像头同网段\n' + '• iPhone 不支持网线直连,请改用摄像头自带 WiFi 或便携路由' : msgNoNetworkEntry; emit( state.copyWith( @@ -549,18 +552,47 @@ class LocalCameraCubit extends Cubit } } - /// iOS:局域网连通判据——走同一 WiFi(或以太网),不要求 USB 以太网卡存在。 + /// iOS:局域网连通判据。 + /// iPhone/iPad 没有安卓那种「USB-C 转以太网 + 以太网共享」,组网方式不同: + /// • 走同一 WiFi(en0);或 + /// • iPad 插 USB‑C 转以太网适配器「纯网线」直连摄像头(connectivity_plus 常把它 + /// 报成 other/none,所以必须再用网卡枚举复核)。 + /// 判据:connectivity 报 WiFi/以太网,或存在任一「非蜂窝、非环回」的 IPv4 网卡。 Future _isLanConnectedForIOS() async { + // ① connectivity 快判 try { final results = await Connectivity().checkConnectivity(); - final bool lan = results.contains(ConnectivityResult.wifi) || - results.contains(ConnectivityResult.ethernet); - debugPrint( - '🌐 [网络检查-iOS] ${lan ? "已连接 WiFi/以太网 LAN ✔" : "未见 LAN"}:$results', - ); - return lan; + if (results.contains(ConnectivityResult.wifi) || + results.contains(ConnectivityResult.ethernet)) { + debugPrint('🌐 [网络检查-iOS] connectivity 报 WiFi/以太网 LAN ✔ $results'); + return true; + } + debugPrint('🌐 [网络检查-iOS] connectivity:$results(转网卡枚举复核)'); } catch (e) { - debugPrint('🌐 [网络检查-iOS] connectivity 失败:$e(保守放行,交给后续探测)'); + debugPrint('🌐 [网络检查-iOS] connectivity 失败:$e(转网卡枚举复核)'); + } + // ② 网卡枚举复核:兼容 iPad USB 以太网直连(纯网线、无 WiFi) + try { + final ifs = await NetworkInterface.list( + type: InternetAddressType.IPv4, + includeLoopback: false, + ); + for (final i in ifs) { + final n = i.name.toLowerCase(); + // 排除蜂窝(iPhone 数据网络),其余有 IPv4 的接口都算局域网就绪 + final cellular = n.startsWith('pdp') || + n.startsWith('wwan') || + n.contains('cellular'); + if (!cellular && i.addresses.isNotEmpty) { + final ips = i.addresses.map((a) => a.address.toString()).join(','); + debugPrint('🌐 [网络检查-iOS] 采用网卡 ${i.name}($ips) 视为局域网 ✔'); + return true; + } + } + debugPrint('🌐 [网络检查-iOS] 未发现可用局域网网卡(仅蜂窝/无 IP)'); + return false; + } catch (e) { + debugPrint('🌐 [网络检查-iOS] 枚举网卡失败:$e(保守放行,交给后续探测)'); return true; } } @@ -738,7 +770,7 @@ class LocalCameraCubit extends Cubit phase: LocalCameraPhase.disconnected, playStep: ChainStatus.failed, message: _usesMediaKit - ? '局域网连接已断开,请检查 WiFi 与摄像头是否在线' + ? '局域网连接已断开,请检查网线/适配器与摄像头是否在线' : msgDisconnected, showReconnect: true, ), diff --git a/lib/features/v2/my/presentation/pages/my_page_v2.dart b/lib/features/v2/my/presentation/pages/my_page_v2.dart new file mode 100644 index 00000000..fda7e907 --- /dev/null +++ b/lib/features/v2/my/presentation/pages/my_page_v2.dart @@ -0,0 +1,610 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart'; +import 'package:maibu_satabot_v2/core/router/route_paths.dart'; +import 'package:maibu_satabot_v2/core/storage/user_storage.dart'; +import 'package:maibu_satabot_v2/core/utils/image_url_util.dart'; +import 'package:maibu_satabot_v2/components/capsule_toast.dart'; +import 'package:maibu_satabot_v2/features/auth/presentation/bloc/auth_cubit.dart'; +import 'package:maibu_satabot_v2/features/v2/my/presentation/pages/help_feedback_page.dart'; +import 'package:maibu_satabot_v2/features/v2/my/presentation/pages/profile_detail_page.dart'; +import 'package:maibu_satabot_v2/features/v2/my/presentation/pages/system_settings_page.dart'; + +/// 我的页面 V2 —— 按 M01「我的首页」设计稿实现。 +/// +/// 说明: +/// - 视觉:背景渐变复用登录页 V2(纵向浅蓝 + 左上/右上径向光晕); +/// 顶部 me_left_logo.png + 铃铛/设置按钮;用户卡片底图用 logo_me_bg.png 切图。 +/// - 保留旧版 my_page.dart(本文件为新版,路由/Tab 已切到本页)。 +/// - 「编辑个人资料」→ 现有 ProfileDetailPage;「通用设置」→ 现有 SystemSettingsPage。 +/// - 数字(工单/保养/未读)暂无接口,按约定显示「未知」;未实现入口统一 Toast。 +/// - 退出登录按需求放在页脚下方。 +class MyPageV2 extends StatelessWidget { + const MyPageV2({super.key}); + + // ===== 设计稿配色(与登录页 V2 同一套固定视觉,不随主题切换) ===== + static const _bgTop = Color(0xFFFAFCFF); + static const _bgMid = Color(0xFFF0F6FE); + static const _bgBottom = Color(0xFFD6E9FB); + static const _navy = Color(0xFF1A2B4A); + static const _grayText = Color(0xFF86909C); + static const _subText = Color(0xFF4E5969); + static const _primaryBlue = Color(0xFF1C96F6); + static const _orange = Color(0xFFF5A623); + static const _iconBgBlue = Color(0xFFE9F3FE); + static const _iconBgOrange = Color(0xFFFEF3E3); + static const _divider = Color(0xFFF0F4FA); + static const _noteBg = Color(0xFFEAF3FD); + // 设计稿卡片描边:白卡周围那圈淡蓝边(实测约 RGB 241,247,254) + static const _cardBorder = Color(0xFFE9F1FA); + + /// 未实现入口的统一提示 + static void _todoTap() { + CapsuleToast.show('功能暂未开发', showCheck: false); + } + + @override + Widget build(BuildContext context) { + final user = context.watch().state.user; + final userName = user?.nickname ?? user?.username ?? 'SATA用户'; + final userContact = user?.email ?? user?.phone; + + return Scaffold( + backgroundColor: _bgTop, + body: Stack( + fit: StackFit.expand, + children: [ + // 背景三层:纵向渐变 + 左上/右上蓝色光晕(与登录页 V2 一致) + const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [_bgTop, _bgMid, _bgBottom], + stops: [0.0, 0.4, 1.0], + ), + ), + ), + const DecoratedBox( + decoration: BoxDecoration( + gradient: RadialGradient( + center: Alignment(-1.1, -1.0), + radius: 1.1, + colors: [Color(0xFFD6E8FC), Color(0x00D6E8FC)], + ), + ), + ), + const DecoratedBox( + decoration: BoxDecoration( + gradient: RadialGradient( + center: Alignment(1.1, -0.9), + radius: 1.25, + colors: [Color(0xFFC7DFF8), Color(0x00C7DFF8)], + ), + ), + ), + SafeArea( + bottom: false, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 12), + _buildTopBar(context), + const SizedBox(height: 16), + _buildUserCard(context, userName, userContact, user?.avatar), + const SizedBox(height: 14), + _buildStatsRow(context), + const SizedBox(height: 22), + _buildSectionTitle('服务支持'), + const SizedBox(height: 10), + _buildServiceGroup(context), + const SizedBox(height: 22), + _buildSectionTitle('账户与设置'), + const SizedBox(height: 10), + _buildAccountGroup(context), + const SizedBox(height: 16), + _buildNoteCard(), + const SizedBox(height: 14), + // 页脚灰字(设计稿) + const Center( + child: Text( + '设计原型 · 数据仅供演示 · 不连接真实设备或账号', + style: TextStyle(fontSize: 11, color: _grayText), + ), + ), + const SizedBox(height: 10), + // 退出登录:按需求放在设计稿内容下方 + _buildLogoutButton(context), + const SizedBox(height: 24), + ], + ), + ), + ), + ], + ), + ); + } + + // ===== 顶部栏:logo + 铃铛/设置按钮 ===== + Widget _buildTopBar(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Image.asset( + 'assets/images/me_left_logo.png', + height: 44, + fit: BoxFit.contain, + ), + Row( + children: [ + _buildTopIconButton(Icons.notifications_none, onTap: _todoTap), + const SizedBox(width: 10), + _buildTopIconButton( + Icons.settings_outlined, + onTap: () => _push(context, const SystemSettingsPage()), + ), + ], + ), + ], + ); + } + + Widget _buildTopIconButton(IconData icon, {required VoidCallback onTap}) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: _cardBorder), + boxShadow: [ + BoxShadow( + color: _primaryBlue.withValues(alpha: 0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Icon(icon, size: 22, color: _navy), + ), + ); + } + + // ===== 用户卡片:底图用 logo_me_bg.png 切图 ===== + Widget _buildUserCard( + BuildContext context, + String userName, + String? userContact, + String? avatarUrl, + ) { + return GestureDetector( + onTap: () => _push(context, const ProfileDetailPage()), + // 切图自身已烘进圆角、细描边与透明留白:不能再套 ClipRRect+cover, + // 否则裁切会把描边线残留在卡片内部形成痕迹;fill 原样呈现即可。 + child: Stack( + children: [ + Positioned.fill( + child: Image.asset( + 'assets/images/logo_me_bg.png', + fit: BoxFit.fill, + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + _buildAvatar(avatarUrl), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + userName, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 19, + fontWeight: FontWeight.bold, + color: _navy, + ), + ), + ), + const Icon( + Icons.chevron_right, + size: 18, + color: _navy, + ), + ], + ), + const SizedBox(height: 3), + Text( + '个人账号 · ${userContact ?? '未设置联系方式'}', + style: const TextStyle( + fontSize: 12.5, + color: _subText, + ), + ), + const SizedBox(height: 8), + const Row( + children: [ + Text( + '编辑个人资料', + style: TextStyle( + fontSize: 13.5, + fontWeight: FontWeight.w600, + color: _primaryBlue, + ), + ), + Icon( + Icons.chevron_right, + size: 16, + color: _primaryBlue, + ), + ], + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } + + /// 头像:后端可能返回相对路径,先经 ImageUrlUtil 补全(项目规范) + Widget _buildAvatar(String? avatarUrl) { + final url = ImageUrlUtil.resolve(avatarUrl); + return Container( + width: 60, + height: 60, + clipBehavior: Clip.antiAlias, + decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle), + child: url != null && url.isNotEmpty + ? Image.network( + url, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + _buildDefaultAvatar(), + ) + : _buildDefaultAvatar(), + ); + } + + Widget _buildDefaultAvatar() { + return const Icon(Icons.person, size: 36, color: _primaryBlue); + } + + // ===== 三个数据卡 ===== + Widget _buildStatsRow(BuildContext context) { + return Row( + children: [ + Expanded( + child: _buildStatCard( + icon: Icons.assignment_outlined, + iconColor: _primaryBlue, + value: '未知', + label: '处理中工单', + onTap: _todoTap, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildStatCard( + icon: Icons.build_outlined, + iconColor: _orange, + value: '未知', + label: '保养提醒', + onTap: _todoTap, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildStatCard( + icon: Icons.article_outlined, + iconColor: _primaryBlue, + value: '教程', + label: '使用与帮助', + onTap: () => _push(context, const HelpFeedbackPage()), + ), + ), + ], + ); + } + + Widget _buildStatCard({ + required IconData icon, + required Color iconColor, + required String value, + required String label, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: _cardBorder), + boxShadow: [ + BoxShadow( + color: _primaryBlue.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + Icon(icon, size: 20, color: iconColor), + const SizedBox(height: 4), + // 字号与登录页统一:数值 16 ≈ 登录按钮字号 + Text( + value, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: _navy, + ), + ), + const SizedBox(height: 1), + // 标签 11.5 ≈ 登录页辅助文字,略小于输入提示 14 + Text(label, style: const TextStyle(fontSize: 11.5, color: _grayText)), + ], + ), + ), + ); + } + + // ===== 分组卡片 ===== + Widget _buildSectionTitle(String title) { + return Text( + title, + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.bold, + color: _navy, + ), + ); + } + + Widget _buildGroupCard(List rows) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: _cardBorder), + boxShadow: [ + BoxShadow( + color: _primaryBlue.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column(children: rows), + ); + } + + Widget _buildServiceGroup(BuildContext context) { + return _buildGroupCard([ + _buildMenuRow( + icon: Icons.headset_mic_outlined, + iconBg: _iconBgBlue, + iconColor: _primaryBlue, + title: '售后服务中心', + subtitle: '报修、工单、保修查询', + onTap: _todoTap, + ), + _buildRowDivider(), + _buildMenuRow( + icon: Icons.build_outlined, + iconBg: _iconBgOrange, + iconColor: _orange, + title: '维修保养', + subtitle: '保养计划、提醒与维护记录', + onTap: _todoTap, + ), + _buildRowDivider(), + _buildMenuRow( + icon: Icons.menu_book_outlined, + iconBg: _iconBgBlue, + iconColor: _primaryBlue, + title: '帮助与操作指南', + subtitle: '产品说明书、教程及常见问题', + onTap: () => _push(context, const HelpFeedbackPage()), + ), + ]); + } + + Widget _buildAccountGroup(BuildContext context) { + return _buildGroupCard([ + _buildMenuRow( + icon: Icons.verified_user_outlined, + iconBg: _iconBgBlue, + iconColor: _primaryBlue, + title: '账号与安全', + subtitle: '邮箱/手机、密码及登录设备', + onTap: _todoTap, + ), + _buildRowDivider(), + _buildMenuRow( + icon: Icons.notifications_none, + iconBg: _iconBgBlue, + iconColor: _primaryBlue, + title: '消息通知', + subtitle: '设备、作业、服务及授权消息', + // 未读接口暂无,按约定显示「未知」 + trailingText: '未读未知', + onTap: _todoTap, + ), + _buildRowDivider(), + _buildMenuRow( + icon: Icons.settings_outlined, + iconBg: _iconBgBlue, + iconColor: _primaryBlue, + title: '通用设置', + subtitle: '语言、单位、地区及APP偏好', + onTap: () => _push(context, const SystemSettingsPage()), + ), + _buildRowDivider(), + _buildMenuRow( + icon: Icons.info_outline, + iconBg: _iconBgBlue, + iconColor: _primaryBlue, + title: '关于与法律协议', + subtitle: 'APP版本、协议和隐私政策', + onTap: _todoTap, + ), + ]); + } + + Widget _buildMenuRow({ + required IconData icon, + required Color iconBg, + required Color iconColor, + required String title, + required String subtitle, + required VoidCallback onTap, + String? trailingText, + }) { + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: iconBg, + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, size: 21, color: iconColor), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: _navy, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: const TextStyle(fontSize: 12, color: _grayText), + ), + ], + ), + ), + if (trailingText != null) + Text( + trailingText, + style: const TextStyle(fontSize: 12.5, color: _grayText), + ), + const SizedBox(width: 2), + const Icon(Icons.chevron_right, size: 18, color: Color(0xFFC9CDD4)), + ], + ), + ), + ); + } + + Widget _buildRowDivider() { + return const Divider(height: 1, thickness: 1, color: _divider, indent: 66); + } + + // ===== 底部说明卡(设计稿原文) ===== + Widget _buildNoteCard() { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: _noteBg, + borderRadius: BorderRadius.circular(12), + ), + child: const Text( + '设备共享、成员授权、管理员移交与解绑转让沿用「设备」模块已有流程,本模块不重复开发。', + style: TextStyle(fontSize: 12.5, color: _subText, height: 1.6), + ), + ); + } + + // ===== 退出登录(放在页脚下方) ===== + Widget _buildLogoutButton(BuildContext context) { + return GestureDetector( + onTap: () => _showLogoutDialog(context), + behavior: HitTestBehavior.opaque, + child: const SizedBox( + width: double.infinity, + height: 44, + child: Center( + child: Text( + '退出登录', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: Color(0xFFF53F3F), + ), + ), + ), + ), + ); + } + + void _showLogoutDialog(BuildContext context) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('退出登录'), + content: const Text('确定要退出登录吗?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('取消'), + ), + TextButton( + onPressed: () async { + Navigator.pop(dialogContext); + await GetIt.I().deleteUser(); + GetIt.I().logout(); + if (dialogContext.mounted) { + GoRouter.of(dialogContext).go(RoutePaths.login); + } + }, + style: TextButton.styleFrom( + foregroundColor: const Color(0xFFF53F3F), + ), + child: const Text('确定'), + ), + ], + ), + ); + } + + void _push(BuildContext context, Widget page) { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => page), + ); + } +} diff --git a/lib/features/v2/my/presentation/routes/my_routes.dart b/lib/features/v2/my/presentation/routes/my_routes.dart index 46bfd06b..51da3e21 100644 --- a/lib/features/v2/my/presentation/routes/my_routes.dart +++ b/lib/features/v2/my/presentation/routes/my_routes.dart @@ -1,5 +1,6 @@ import 'package:go_router/go_router.dart'; -import 'package:maibu_satabot_v2/features/v2/my/presentation/pages/my_page.dart'; +// 新版「我的」页(M01 设计稿);旧版 my_page.dart 保留备用 +import 'package:maibu_satabot_v2/features/v2/my/presentation/pages/my_page_v2.dart'; /// 我的页面路由配置 class MyRoutes { @@ -7,7 +8,7 @@ class MyRoutes { GoRoute( path: 'my', name: 'my', - builder: (context, state) => const MyPage(), + builder: (context, state) => const MyPageV2(), ), ]; }