diff --git a/airhub_app/ios/Runner/Info.plist b/airhub_app/ios/Runner/Info.plist index 943fe1e..a5ebd0d 100644 --- a/airhub_app/ios/Runner/Info.plist +++ b/airhub_app/ios/Runner/Info.plist @@ -26,6 +26,10 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSCameraUsageDescription + 需要相机权限来拍照传图到徽章设备 + NSPhotoLibraryUsageDescription + 需要相册权限来选择图片传图到徽章设备 NSBluetoothAlwaysUsageDescription 需要蓝牙权限来搜索和连接您的设备 NSBluetoothPeripheralUsageDescription diff --git a/airhub_app/lib/core/network/api_client.dart b/airhub_app/lib/core/network/api_client.dart index 3b3b8bb..cae9f7e 100644 --- a/airhub_app/lib/core/network/api_client.dart +++ b/airhub_app/lib/core/network/api_client.dart @@ -143,7 +143,6 @@ class _AuthInterceptor extends Interceptor { '/auth/phone-login/', '/auth/refresh/', '/version/check/', - '/devices/query-by-mac/', ]; final needsAuth = !noAuthPaths.any((p) => options.path.contains(p)); diff --git a/airhub_app/lib/core/network/api_config.dart b/airhub_app/lib/core/network/api_config.dart index e6e3598..b4011e3 100644 --- a/airhub_app/lib/core/network/api_config.dart +++ b/airhub_app/lib/core/network/api_config.dart @@ -1,6 +1,14 @@ +import 'package:flutter/foundation.dart' show kIsWeb; + class ApiConfig { - /// 后端服务器地址 - static const String baseUrl = 'https://qiyuan-rtc-api.airlabs.art'; + /// 本地开发地址(Web 调试用) + static const String _localUrl = 'http://192.168.124.8:8000'; + + /// 线上地址(APP 用) + static const String _prodUrl = 'https://qiyuan-rtc-api.airlabs.art'; + + /// 根据运行环境自动选择:Web → 本地,APP → 线上 + static String get baseUrl => kIsWeb ? _localUrl : _prodUrl; /// 一键授权登录专用域名(HTTPS,用于阿里云号码认证) static const String authBaseUrl = 'https://qiyuan-rtc-api.airlabs.art'; diff --git a/airhub_app/lib/core/router/app_router.dart b/airhub_app/lib/core/router/app_router.dart index 7216857..1dc2bfb 100644 --- a/airhub_app/lib/core/router/app_router.dart +++ b/airhub_app/lib/core/router/app_router.dart @@ -1,4 +1,5 @@ import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; import 'package:go_router/go_router.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -10,13 +11,23 @@ import '../../pages/product_selection_page.dart'; import '../../pages/profile/profile_page.dart'; import '../../pages/webview_page.dart'; import '../../pages/wifi_config_page.dart'; +import '../../features/badge/presentation/pages/badge_basic_control_page.dart'; import '../../features/badge/presentation/pages/badge_control_page.dart'; import '../../features/badge/presentation/pages/badge_home_page.dart'; import '../../features/badge/presentation/pages/badge_transfer_page.dart'; +import '../../features/device/data/datasources/device_remote_data_source.dart'; +import '../../theme/product_theme.dart'; import '../network/token_manager.dart'; part 'app_router.g.dart'; +/// 产品代码 → 路由 + ProductType 的映射 +const _productCodeRoutes = { + 'KPBL-ON': (route: '/device-control', type: ProductType.capybara), + 'DZBJ-ON': (route: '/badge-control', type: ProductType.badgeAi), + 'DZBJ-OFF': (route: '/badge-basic-control', type: ProductType.badgeBasic), +}; + @riverpod GoRouter goRouter(Ref ref) { final tokenManager = ref.watch(tokenManagerProvider); @@ -32,6 +43,37 @@ GoRouter goRouter(Ref ref) { return '/login'; } if (hasToken && isLoginRoute) { + // 登录成功 → 跳到最近使用的设备业务页 + try { + final dataSource = ref.read(deviceRemoteDataSourceProvider); + final devices = await dataSource.getMyDevices(); + debugPrint('[Router] 已绑定设备数: ${devices.length}'); + if (devices.isNotEmpty) { + // 按 last_online_at 降序,取最近使用的设备 + devices.sort((a, b) { + final ta = a.device.lastOnlineAt ?? ''; + final tb = b.device.lastOnlineAt ?? ''; + return tb.compareTo(ta); + }); + final recent = devices.first; + final dt = recent.device.deviceType; + final dti = recent.device.deviceTypeInfo; + debugPrint('[Router] 最近设备 sn=${recent.device.sn}'); + debugPrint('[Router] deviceType=$dt'); + debugPrint('[Router] deviceTypeInfo=$dti'); + final resolvedDt = dt ?? dti; + final code = resolvedDt?.productCode ?? ''; + debugPrint('[Router] productCode=$code'); + final mapping = _productCodeRoutes[code]; + debugPrint('[Router] mapping=$mapping → route=${mapping?.route}'); + if (mapping != null) { + ref.read(currentProductTypeProvider.notifier).set(mapping.type); + return mapping.route; + } + } + } catch (e) { + debugPrint('[Router] 获取设备失败: $e'); + } return '/home'; } return null; @@ -69,6 +111,10 @@ GoRouter goRouter(Ref ref) { path: '/badge-control', builder: (context, state) => const BadgeControlPage(), ), + GoRoute( + path: '/badge-basic-control', + builder: (context, state) => const BadgeBasicControlPage(), + ), GoRoute( path: '/badge', builder: (context, state) => const BadgeHomePage(), diff --git a/airhub_app/lib/core/router/app_router.g.dart b/airhub_app/lib/core/router/app_router.g.dart index 26cd54c..2b81a6b 100644 --- a/airhub_app/lib/core/router/app_router.g.dart +++ b/airhub_app/lib/core/router/app_router.g.dart @@ -48,4 +48,4 @@ final class GoRouterProvider } } -String _$goRouterHash() => r'25447f0e21cf92fb17956c2d137db4fe19e43338'; +String _$goRouterHash() => r'276ed56c903c6bd5bb43569f7b8f58103d73198c'; diff --git a/airhub_app/lib/features/auth/presentation/pages/login_page.dart b/airhub_app/lib/features/auth/presentation/pages/login_page.dart index bf9e330..9d0465d 100644 --- a/airhub_app/lib/features/auth/presentation/pages/login_page.dart +++ b/airhub_app/lib/features/auth/presentation/pages/login_page.dart @@ -219,7 +219,7 @@ class _LoginPageState extends ConsumerState { final success = await ref.read(authControllerProvider.notifier).tokenLogin(token); debugPrint('[Login] tokenLogin 结果: $success'); if (success && mounted) { - await _navigateAfterLogin(); + context.go('/login'); } } @@ -274,25 +274,8 @@ class _LoginPageState extends ConsumerState { .read(authControllerProvider.notifier) .codeLogin(_phoneController.text, _codeController.text); if (success && mounted) { - await _navigateAfterLogin(); - } - } - - Future _navigateAfterLogin() async { - if (!mounted) return; - try { - final devices = await ref.read(deviceControllerProvider.future); - if (!mounted) return; - if (devices.isNotEmpty) { - debugPrint('[Login] User has ${devices.length} device(s), navigating to device control'); - context.go('/device-control'); - } else { - debugPrint('[Login] No devices, navigating to home'); - context.go('/home'); - } - } catch (e) { - debugPrint('[Login] Device check failed: $e'); - if (mounted) context.go('/home'); + // 登录成功后跳到 /login,触发 router redirect 统一处理跳转 + context.go('/login'); } } diff --git a/airhub_app/lib/features/badge/presentation/pages/badge_basic_control_page.dart b/airhub_app/lib/features/badge/presentation/pages/badge_basic_control_page.dart new file mode 100644 index 0000000..7bd9819 --- /dev/null +++ b/airhub_app/lib/features/badge/presentation/pages/badge_basic_control_page.dart @@ -0,0 +1,424 @@ +import 'dart:convert'; +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:go_router/go_router.dart'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../../../core/network/api_config.dart'; +import '../../../../pages/profile/profile_page.dart'; +import '../../../../theme/product_theme.dart'; +import '../../../../widgets/animated_gradient_background.dart'; + +/// 普通电子吧唧 (DZBJ-OFF) 业务主页 +class BadgeBasicControlPage extends ConsumerStatefulWidget { + const BadgeBasicControlPage({super.key}); + + @override + ConsumerState createState() => + _BadgeBasicControlPageState(); +} + +class _BadgeBasicControlPageState extends ConsumerState + with SingleTickerProviderStateMixin { + int _currentTab = 0; + late AnimationController _floatController; + late Animation _floatAnimation; + + String? _lastImageUrl; + bool _loading = true; + + @override + void initState() { + super.initState(); + Future.microtask(() => ref.read(currentProductTypeProvider.notifier).set(ProductType.badgeBasic)); + _floatController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 3000), + )..repeat(reverse: true); + _floatAnimation = Tween(begin: -8, end: 8).animate( + CurvedAnimation(parent: _floatController, curve: Curves.easeInOut), + ); + _loadLastImage(); + } + + @override + void dispose() { + _floatController.dispose(); + super.dispose(); + } + + Future _loadLastImage() async { + try { + final prefs = await SharedPreferences.getInstance(); + final token = prefs.getString('access_token'); + final resp = await http.get( + Uri.parse('${ApiConfig.fullBaseUrl}/badge/history/'), + headers: {if (token != null) 'Authorization': 'Bearer $token'}, + ).timeout(const Duration(seconds: 10)); + + if (resp.statusCode == 200) { + final body = jsonDecode(resp.body) as Map; + final data = body['data'] as Map? ?? {}; + final images = (data['images'] as List? ?? []) + .cast>() + .where((img) => + img['generation_status'] == 'completed' && + (img['image_url'] as String?)?.isNotEmpty == true) + .toList(); + if (images.isNotEmpty && mounted) { + setState(() => _lastImageUrl = images.first['image_url'] as String); + } + } + } catch (_) {} + if (mounted) setState(() => _loading = false); + } + + @override + Widget build(BuildContext context) { + final productTheme = ref.watch(currentProductThemeProvider); + + return Scaffold( + backgroundColor: Colors.white, + body: Stack( + children: [ + const AnimatedGradientBackground(), + + IndexedStack( + index: _currentTab, + children: [ + _buildHomePage(productTheme), + const ProfilePage(), + ], + ), + + Positioned( + left: 0, + right: 0, + bottom: MediaQuery.of(context).padding.bottom + 12, + child: _buildBottomNavBar(productTheme), + ), + ], + ), + ); + } + + Widget _buildHomePage(ProductThemeData productTheme) { + return SafeArea( + bottom: false, + child: Stack( + children: [ + Center( + child: AnimatedBuilder( + animation: _floatAnimation, + builder: (context, child) => Transform.translate( + offset: Offset(0, _floatAnimation.value), + child: child, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _BadgePreviewCircle( + imageUrl: _lastImageUrl, + loading: _loading, + accentColor: productTheme.accentColor, + size: 240, + ), + const SizedBox(height: 16), + if (!_loading && _lastImageUrl == null) + Text( + '点击右上角「传图」上传你的第一张图', + style: TextStyle( + fontSize: 13, + color: productTheme.accentColor.withOpacity(0.5), + ), + ), + ], + ), + ), + ), + + Positioned( + top: 8, + left: 16, + right: 16, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _GlassIconButton( + onTap: () => context.push('/product-selection'), + child: SvgPicture.asset( + 'assets/www/icons/icon-switch.svg', + width: 20, + height: 20, + colorFilter: const ColorFilter.mode( + Color(0xFF4B5563), + BlendMode.srcIn, + ), + ), + ), + _GlassPillButton( + onTap: () => context.push('/badge'), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '传图', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: productTheme.accentColor, + ), + ), + const SizedBox(width: 4), + Icon(Icons.send_rounded, + size: 16, color: productTheme.accentColor), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildBottomNavBar(ProductThemeData productTheme) { + return Center( + child: ClipRRect( + borderRadius: BorderRadius.circular(32), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + width: 180, + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.6), + borderRadius: BorderRadius.circular(32), + border: Border.all(color: Colors.white.withOpacity(0.8)), + boxShadow: [ + BoxShadow( + color: const Color(0xFF4B5563).withOpacity(0.08), + offset: const Offset(0, 10), + blurRadius: 30, + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildNavItem(0, 'assets/www/icons/icon-home-pixel.svg', + Icons.home, productTheme), + _buildNavItem(1, 'assets/www/icons/icon-user-pixel.svg', + Icons.person, productTheme), + ], + ), + ), + ), + ), + ); + } + + Widget _buildNavItem( + int index, String iconPath, IconData fallback, ProductThemeData theme) { + final isActive = _currentTab == index; + return GestureDetector( + onTap: () => setState(() => _currentTab = index), + child: AnimatedContainer( + duration: const Duration(milliseconds: 250), + width: 56, + height: 56, + decoration: BoxDecoration( + gradient: isActive ? theme.buttonGradient : null, + borderRadius: BorderRadius.circular(28), + boxShadow: isActive + ? [ + BoxShadow( + color: theme.accentColor.withOpacity(0.4), + offset: const Offset(0, 4), + blurRadius: 15, + ), + ] + : null, + ), + alignment: Alignment.center, + child: SvgPicture.asset( + iconPath, + width: isActive ? 28 : 26, + height: isActive ? 28 : 26, + colorFilter: ColorFilter.mode( + isActive ? Colors.white : const Color(0xFF6B7280).withOpacity(0.6), + BlendMode.srcIn, + ), + placeholderBuilder: (_) => Icon( + fallback, + color: isActive ? Colors.white : const Color(0xFF6B7280), + size: 24, + ), + ), + ), + ); + } +} + +class _BadgePreviewCircle extends StatelessWidget { + final String? imageUrl; + final bool loading; + final Color accentColor; + final double size; + + const _BadgePreviewCircle({ + required this.imageUrl, + required this.loading, + required this.accentColor, + required this.size, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: const Color(0xFF2D3748), + border: Border.all(color: const Color(0xFF4A5568), width: 4), + boxShadow: [ + BoxShadow( + color: accentColor.withOpacity(0.2), + blurRadius: 40, + spreadRadius: 8, + ), + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + padding: const EdgeInsets.all(8), + child: ClipOval( + child: loading + ? Center( + child: CircularProgressIndicator( + strokeWidth: 2, + color: accentColor.withOpacity(0.5), + ), + ) + : imageUrl != null + ? Image.network( + imageUrl!, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => _buildPlaceholder(), + ) + : _buildPlaceholder(), + ), + ); + } + + Widget _buildPlaceholder() { + return Container( + color: const Color(0xFF1A202C), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.add_photo_alternate_outlined, + size: 48, color: accentColor.withOpacity(0.4)), + const SizedBox(height: 8), + Text( + '暂无图片', + style: TextStyle( + fontSize: 13, + color: accentColor.withOpacity(0.3), + ), + ), + ], + ), + ), + ); + } +} + +class _GlassIconButton extends StatefulWidget { + final VoidCallback onTap; + final Widget child; + const _GlassIconButton({required this.onTap, required this.child}); + @override + State<_GlassIconButton> createState() => _GlassIconButtonState(); +} + +class _GlassIconButtonState extends State<_GlassIconButton> { + bool _pressed = false; + @override + Widget build(BuildContext context) { + return GestureDetector( + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + onTap: () { + HapticFeedback.lightImpact(); + widget.onTap(); + }, + child: AnimatedScale( + scale: _pressed ? 0.92 : 1.0, + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + child: Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: Colors.white.withOpacity(_pressed ? 0.45 : 0.25), + borderRadius: BorderRadius.circular(22), + border: Border.all(color: Colors.white.withOpacity(0.4)), + ), + alignment: Alignment.center, + child: widget.child, + ), + ), + ); + } +} + +class _GlassPillButton extends StatefulWidget { + final VoidCallback onTap; + final Widget child; + const _GlassPillButton({required this.onTap, required this.child}); + @override + State<_GlassPillButton> createState() => _GlassPillButtonState(); +} + +class _GlassPillButtonState extends State<_GlassPillButton> { + bool _pressed = false; + @override + Widget build(BuildContext context) { + return GestureDetector( + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + onTap: () { + HapticFeedback.lightImpact(); + widget.onTap(); + }, + child: AnimatedScale( + scale: _pressed ? 0.94 : 1.0, + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + child: Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: Colors.white.withOpacity(_pressed ? 0.45 : 0.25), + borderRadius: BorderRadius.circular(22), + border: Border.all(color: Colors.white.withOpacity(0.4)), + ), + child: widget.child, + ), + ), + ); + } +} diff --git a/airhub_app/lib/features/badge/presentation/pages/badge_control_page.dart b/airhub_app/lib/features/badge/presentation/pages/badge_control_page.dart index 69b3da7..7863591 100644 --- a/airhub_app/lib/features/badge/presentation/pages/badge_control_page.dart +++ b/airhub_app/lib/features/badge/presentation/pages/badge_control_page.dart @@ -1,12 +1,15 @@ +import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; +import '../../../../pages/profile/profile_page.dart'; import '../../../../theme/product_theme.dart'; import '../../../../widgets/animated_gradient_background.dart'; +/// 电子吧唧 AI (DZBJ-ON) 业务主页 class BadgeControlPage extends ConsumerStatefulWidget { const BadgeControlPage({super.key}); @@ -16,17 +19,18 @@ class BadgeControlPage extends ConsumerStatefulWidget { class _BadgeControlPageState extends ConsumerState with SingleTickerProviderStateMixin { + int _currentTab = 0; // 0: 主页, 1: 设置 late AnimationController _floatController; late Animation _floatAnimation; @override void initState() { super.initState(); + Future.microtask(() => ref.read(currentProductTypeProvider.notifier).set(ProductType.badgeAi)); _floatController = AnimationController( vsync: this, duration: const Duration(milliseconds: 3000), )..repeat(reverse: true); - // 自然呼吸曲线 _floatAnimation = Tween(begin: -8, end: 8).animate( CurvedAnimation(parent: _floatController, curve: Curves.easeInOut), ); @@ -47,98 +51,106 @@ class _BadgeControlPageState extends ConsumerState body: Stack( children: [ const AnimatedGradientBackground(), - SafeArea( - child: Stack( + + // 内容区 + IndexedStack( + index: _currentTab, + children: [ + _buildHomePage(productTheme), + const ProfilePage(), + ], + ), + + // 底部导航栏 + Positioned( + left: 0, + right: 0, + bottom: MediaQuery.of(context).padding.bottom + 12, + child: _buildBottomNavBar(productTheme), + ), + ], + ), + ); + } + + Widget _buildHomePage(ProductThemeData productTheme) { + return SafeArea( + bottom: false, + child: Stack( + children: [ + // 居中的吧唧图片 + Center( + child: AnimatedBuilder( + animation: _floatAnimation, + builder: (context, child) => Transform.translate( + offset: Offset(0, _floatAnimation.value), + child: child, + ), + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: productTheme.accentColor.withOpacity(0.2), + blurRadius: 60, + spreadRadius: 15, + ), + BoxShadow( + color: productTheme.accentColorLight.withOpacity(0.1), + blurRadius: 100, + spreadRadius: 30, + ), + ], + ), + child: Image.asset( + 'assets/www/Capybara.png', + width: 260, + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => Icon( + Icons.smart_toy, + size: 150, + color: productTheme.accentColor), + ), + ), + ), + ), + + // 顶部操作栏 + Positioned( + top: 8, + left: 16, + right: 16, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - // 居中的吧唧图片 - Center( - child: AnimatedBuilder( - animation: _floatAnimation, - builder: (context, child) { - return Transform.translate( - offset: Offset(0, _floatAnimation.value), - child: child, - ); - }, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // 主题色光晕 - Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: productTheme.accentColor - .withOpacity(0.2), - blurRadius: 60, - spreadRadius: 15, - ), - BoxShadow( - color: productTheme.accentColorLight - .withOpacity(0.1), - blurRadius: 100, - spreadRadius: 30, - ), - ], - ), - child: Image.asset( - 'assets/www/Capybara.png', - width: 260, - fit: BoxFit.contain, - errorBuilder: (_, __, ___) => Icon( - Icons.smart_toy, - size: 150, - color: productTheme.accentColor), - ), - ), - ], + _GlassIconButton( + onTap: () => context.push('/product-selection'), + child: SvgPicture.asset( + 'assets/www/icons/icon-switch.svg', + width: 20, + height: 20, + colorFilter: const ColorFilter.mode( + Color(0xFF4B5563), + BlendMode.srcIn, ), ), ), - - // 顶部操作栏 - Positioned( - top: 8, - left: 16, - right: 16, + _GlassPillButton( + onTap: () => context.push('/badge'), child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.min, children: [ - // 切换产品按钮 - _GlassIconButton( - onTap: () => context.push('/product-selection'), - child: SvgPicture.asset( - 'assets/www/icons/icon-switch.svg', - width: 20, - height: 20, - colorFilter: const ColorFilter.mode( - Color(0xFF4B5563), - BlendMode.srcIn, - ), - ), - ), - // 传图按钮 - _GlassPillButton( - onTap: () => context.push('/badge'), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '传图', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: productTheme.accentColor, - ), - ), - const SizedBox(width: 4), - Icon(Icons.send_rounded, - size: 16, - color: productTheme.accentColor), - ], + Text( + '传图', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: productTheme.accentColor, ), ), + const SizedBox(width: 4), + Icon(Icons.send_rounded, + size: 16, color: productTheme.accentColor), ], ), ), @@ -149,22 +161,95 @@ class _BadgeControlPageState extends ConsumerState ), ); } + + Widget _buildBottomNavBar(ProductThemeData productTheme) { + return Center( + child: ClipRRect( + borderRadius: BorderRadius.circular(32), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + width: 180, + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.6), + borderRadius: BorderRadius.circular(32), + border: Border.all(color: Colors.white.withOpacity(0.8)), + boxShadow: [ + BoxShadow( + color: const Color(0xFF4B5563).withOpacity(0.08), + offset: const Offset(0, 10), + blurRadius: 30, + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildNavItem(0, 'assets/www/icons/icon-home-pixel.svg', + Icons.home, productTheme), + _buildNavItem(1, 'assets/www/icons/icon-user-pixel.svg', + Icons.person, productTheme), + ], + ), + ), + ), + ), + ); + } + + Widget _buildNavItem( + int index, String iconPath, IconData fallback, ProductThemeData theme) { + final isActive = _currentTab == index; + return GestureDetector( + onTap: () => setState(() => _currentTab = index), + child: AnimatedContainer( + duration: const Duration(milliseconds: 250), + width: 56, + height: 56, + decoration: BoxDecoration( + gradient: isActive ? theme.buttonGradient : null, + borderRadius: BorderRadius.circular(28), + boxShadow: isActive + ? [ + BoxShadow( + color: theme.accentColor.withOpacity(0.4), + offset: const Offset(0, 4), + blurRadius: 15, + ), + ] + : null, + ), + alignment: Alignment.center, + child: SvgPicture.asset( + iconPath, + width: isActive ? 28 : 26, + height: isActive ? 28 : 26, + colorFilter: ColorFilter.mode( + isActive ? Colors.white : const Color(0xFF6B7280).withOpacity(0.6), + BlendMode.srcIn, + ), + placeholderBuilder: (_) => Icon( + fallback, + color: isActive ? Colors.white : const Color(0xFF6B7280), + size: 24, + ), + ), + ), + ); + } } -/// 磨砂玻璃圆形图标按钮 — 带按压反馈 class _GlassIconButton extends StatefulWidget { final VoidCallback onTap; final Widget child; - const _GlassIconButton({required this.onTap, required this.child}); - @override State<_GlassIconButton> createState() => _GlassIconButtonState(); } class _GlassIconButtonState extends State<_GlassIconButton> { bool _pressed = false; - @override Widget build(BuildContext context) { return GestureDetector( @@ -195,20 +280,16 @@ class _GlassIconButtonState extends State<_GlassIconButton> { } } -/// 磨砂玻璃胶囊按钮 — 带按压反馈 class _GlassPillButton extends StatefulWidget { final VoidCallback onTap; final Widget child; - const _GlassPillButton({required this.onTap, required this.child}); - @override State<_GlassPillButton> createState() => _GlassPillButtonState(); } class _GlassPillButtonState extends State<_GlassPillButton> { bool _pressed = false; - @override Widget build(BuildContext context) { return GestureDetector( diff --git a/airhub_app/lib/features/device/presentation/controllers/device_controller.dart b/airhub_app/lib/features/device/presentation/controllers/device_controller.dart index aca2810..4653412 100644 --- a/airhub_app/lib/features/device/presentation/controllers/device_controller.dart +++ b/airhub_app/lib/features/device/presentation/controllers/device_controller.dart @@ -19,16 +19,17 @@ class DeviceController extends _$DeviceController { ); } - Future bindDevice(String sn, {int? spiritId}) async { + /// 绑定设备,成功返回 null,失败返回错误信息 + Future bindDevice(String sn, {int? spiritId}) async { final repository = ref.read(deviceRepositoryProvider); final result = await repository.bindDevice(sn, spiritId: spiritId); - if (!ref.mounted) return false; + if (!ref.mounted) return '组件已卸载'; return result.fold( - (failure) => false, + (failure) => failure.message, (bindingId) { - if (!ref.mounted) return false; + if (!ref.mounted) return '组件已卸载'; ref.invalidateSelf(); - return true; + return null; }, ); } diff --git a/airhub_app/lib/pages/bluetooth_page.dart b/airhub_app/lib/pages/bluetooth_page.dart index 11b91e6..967ffa9 100644 --- a/airhub_app/lib/pages/bluetooth_page.dart +++ b/airhub_app/lib/pages/bluetooth_page.dart @@ -27,6 +27,7 @@ class MockDevice { final DeviceType type; final bool hasAI; final bool isNetworkRequired; + final String bindStatus; // unbound / bound_by_me / bound_by_other final BluetoothDevice? bleDevice; const MockDevice({ @@ -36,9 +37,12 @@ class MockDevice { required this.type, required this.hasAI, this.isNetworkRequired = true, + this.bindStatus = 'unbound', this.bleDevice, }); + bool get isBoundByOther => bindStatus == 'bound_by_other'; + String get iconPath { switch (type) { case DeviceType.plush: @@ -256,6 +260,7 @@ class _BluetoothPageState extends ConsumerState final productCode = data['device_type']?['product_code'] as String? ?? ''; final sn = data['sn'] as String? ?? ''; final isNetworkRequired = data['device_type']?['is_network_required'] as bool? ?? true; + final bindStatus = data['bind_status'] as String? ?? 'unbound'; final bleDevice = _pendingBleDevices[mac]; // API 返回了有效设备名 → 添加到列表 @@ -271,6 +276,7 @@ class _BluetoothPageState extends ConsumerState type: _inferDeviceTypeByCode(productCode, displayName), hasAI: _inferHasAI(displayName), isNetworkRequired: isNetworkRequired, + bindStatus: bindStatus, bleDevice: bleDevice, )); } @@ -387,7 +393,7 @@ class _BluetoothPageState extends ConsumerState context.go('/badge-control'); case DeviceType.badge: ref.read(currentProductTypeProvider.notifier).set(ProductType.badgeBasic); - context.go('/badge-control'); + context.go('/badge-basic-control'); case DeviceType.plush: ref.read(currentProductTypeProvider.notifier).set(ProductType.capybara); context.go('/device-control'); @@ -405,14 +411,38 @@ class _BluetoothPageState extends ConsumerState } final device = _devices[_currentIndex]; - debugPrint('[Bluetooth] 连接设备: sn=${device.sn}, mac=${device.macAddress}, needsNetwork=${device.isNetworkRequired}'); + debugPrint('[Bluetooth] 连接设备: sn=${device.sn}, mac=${device.macAddress}, bindStatus=${device.bindStatus}'); + + // 设备已被其他用户绑定 → 拦截 + if (device.isBoundByOther) { + showGlassDialog( + context: context, + title: '无法连接', + description: '该设备已被其他用户绑定,无法使用。如需解绑请联系设备所有者。', + confirmText: '确定', + onConfirm: () => Navigator.of(context).pop(), + ); + return; + } if (!device.isNetworkRequired) { // 不需要联网 -> 跳过配网,绑定设备后进入业务页 if (device.sn.isNotEmpty) { setState(() => _isConnecting = true); try { - await ref.read(deviceControllerProvider.notifier).bindDevice(device.sn); + final error = await ref.read(deviceControllerProvider.notifier).bindDevice(device.sn); + if (!mounted) return; + if (error != null) { + setState(() => _isConnecting = false); + showGlassDialog( + context: context, + title: '绑定失败', + description: error, + confirmText: '确定', + onConfirm: () => Navigator.of(context).pop(), + ); + return; + } } catch (e) { debugPrint('[Bluetooth] bindDevice 异常: $e'); } @@ -428,7 +458,19 @@ class _BluetoothPageState extends ConsumerState debugPrint('[Bluetooth] Web 环境,直接绑定 sn=${device.sn}'); setState(() => _isConnecting = true); if (device.sn.isNotEmpty) { - await ref.read(deviceControllerProvider.notifier).bindDevice(device.sn); + final error = await ref.read(deviceControllerProvider.notifier).bindDevice(device.sn); + if (!mounted) return; + if (error != null) { + setState(() => _isConnecting = false); + showGlassDialog( + context: context, + title: '绑定失败', + description: error, + confirmText: '确定', + onConfirm: () => Navigator.of(context).pop(), + ); + return; + } } if (!mounted) return; setState(() => _isConnecting = false); @@ -523,7 +565,7 @@ class _BluetoothPageState extends ConsumerState children: [ // 返回按钮 - CSS: border-radius: 12px, bg: rgba(255,255,255,0.6), no border GestureDetector( - onTap: () => context.go('/home'), + onTap: () => context.pop(), child: Container( width: 40, height: 40, @@ -788,9 +830,22 @@ class _BluetoothPageState extends ConsumerState style: GoogleFonts.outfit( fontSize: 18, fontWeight: FontWeight.w600, - color: const Color(0xFF1F2937), + color: device.isBoundByOther + ? const Color(0xFF9CA3AF) + : const Color(0xFF1F2937), ), ), + // 已被其他用户绑定提示 + if (device.isBoundByOther) ...[ + const SizedBox(height: 4), + Text( + '已被其他用户绑定', + style: TextStyle( + fontSize: 12, + color: const Color(0xFFEF4444), + ), + ), + ], ], ); } @@ -848,7 +903,7 @@ class _BluetoothPageState extends ConsumerState children: [ // 取消按钮 - HTML: frosted glass with border GestureDetector( - onTap: () => context.go('/home'), + onTap: () => context.pop(), child: ClipRRect( borderRadius: BorderRadius.circular(25), child: Container( diff --git a/airhub_app/lib/pages/device_control_page.dart b/airhub_app/lib/pages/device_control_page.dart index ea48e3d..643882f 100644 --- a/airhub_app/lib/pages/device_control_page.dart +++ b/airhub_app/lib/pages/device_control_page.dart @@ -19,6 +19,7 @@ import '../widgets/ios_toast.dart'; import '../widgets/animated_gradient_background.dart'; import '../widgets/gradient_button.dart'; import '../features/device/presentation/controllers/device_controller.dart'; +import '../theme/product_theme.dart'; class DeviceControlPage extends ConsumerStatefulWidget { const DeviceControlPage({super.key}); @@ -48,6 +49,7 @@ class _DeviceControlPageState extends ConsumerState @override void initState() { super.initState(); + Future.microtask(() => ref.read(currentProductTypeProvider.notifier).set(ProductType.capybara)); _mascotAnimController = AnimationController( vsync: this, duration: const Duration(seconds: 4), diff --git a/airhub_app/lib/pages/product_selection_page.dart b/airhub_app/lib/pages/product_selection_page.dart index 6eb7df4..9122da5 100644 --- a/airhub_app/lib/pages/product_selection_page.dart +++ b/airhub_app/lib/pages/product_selection_page.dart @@ -205,14 +205,16 @@ class _ProductSelectionPageState extends ConsumerState { if (boundDevice != null) { // 已绑定 → 根据产品类型进入对应控制页 final pid = product['id'] as String; - if (pid == 'badge-ai' || pid == 'badge-basic') { + if (pid == 'badge-ai') { context.go('/badge-control'); + } else if (pid == 'badge-basic') { + context.go('/badge-basic-control'); } else { context.go('/device-control'); } } else { - // 未绑定 → 跳转蓝牙搜索页 - context.go('/bluetooth'); + // 未绑定 → 跳转蓝牙搜索页(push 保留返回栈) + context.push('/bluetooth'); } }, ); diff --git a/airhub_app/lib/pages/wifi_config_page.dart b/airhub_app/lib/pages/wifi_config_page.dart index 87d74c8..d5aaa0b 100644 --- a/airhub_app/lib/pages/wifi_config_page.dart +++ b/airhub_app/lib/pages/wifi_config_page.dart @@ -8,6 +8,7 @@ import '../core/services/ble_provisioning_service.dart'; import '../features/device/presentation/controllers/device_controller.dart'; import '../widgets/animated_gradient_background.dart'; import '../widgets/gradient_button.dart'; +import '../widgets/glass_dialog.dart'; import '../theme/product_theme.dart'; class WifiConfigPage extends ConsumerStatefulWidget { @@ -137,7 +138,19 @@ class _WifiConfigPageState extends ConsumerState if (sn.isNotEmpty) { try { debugPrint('[WiFi Config] Binding device sn=$sn'); - await ref.read(deviceControllerProvider.notifier).bindDevice(sn); + final error = await ref.read(deviceControllerProvider.notifier).bindDevice(sn); + if (!mounted) return; + if (error != null) { + setState(() => _isBinding = false); + showGlassDialog( + context: context, + title: '绑定失败', + description: error, + confirmText: '确定', + onConfirm: () => Navigator.of(context).pop(), + ); + return; + } } catch (e) { debugPrint('[WiFi Config] bindDevice 异常: $e'); } @@ -150,7 +163,7 @@ class _WifiConfigPageState extends ConsumerState context.go('/badge-control'); } else if (deviceType == 'badge') { ref.read(currentProductTypeProvider.notifier).set(ProductType.badgeBasic); - context.go('/badge-control'); + context.go('/badge-basic-control'); } else { ref.read(currentProductTypeProvider.notifier).set(ProductType.capybara); context.go('/device-control');