diff --git a/airhub_app/android/app/src/main/AndroidManifest.xml b/airhub_app/android/app/src/main/AndroidManifest.xml index 00b6227..1c33bdf 100644 --- a/airhub_app/android/app/src/main/AndroidManifest.xml +++ b/airhub_app/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,11 @@ + + + + + + + UIApplicationSupportsIndirectInputEvents + NSBluetoothAlwaysUsageDescription + 需要蓝牙权限来搜索和连接您的设备 + NSBluetoothPeripheralUsageDescription + 需要蓝牙权限来搜索和连接您的设备 + NSLocationWhenInUseUsageDescription + 需要位置权限以扫描附近的蓝牙设备 UILaunchScreen UIColorName diff --git a/airhub_app/lib/core/network/api_client.dart b/airhub_app/lib/core/network/api_client.dart index 5a8f961..a37fafc 100644 --- a/airhub_app/lib/core/network/api_client.dart +++ b/airhub_app/lib/core/network/api_client.dart @@ -128,6 +128,7 @@ 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 7abe3aa..89b6863 100644 --- a/airhub_app/lib/core/network/api_config.dart +++ b/airhub_app/lib/core/network/api_config.dart @@ -1,6 +1,6 @@ class ApiConfig { /// 后端服务器地址(开发环境请替换为实际 IP) - static const String baseUrl = 'http://127.0.0.1:8000'; + static const String baseUrl = 'http://192.168.124.24:8000'; /// App 端 API 前缀 static const String apiPrefix = '/api/v1'; diff --git a/airhub_app/lib/core/router/app_router.dart b/airhub_app/lib/core/router/app_router.dart index 67faa4c..fe4a7df 100644 --- a/airhub_app/lib/core/router/app_router.dart +++ b/airhub_app/lib/core/router/app_router.dart @@ -44,7 +44,9 @@ GoRouter goRouter(Ref ref) { ), GoRoute( path: '/wifi-config', - builder: (context, state) => const WifiConfigPage(), + builder: (context, state) => WifiConfigPage( + extra: state.extra as Map?, + ), ), GoRoute( path: '/device-control', diff --git a/airhub_app/lib/core/services/ble_provisioning_service.dart b/airhub_app/lib/core/services/ble_provisioning_service.dart new file mode 100644 index 0000000..dc28bef --- /dev/null +++ b/airhub_app/lib/core/services/ble_provisioning_service.dart @@ -0,0 +1,282 @@ +import 'dart:async'; +import 'dart:convert' show utf8; +import 'dart:typed_data'; +import 'package:flutter/foundation.dart' show debugPrint; +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; + +/// 硬件 BLE 配网协议常量 +class _ProvCmd { + static const int setSsid = 0x01; + static const int setPassword = 0x02; + static const int connectAp = 0x04; + static const int getWifiList = 0x06; +} + +class _ProvResp { + static const int wifiStatus = 0x81; + static const int wifiList = 0x82; + static const int wifiListEnd = 0x83; + static const int customData = 0x84; +} + +/// 配网服务 UUID(与硬件一致) +class _ProvUuid { + static final service = Guid('0000abf0-0000-1000-8000-00805f9b34fb'); + static final writeChar = Guid('0000abf1-0000-1000-8000-00805f9b34fb'); + static final notifyChar = Guid('0000abf2-0000-1000-8000-00805f9b34fb'); +} + +/// 扫描到的 WiFi 网络 +class ScannedWifi { + final String ssid; + final int rssi; + + const ScannedWifi({required this.ssid, required this.rssi}); + + /// 信号强度等级 1-4 + int get level { + if (rssi >= -50) return 4; + if (rssi >= -65) return 3; + if (rssi >= -80) return 2; + return 1; + } +} + +/// WiFi 连接结果 +class WifiResult { + final bool success; + final int reasonCode; + final String? staMac; + + const WifiResult({required this.success, this.reasonCode = 0, this.staMac}); +} + +/// BLE WiFi 配网服务 +/// +/// 封装与硬件的 BLE 通信协议,提供: +/// - 连接 BLE 设备 +/// - 获取 WiFi 列表 +/// - 发送 WiFi 凭证 +/// - 监听连接状态 +class BleProvisioningService { + BluetoothDevice? _device; + BluetoothCharacteristic? _writeChar; + BluetoothCharacteristic? _notifyChar; + StreamSubscription? _notifySubscription; + StreamSubscription? _connectionSubscription; + + bool _connected = false; + bool get isConnected => _connected; + String? get deviceId => _device?.remoteId.str; + + /// 用于传递 WiFi 扫描结果 + final _wifiListController = StreamController>.broadcast(); + Stream> get onWifiList => _wifiListController.stream; + + /// 用于传递 WiFi 连接状态 + final _wifiStatusController = StreamController.broadcast(); + Stream get onWifiStatus => _wifiStatusController.stream; + + /// 用于传递连接断开事件 + final _disconnectController = StreamController.broadcast(); + Stream get onDisconnect => _disconnectController.stream; + + /// 临时存储 WiFi 列表条目 + List _pendingWifiList = []; + + /// 连接到 BLE 设备并发现配网服务 + Future connect(BluetoothDevice device) async { + try { + _device = device; + debugPrint('[BLE Prov] 连接设备: ${device.remoteId}'); + + await device.connect(timeout: const Duration(seconds: 15)); + _connected = true; + debugPrint('[BLE Prov] BLE 连接成功'); + + // 监听连接状态 + _connectionSubscription = device.connectionState.listen((state) { + debugPrint('[BLE Prov] 连接状态变化: $state'); + if (state == BluetoothConnectionState.disconnected) { + debugPrint('[BLE Prov] 设备已断开'); + _connected = false; + _disconnectController.add(null); + } + }); + + // 请求更大的 MTU(iOS 自动协商,可能不支持显式请求) + try { + final mtu = await device.requestMtu(512); + debugPrint('[BLE Prov] MTU 协商成功: $mtu'); + } catch (e) { + debugPrint('[BLE Prov] MTU 协商失败(可忽略): $e'); + } + + // 发现服务 + debugPrint('[BLE Prov] 开始发现服务...'); + final services = await device.discoverServices(); + debugPrint('[BLE Prov] 发现 ${services.length} 个服务'); + + BluetoothService? provService; + for (final s in services) { + debugPrint('[BLE Prov] 服务: ${s.uuid}'); + if (s.uuid == _ProvUuid.service) { + provService = s; + } + } + + if (provService == null) { + debugPrint('[BLE Prov] 未找到配网服务 ${_ProvUuid.service}'); + await disconnect(); + return false; + } + debugPrint('[BLE Prov] 找到配网服务 ABF0'); + + // 找到读写特征 + for (final c in provService.characteristics) { + debugPrint('[BLE Prov] 特征: ${c.uuid}, props: ${c.properties}'); + if (c.uuid == _ProvUuid.writeChar) _writeChar = c; + if (c.uuid == _ProvUuid.notifyChar) _notifyChar = c; + } + + if (_writeChar == null || _notifyChar == null) { + debugPrint('[BLE Prov] 未找到所需特征 writeChar=$_writeChar notifyChar=$_notifyChar'); + await disconnect(); + return false; + } + debugPrint('[BLE Prov] 找到 ABF1(write) + ABF2(notify)'); + + // 订阅 Notify + await _notifyChar!.setNotifyValue(true); + _notifySubscription = _notifyChar!.onValueReceived.listen(_handleNotify); + + debugPrint('[BLE Prov] 配网服务就绪'); + return true; + } catch (e, stack) { + debugPrint('[BLE Prov] 连接失败: $e'); + debugPrint('[BLE Prov] 堆栈: $stack'); + _connected = false; + return false; + } + } + + /// 请求设备扫描 WiFi 网络 + Future requestWifiScan() async { + _pendingWifiList = []; + await _write([_ProvCmd.getWifiList]); + debugPrint('[BLE Prov] 已发送 WiFi 扫描命令'); + } + + /// 发送 WiFi 凭证并触发连接 + Future sendWifiCredentials(String ssid, String password) async { + // 1. 发送 SSID + final ssidBytes = Uint8List.fromList([_ProvCmd.setSsid, ...ssid.codeUnits]); + await _write(ssidBytes); + debugPrint('[BLE Prov] 已发送 SSID: $ssid'); + + // 稍等确保硬件处理完成 + await Future.delayed(const Duration(milliseconds: 100)); + + // 2. 发送密码(硬件收到密码后自动开始连接) + final pwdBytes = Uint8List.fromList([_ProvCmd.setPassword, ...password.codeUnits]); + await _write(pwdBytes); + debugPrint('[BLE Prov] 已发送密码,等待硬件连接 WiFi...'); + } + + /// 断开连接 + Future disconnect() async { + _notifySubscription?.cancel(); + _connectionSubscription?.cancel(); + try { + await _device?.disconnect(); + } catch (_) {} + _connected = false; + debugPrint('[BLE Prov] 已断开'); + } + + /// 释放资源 + void dispose() { + disconnect(); + _wifiListController.close(); + _wifiStatusController.close(); + _disconnectController.close(); + } + + /// 写入数据到 Write 特征 + Future _write(List data) async { + if (_writeChar == null) { + debugPrint('[BLE Prov] writeChar 未就绪'); + return; + } + await _writeChar!.write(data, withoutResponse: false); + } + + /// 处理 Notify 数据 + void _handleNotify(List data) { + if (data.isEmpty) return; + final cmd = data[0]; + debugPrint('[BLE Prov] 收到通知: cmd=0x${cmd.toRadixString(16)}, len=${data.length}'); + + switch (cmd) { + case _ProvResp.wifiList: + _handleWifiListEntry(data); + break; + case _ProvResp.wifiListEnd: + _handleWifiListEnd(); + break; + case _ProvResp.wifiStatus: + _handleWifiStatus(data); + break; + case _ProvResp.customData: + _handleCustomData(data); + break; + } + } + + /// 解析单条 WiFi 列表: [0x82][RSSI][SSID_LEN][SSID...] + void _handleWifiListEntry(List data) { + if (data.length < 4) return; + final rssi = data[1].toSigned(8); // signed byte + final ssidLen = data[2]; + if (data.length < 3 + ssidLen) return; + final ssid = utf8.decode(data.sublist(3, 3 + ssidLen), allowMalformed: true); + if (ssid.isNotEmpty) { + _pendingWifiList.add(ScannedWifi(ssid: ssid, rssi: rssi)); + debugPrint('[BLE Prov] WiFi: $ssid (RSSI: $rssi)'); + } + } + + /// WiFi 列表结束 + void _handleWifiListEnd() { + debugPrint('[BLE Prov] WiFi 列表完成,共 ${_pendingWifiList.length} 个'); + // 按信号强度排序 + _pendingWifiList.sort((a, b) => b.rssi.compareTo(a.rssi)); + _wifiListController.add(List.unmodifiable(_pendingWifiList)); + } + + /// WiFi 连接状态: [0x81][success][reason] + void _handleWifiStatus(List data) { + if (data.length < 3) return; + final success = data[1] == 1; + final reason = data[2]; + debugPrint('[BLE Prov] WiFi 状态: success=$success, reason=$reason'); + _wifiStatusController.add(WifiResult( + success: success, + reasonCode: reason, + staMac: _lastStaMac, + )); + } + + String? _lastStaMac; + + /// 自定义数据: [0x84][payload...] 如 "STA_MAC:AA:BB:CC:DD:EE:FF" + void _handleCustomData(List data) { + if (data.length < 2) return; + final payload = String.fromCharCodes(data.sublist(1)); + debugPrint('[BLE Prov] 自定义数据: $payload'); + if (payload.startsWith('STA_MAC:')) { + _lastStaMac = payload.substring(8); + debugPrint('[BLE Prov] 设备 STA MAC: $_lastStaMac'); + } + } +} diff --git a/airhub_app/lib/core/services/phone_auth_service.dart b/airhub_app/lib/core/services/phone_auth_service.dart index cfd1177..30f44a8 100644 --- a/airhub_app/lib/core/services/phone_auth_service.dart +++ b/airhub_app/lib/core/services/phone_auth_service.dart @@ -1,10 +1,9 @@ import 'dart:async'; -import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/foundation.dart' show debugPrint, kIsWeb; import 'package:riverpod_annotation/riverpod_annotation.dart'; -// 条件导入:Web 用 stub,原生用真实 ali_auth -import 'phone_auth_service_stub.dart' - if (dart.library.io) 'package:ali_auth/ali_auth.dart'; +// 本地 Web 调试:始终使用 stub(ali_auth 不兼容当前 Dart 版本) +import 'phone_auth_service_stub.dart'; part 'phone_auth_service.g.dart'; @@ -22,12 +21,19 @@ PhoneAuthService phoneAuthService(Ref ref) { class PhoneAuthService { bool _initialized = false; + String? _lastError; + + /// 最近一次错误信息(用于 UI 展示) + String? get lastError => _lastError; /// 初始化 SDK(只需调用一次) Future init() async { + debugPrint('[AliAuth] init() called, _initialized=$_initialized, kIsWeb=$kIsWeb'); if (_initialized) return; - // 真机才初始化,Web 跳过 - if (kIsWeb) return; + if (kIsWeb) { + _lastError = '不支持 Web 平台'; + return; + } try { await AliAuth.initSdk( @@ -40,37 +46,45 @@ class PhoneAuthService { ), ); _initialized = true; + _lastError = null; + debugPrint('[AliAuth] SDK 初始化成功'); } catch (e) { - // SDK 初始化失败不阻塞 App 启动 _initialized = false; + _lastError = 'SDK初始化失败: $e'; + debugPrint('[AliAuth] $_lastError'); } } /// 一键登录,返回阿里云 token(用于发给后端换手机号) /// 返回 null 表示用户取消或认证失败 Future getLoginToken() async { + debugPrint('[AliAuth] getLoginToken() called, _initialized=$_initialized'); if (!_initialized) { await init(); } - if (!_initialized) return null; + if (!_initialized) { + debugPrint('[AliAuth] SDK 未初始化,返回 null, error=$_lastError'); + return null; + } final completer = Completer(); AliAuth.loginListen(onEvent: (event) { + debugPrint('[AliAuth] loginListen event: $event'); final code = event['code'] as String?; if (code == '600000' && event['data'] != null) { - // 成功获取 token if (!completer.isCompleted) { completer.complete(event['data'] as String); } } else if (code == '700000' || code == '700001') { - // 用户取消 + _lastError = '用户取消'; if (!completer.isCompleted) { completer.complete(null); } } else if (code != null && code.startsWith('6') && code != '600000') { - // 其他 6xxxxx 错误码 + _lastError = '错误码$code: ${event['msg']}'; + debugPrint('[AliAuth] $_lastError'); if (!completer.isCompleted) { completer.complete(null); } @@ -79,7 +93,11 @@ class PhoneAuthService { return completer.future.timeout( const Duration(seconds: 30), - onTimeout: () => null, + onTimeout: () { + _lastError = '请求超时(30s)'; + debugPrint('[AliAuth] $_lastError'); + return null; + }, ); } } 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 ab1bda4..bf9e330 100644 --- a/airhub_app/lib/features/auth/presentation/pages/login_page.dart +++ b/airhub_app/lib/features/auth/presentation/pages/login_page.dart @@ -12,6 +12,7 @@ import '../../../../theme/app_colors.dart'; import '../../../../widgets/animated_gradient_background.dart'; import '../../../../widgets/gradient_button.dart'; import '../../../../widgets/ios_toast.dart'; +import '../../../device/presentation/controllers/device_controller.dart'; import '../controllers/auth_controller.dart'; import '../widgets/floating_mascot.dart'; @@ -205,21 +206,25 @@ class _LoginPageState extends ConsumerState { // Logic Methods Future _doOneClickLogin() async { - // 通过阿里云号码认证 SDK 获取 token + debugPrint('[Login] _doOneClickLogin() 开始'); final phoneAuthService = ref.read(phoneAuthServiceProvider); final token = await phoneAuthService.getLoginToken(); + debugPrint('[Login] getLoginToken 返回: $token'); if (token == null) { - if (mounted) _showToast('一键登录取消或失败,请使用验证码登录', isError: true); + final error = phoneAuthService.lastError ?? '未知错误'; + if (mounted) _showToast('一键登录失败: $error', isError: true); return; } if (!mounted) return; final success = await ref.read(authControllerProvider.notifier).tokenLogin(token); + debugPrint('[Login] tokenLogin 结果: $success'); if (success && mounted) { - context.go('/home'); + await _navigateAfterLogin(); } } void _handleOneClickLogin() { + debugPrint('[Login] _handleOneClickLogin() agreed=$_agreed'); if (!_agreed) { _showAgreementDialog(action: 'oneclick'); return; @@ -269,7 +274,25 @@ class _LoginPageState extends ConsumerState { .read(authControllerProvider.notifier) .codeLogin(_phoneController.text, _codeController.text); if (success && mounted) { - context.go('/home'); + 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'); } } diff --git a/airhub_app/lib/pages/bluetooth_page.dart b/airhub_app/lib/pages/bluetooth_page.dart index df17fc8..cde01a0 100644 --- a/airhub_app/lib/pages/bluetooth_page.dart +++ b/airhub_app/lib/pages/bluetooth_page.dart @@ -1,13 +1,19 @@ import 'dart:async'; -import 'dart:math'; +import 'dart:io'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import '../core/services/ble_provisioning_service.dart'; +import '../features/device/data/datasources/device_remote_data_source.dart'; +import '../features/device/presentation/controllers/device_controller.dart'; import '../widgets/animated_gradient_background.dart'; -import '../theme/app_colors.dart'; import '../widgets/gradient_button.dart'; +import '../widgets/glass_dialog.dart'; /// 设备类型 enum DeviceType { plush, badgeAi, badge } @@ -16,14 +22,20 @@ enum DeviceType { plush, badgeAi, badge } class MockDevice { final String sn; final String name; + final String macAddress; final DeviceType type; final bool hasAI; + final bool isNetworkRequired; + final BluetoothDevice? bleDevice; const MockDevice({ required this.sn, required this.name, + required this.macAddress, required this.type, required this.hasAI, + this.isNetworkRequired = true, + this.bleDevice, }); String get iconPath { @@ -50,53 +62,39 @@ class MockDevice { } /// 蓝牙搜索页面 -class BluetoothPage extends StatefulWidget { +class BluetoothPage extends ConsumerStatefulWidget { const BluetoothPage({super.key}); @override - State createState() => _BluetoothPageState(); + ConsumerState createState() => _BluetoothPageState(); } -class _BluetoothPageState extends State +class _BluetoothPageState extends ConsumerState with TickerProviderStateMixin { + /// Airhub 设备名前缀(硬件广播格式: Airhub_ + MAC) + static const _airhubPrefix = 'Airhub_'; + // 状态 bool _isSearching = true; + bool _isBluetoothOn = false; List _devices = []; int _currentIndex = 0; + // 已查询过的 MAC → 设备信息缓存(避免重复调 API) + final Map> _macInfoCache = {}; + // 动画控制器 late AnimationController _searchAnimController; // 滚轮控制器 late FixedExtentScrollController _wheelController; - // 模拟设备数据 - static const List _mockDevices = [ - MockDevice( - sn: 'PLUSH_01', - name: '卡皮巴拉-001', - type: DeviceType.plush, - hasAI: true, - ), - MockDevice( - sn: 'BADGE_01', - name: 'AI电子吧唧-001', - type: DeviceType.badgeAi, - hasAI: true, - ), - MockDevice( - sn: 'BADGE_02', - name: '电子吧唧-001', - type: DeviceType.badge, - hasAI: false, - ), - MockDevice( - sn: 'PLUSH_02', - name: '卡皮巴拉-002', - type: DeviceType.plush, - hasAI: true, - ), - ]; + // 蓝牙订阅 + StreamSubscription? _bluetoothSubscription; + StreamSubscription>? _scanSubscription; + + // 是否已弹过蓝牙关闭提示(避免重复弹窗) + bool _hasShownBluetoothDialog = false; @override void initState() { @@ -111,61 +109,315 @@ class _BluetoothPageState extends State // 滚轮控制器 _wheelController = FixedExtentScrollController(initialItem: _currentIndex); - // 模拟搜索延迟 - _startSearch(); + // 监听蓝牙适配器状态 + _listenBluetoothState(); } @override void dispose() { + _bluetoothSubscription?.cancel(); + _scanSubscription?.cancel(); + FlutterBluePlus.stopScan(); _searchAnimController.dispose(); _wheelController.dispose(); super.dispose(); } - /// 开始搜索 (模拟) + /// 监听蓝牙适配器状态 + void _listenBluetoothState() { + _bluetoothSubscription = FlutterBluePlus.adapterState.listen((state) { + if (!mounted) return; + + final isOn = state == BluetoothAdapterState.on; + setState(() => _isBluetoothOn = isOn); + + if (isOn) { + _startSearch(); + } else if (state == BluetoothAdapterState.off) { + FlutterBluePlus.stopScan(); + setState(() { + _isSearching = false; + _devices.clear(); + }); + if (!_hasShownBluetoothDialog) { + _hasShownBluetoothDialog = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _showBluetoothOffDialog(); + }); + } + } + }); + } + + /// 从设备名中提取 MAC 地址(格式: Airhub_XXXXXXXXXXXX 或 Airhub_XX:XX:XX:XX:XX:XX) + /// 返回标准格式 XX:XX:XX:XX:XX:XX(大写,带冒号),或 null + String? _extractMacFromName(String bleName) { + if (!bleName.startsWith(_airhubPrefix)) return null; + final rawMac = bleName.substring(_airhubPrefix.length).trim(); + if (rawMac.isEmpty) return null; + + // 移除冒号/横杠,统一处理 + final hex = rawMac.replaceAll(RegExp(r'[:\-]'), '').toUpperCase(); + if (hex.length != 12 || !RegExp(r'^[0-9A-F]{12}$').hasMatch(hex)) { + debugPrint('[BLE Scan] MAC 格式异常: $rawMac'); + return null; + } + + // 转为 XX:XX:XX:XX:XX:XX + return '${hex.substring(0, 2)}:${hex.substring(2, 4)}:${hex.substring(4, 6)}:' + '${hex.substring(6, 8)}:${hex.substring(8, 10)}:${hex.substring(10, 12)}'; + } + + // 暂存扫描到但尚未完成 API 查询的 Airhub 设备 BLE 句柄 + final Map _pendingBleDevices = {}; + + /// 开始 BLE 扫描(持续扫描,直到找到设备并完成 API 查询) Future _startSearch() async { - // 请求蓝牙权限 + if (!_isBluetoothOn) { + _showBluetoothOffDialog(); + return; + } + await _requestPermissions(); - // 模拟 2 秒搜索延迟 - await Future.delayed(const Duration(seconds: 2)); + if (!mounted) return; + setState(() { + _isSearching = true; + _devices.clear(); + _currentIndex = 0; + }); + _pendingBleDevices.clear(); + + _scanSubscription?.cancel(); + _scanSubscription = FlutterBluePlus.onScanResults.listen((results) { + if (!mounted) return; + + for (final r in results) { + final name = r.device.platformName; + if (name.isEmpty) continue; + + final mac = _extractMacFromName(name); + if (mac == null) continue; + + // 记录 BLE 句柄 + _pendingBleDevices[mac] = r.device; + + // 如果没查过这个 MAC,发起 API 查询 + if (!_macInfoCache.containsKey(mac)) { + _macInfoCache[mac] = {}; // 占位,避免重复查询 + _queryDeviceByMac(mac); + } + } + }); + + // 持续扫描(不设超时),由 _queryDeviceByMac 成功后停止 + await FlutterBluePlus.startScan( + timeout: const Duration(seconds: 30), + androidUsesFineLocation: true, + ); + + // 30 秒兜底超时:如果始终没找到设备 + if (mounted && _isSearching) { + setState(() => _isSearching = false); + } + } + + /// 通过 MAC 调用后端 API 查询设备信息 + /// 查询成功后:添加设备到列表、停止扫描、结束搜索状态 + Future _queryDeviceByMac(String mac) async { + try { + final dataSource = ref.read(deviceRemoteDataSourceProvider); + debugPrint('[Bluetooth] queryByMac: $mac'); + final data = await dataSource.queryByMac(mac); + debugPrint('[Bluetooth] queryByMac 返回: $data'); + + if (!mounted) return; + + _macInfoCache[mac] = data; + + final deviceTypeName = data['device_type']?['name'] as String? ?? ''; + final sn = data['sn'] as String? ?? ''; + final isNetworkRequired = data['device_type']?['is_network_required'] as bool? ?? true; + final bleDevice = _pendingBleDevices[mac]; + + // API 返回了有效设备名 → 添加到列表 + final displayName = deviceTypeName.isNotEmpty ? deviceTypeName : 'Airhub 设备'; - if (mounted) { - // 随机选择 1-4 个设备 - final count = Random().nextInt(4) + 1; setState(() { - _devices = _mockDevices.take(count).toList(); + // 避免重复添加 + if (!_devices.any((d) => d.macAddress == mac)) { + _devices.add(MockDevice( + sn: sn, + name: displayName, + macAddress: mac, + type: _inferDeviceType(displayName), + hasAI: _inferHasAI(displayName), + isNetworkRequired: isNetworkRequired, + bleDevice: bleDevice, + )); + } + // 有设备了,结束搜索状态 _isSearching = false; }); + + // 停止扫描 + try { await FlutterBluePlus.stopScan(); } catch (_) {} + + debugPrint('[Bluetooth] 设备已就绪: $mac → $displayName'); + } catch (e) { + debugPrint('[Bluetooth] queryByMac 失败($mac): $e'); + // API 查询失败时,用 BLE 名作为 fallback 也显示出来 + if (!mounted) return; + final bleDevice = _pendingBleDevices[mac]; + setState(() { + if (!_devices.any((d) => d.macAddress == mac)) { + _devices.add(MockDevice( + sn: '', + name: '${_airhubPrefix}设备', + macAddress: mac, + type: DeviceType.plush, + hasAI: true, + bleDevice: bleDevice, + )); + } + _isSearching = false; + }); + try { await FlutterBluePlus.stopScan(); } catch (_) {} } } - /// 请求蓝牙权限(模拟器上可能失败,不影响 mock 搜索) + /// 根据设备名称推断设备类型 + DeviceType _inferDeviceType(String name) { + final lower = name.toLowerCase(); + if (lower.contains('plush') || lower.contains('卡皮巴拉') || lower.contains('机芯') || lower.contains('airhub')) { + return DeviceType.plush; + } + if (lower.contains('ai') || lower.contains('智能')) { + return DeviceType.badgeAi; + } + return DeviceType.badge; + } + + /// 根据设备名称推断是否支持 AI + bool _inferHasAI(String name) { + final lower = name.toLowerCase(); + return lower.contains('ai') || lower.contains('plush') || + lower.contains('卡皮巴拉') || lower.contains('机芯') || lower.contains('智能') || lower.contains('airhub'); + } + + /// 请求蓝牙权限 Future _requestPermissions() async { try { - await Permission.bluetooth.request(); - await Permission.bluetoothScan.request(); - await Permission.bluetoothConnect.request(); - await Permission.location.request(); - } catch (_) { - // 模拟器上蓝牙不可用,忽略权限错误,继续用 mock 数据 + if (Platform.isAndroid) { + // Android 需要位置权限才能扫描 BLE + await Permission.bluetoothScan.request(); + await Permission.bluetoothConnect.request(); + await Permission.location.request(); + } else { + // iOS 只需蓝牙权限,不需要位置 + await Permission.bluetooth.request(); + } + } catch (e) { + debugPrint('[Bluetooth] 权限请求异常: $e'); } } + /// 蓝牙未开启弹窗 + void _showBluetoothOffDialog() { + if (!mounted) return; + showGlassDialog( + context: context, + title: '蓝牙未开启', + description: '请开启蓝牙以搜索附近的设备', + cancelText: '取消', + confirmText: Platform.isAndroid ? '开启蓝牙' : '去设置', + onConfirm: () { + Navigator.of(context).pop(); + if (Platform.isAndroid) { + // Android 可直接请求开启蓝牙 + FlutterBluePlus.turnOn(); + } else { + // iOS 无法直接开启,引导到系统设置 + openAppSettings(); + } + }, + ); + } + + bool _isConnecting = false; + /// 连接设备 - void _handleConnect() { - if (_devices.isEmpty) return; + Future _handleConnect() async { + if (_devices.isEmpty || _isConnecting) return; + + // 检查蓝牙状态 + if (!_isBluetoothOn) { + _showBluetoothOffDialog(); + return; + } final device = _devices[_currentIndex]; - // TODO: 保存设备信息到本地存储 + debugPrint('[Bluetooth] 连接设备: sn=${device.sn}, mac=${device.macAddress}, needsNetwork=${device.isNetworkRequired}'); - if (device.type == DeviceType.badge) { - // 普通吧唧 -> 设备控制页 + if (!device.isNetworkRequired) { + // 不需要联网 -> 直接去设备控制页 context.go('/device-control'); - } else { - // 其他 -> WiFi 配网页 - context.go('/wifi-config'); + return; } + + // Web 环境:跳过 BLE 和 WiFi 配网,直接绑定设备 + if (kIsWeb) { + debugPrint('[Bluetooth] Web 环境,直接绑定 sn=${device.sn}'); + setState(() => _isConnecting = true); + if (device.sn.isNotEmpty) { + await ref.read(deviceControllerProvider.notifier).bindDevice(device.sn); + } + if (!mounted) return; + setState(() => _isConnecting = false); + context.go('/device-control'); + return; + } + + // 需要联网 -> BLE 连接后进入 WiFi 配网 + final bleDevice = device.bleDevice; + if (bleDevice == null) { + debugPrint('[Bluetooth] 无 BLE 句柄,无法连接'); + return; + } + + setState(() => _isConnecting = true); + + // 连接前先停止扫描(iOS 上扫描和连接并发会冲突) + try { + await FlutterBluePlus.stopScan(); + } catch (_) {} + await Future.delayed(const Duration(milliseconds: 300)); + + final provService = BleProvisioningService(); + final ok = await provService.connect(bleDevice); + + if (!mounted) return; + setState(() => _isConnecting = false); + + if (!ok) { + showGlassDialog( + context: context, + title: '连接失败', + description: '无法连接到设备,请确认设备已开机并靠近手机', + confirmText: '确定', + onConfirm: () => Navigator.of(context).pop(), + ); + return; + } + + // BLE 连接成功,跳转 WiFi 配网页并传递 service + context.go('/wifi-config', extra: { + 'provService': provService, + 'sn': device.sn, + 'name': device.name, + 'mac': device.macAddress, + 'type': device.type.name, + }); } @override @@ -564,10 +816,10 @@ class _BluetoothPageState extends State if (!_isSearching && _devices.isNotEmpty) ...[ const SizedBox(width: 16), // HTML: gap 16px GradientButton( - text: '连接设备', + text: _isConnecting ? '连接中...' : '连接设备', width: 180, height: 52, - onPressed: _handleConnect, + onPressed: _isConnecting ? null : _handleConnect, ), ], ], diff --git a/airhub_app/lib/pages/profile/profile_info_page.dart b/airhub_app/lib/pages/profile/profile_info_page.dart index 6facade..7c1a16f 100644 --- a/airhub_app/lib/pages/profile/profile_info_page.dart +++ b/airhub_app/lib/pages/profile/profile_info_page.dart @@ -35,7 +35,12 @@ class _ProfileInfoPageState extends ConsumerState { super.dispose(); } - static const _genderToDisplay = {'male': '男', 'female': '女'}; + static const _genderToDisplay = { + 'male': '男', + 'female': '女', + 'M': '男', + 'F': '女', + }; static const _displayToGender = {'男': 'male', '女': 'female'}; void _initFromUser() { diff --git a/airhub_app/lib/pages/wifi_config_page.dart b/airhub_app/lib/pages/wifi_config_page.dart index 6fc01d7..c4f4cc7 100644 --- a/airhub_app/lib/pages/wifi_config_page.dart +++ b/airhub_app/lib/pages/wifi_config_page.dart @@ -4,12 +4,15 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:flutter_svg/flutter_svg.dart'; +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 '../features/device/presentation/controllers/device_controller.dart'; class WifiConfigPage extends ConsumerStatefulWidget { - const WifiConfigPage({super.key}); + final Map? extra; + + const WifiConfigPage({super.key, this.extra}); @override ConsumerState createState() => _WifiConfigPageState(); @@ -25,36 +28,112 @@ class _WifiConfigPageState extends ConsumerState // Progress State double _progress = 0.0; String _progressText = '正在连接WiFi...'; + bool _connectFailed = false; - // Device Info (Mock or from Route Args) - // We'll try to get it from arguments, default to a fallback + // Device Info Map _deviceInfo = {}; - // Mock WiFi List - final List> _wifiList = [ - {'ssid': 'Home_5G', 'level': 4}, - {'ssid': 'Office_WiFi', 'level': 3}, - {'ssid': 'Guest_Network', 'level': 2}, - ]; + // BLE Provisioning + BleProvisioningService? _provService; + List _wifiList = []; + bool _isScanning = false; + + // Subscriptions + StreamSubscription? _wifiListSub; + StreamSubscription? _wifiStatusSub; + StreamSubscription? _disconnectSub; @override - void didChangeDependencies() { - super.didChangeDependencies(); - // Retrieve device info from arguments - final args = ModalRoute.of(context)?.settings.arguments; - if (args is Map) { - _deviceInfo = args; + void initState() { + super.initState(); + _deviceInfo = widget.extra ?? {}; + _provService = _deviceInfo['provService'] as BleProvisioningService?; + + if (_provService != null) { + _setupBleListeners(); + // 自动开始 WiFi 扫描 + _requestWifiScan(); } } - void _handleNext() { + @override + void dispose() { + _wifiListSub?.cancel(); + _wifiStatusSub?.cancel(); + _disconnectSub?.cancel(); + _provService?.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + void _setupBleListeners() { + // 监听 WiFi 列表 + _wifiListSub = _provService!.onWifiList.listen((list) { + if (!mounted) return; + debugPrint('[WiFi Config] 收到 WiFi 列表: ${list.length} 个'); + setState(() { + _wifiList = list; + _isScanning = false; + }); + }); + + // 监听 WiFi 连接状态 + _wifiStatusSub = _provService!.onWifiStatus.listen((result) { + if (!mounted) return; + debugPrint('[WiFi Config] WiFi 状态: success=${result.success}, reason=${result.reasonCode}'); + if (result.success) { + setState(() { + _progress = 1.0; + _progressText = '配网成功!'; + _currentStep = 4; + }); + } else { + setState(() { + _connectFailed = true; + _progressText = '连接失败 (错误码: ${result.reasonCode})'; + }); + } + }); + + // 监听 BLE 断开 + _disconnectSub = _provService!.onDisconnect.listen((_) { + if (!mounted) return; + debugPrint('[WiFi Config] BLE 连接已断开'); + // 如果在配网中断开,可能是成功后设备重启 + if (_currentStep == 3 && !_connectFailed) { + setState(() { + _progress = 1.0; + _progressText = '设备正在重启...'; + _currentStep = 4; + }); + } + }); + } + + Future _requestWifiScan() async { + if (_provService == null) return; + setState(() => _isScanning = true); + await _provService!.requestWifiScan(); + // WiFi 列表会通过 onWifiList stream 回调 + // 设置超时:10 秒后如果还没收到列表,停止加载 + Future.delayed(const Duration(seconds: 10), () { + if (mounted && _isScanning) { + setState(() => _isScanning = false); + } + }); + } + + Future _handleNext() async { if (_currentStep == 1 && _selectedWifiSsid.isEmpty) return; if (_currentStep == 2 && _passwordController.text.isEmpty) return; if (_currentStep == 4) { - // Navigate to Device Control - // Use pushNamedAndRemoveUntil to remove Bluetooth and WiFi pages from stack - // but keep Home page so back button goes to Home + final sn = _deviceInfo['sn'] as String? ?? ''; + if (sn.isNotEmpty) { + debugPrint('[WiFi Config] Binding device sn=$sn'); + await ref.read(deviceControllerProvider.notifier).bindDevice(sn); + } + if (!mounted) return; context.go('/device-control'); return; } @@ -72,13 +151,58 @@ class _WifiConfigPageState extends ConsumerState if (_currentStep > 1) { setState(() { _currentStep--; + if (_currentStep == 1) { + _connectFailed = false; + _progress = 0.0; + } }); } else { - context.go('/home'); + _provService?.disconnect(); + context.go('/bluetooth'); } } - void _startConnecting() { + Future _startConnecting() async { + setState(() { + _progress = 0.1; + _progressText = '正在发送WiFi信息...'; + _connectFailed = false; + }); + + if (_provService != null && _provService!.isConnected) { + // 通过 BLE 发送 WiFi 凭证 + setState(() { + _progress = 0.3; + _progressText = '正在发送WiFi凭证...'; + }); + + await _provService!.sendWifiCredentials( + _selectedWifiSsid, + _passwordController.text, + ); + + setState(() { + _progress = 0.5; + _progressText = '等待设备连接WiFi...'; + }); + + // WiFi 状态会通过 onWifiStatus stream 回调 + // 设置超时:60 秒后如果还没收到结果 + Future.delayed(const Duration(seconds: 60), () { + if (mounted && _currentStep == 3 && !_connectFailed) { + setState(() { + _connectFailed = true; + _progressText = '连接超时,请重试'; + }); + } + }); + } else { + // 无 BLE 连接(模拟模式),使用 mock 流程 + _startMockConnecting(); + } + } + + void _startMockConnecting() { const steps = [ {'progress': 0.3, 'text': '正在连接WiFi...'}, {'progress': 0.6, 'text': '正在验证密码...'}, @@ -98,27 +222,13 @@ class _WifiConfigPageState extends ConsumerState stepIndex++; } else { timer.cancel(); - // Record WiFi config on server - _recordWifiConfig(); if (mounted) { - setState(() { - _currentStep = 4; - }); + setState(() => _currentStep = 4); } } }); } - Future _recordWifiConfig() async { - final userDeviceId = _deviceInfo['userDeviceId'] as int?; - if (userDeviceId != null && _selectedWifiSsid.isNotEmpty) { - final controller = ref.read( - deviceDetailControllerProvider(userDeviceId).notifier, - ); - await controller.configWifi(_selectedWifiSsid); - } - } - @override Widget build(BuildContext context) { return Scaffold( @@ -126,34 +236,24 @@ class _WifiConfigPageState extends ConsumerState resizeToAvoidBottomInset: true, body: Stack( children: [ - // Background - _buildGradientBackground(), - + const AnimatedGradientBackground(), Positioned.fill( child: SafeArea( child: Column( children: [ - // Header _buildHeader(), - - // Content Expanded( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 32), child: Column( children: [ - // Steps Indicator _buildStepIndicator(), const SizedBox(height: 32), - - // Dynamic Step Content _buildCurrentStepContent(), ], ), ), ), - - // Footer _buildFooter(), ], ), @@ -164,17 +264,11 @@ class _WifiConfigPageState extends ConsumerState ); } - // Common Gradient Background - Widget _buildGradientBackground() { - return const AnimatedGradientBackground(); - } - Widget _buildHeader() { return Container( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), child: Row( children: [ - // Back button - HTML: bg rgba(255,255,255,0.6), border-radius: 12px, color #4B5563 GestureDetector( onTap: _handleBack, child: Container( @@ -187,7 +281,7 @@ class _WifiConfigPageState extends ConsumerState child: const Icon( Icons.arrow_back_ios_new, size: 18, - color: Color(0xFF4B5563), // Gray per HTML, not purple + color: Color(0xFF4B5563), ), ), ), @@ -202,7 +296,7 @@ class _WifiConfigPageState extends ConsumerState ), ), ), - const SizedBox(width: 48), // Balance back button + const SizedBox(width: 48), ], ), ); @@ -223,10 +317,10 @@ class _WifiConfigPageState extends ConsumerState margin: const EdgeInsets.symmetric(horizontal: 4), decoration: BoxDecoration( color: isCompleted - ? const Color(0xFF22C55E) // Green for completed + ? const Color(0xFF22C55E) : isActive - ? const Color(0xFF8B5CF6) // Purple for active - : const Color(0xFF8B5CF6).withOpacity(0.3), // Faded purple + ? const Color(0xFF8B5CF6) + : const Color(0xFF8B5CF6).withOpacity(0.3), borderRadius: BorderRadius.circular(4), ), ); @@ -249,11 +343,10 @@ class _WifiConfigPageState extends ConsumerState } } - // Step 1: Select Network + // Step 1: 选择 WiFi 网络 Widget _buildStep1() { return Column( children: [ - // Icon Container( margin: const EdgeInsets.only(bottom: 24), child: const Icon(Icons.wifi, size: 80, color: Color(0xFF8B5CF6)), @@ -269,27 +362,74 @@ class _WifiConfigPageState extends ConsumerState const SizedBox(height: 8), const Text( '设备需要连接WiFi以使用AI功能', - style: TextStyle( - - fontSize: 14, - color: Color(0xFF6B7280), - ), + style: TextStyle(fontSize: 14, color: Color(0xFF6B7280)), ), const SizedBox(height: 24), - // List - Column( - children: _wifiList.map((wifi) => _buildWifiItem(wifi)).toList(), - ), + if (_isScanning) + const Padding( + padding: EdgeInsets.symmetric(vertical: 40), + child: Column( + children: [ + CircularProgressIndicator(color: Color(0xFF8B5CF6)), + SizedBox(height: 16), + Text( + '正在通过设备扫描WiFi...', + style: TextStyle(fontSize: 14, color: Color(0xFF6B7280)), + ), + ], + ), + ) + else if (_wifiList.isEmpty) + Column( + children: [ + const Padding( + padding: EdgeInsets.symmetric(vertical: 40), + child: Text( + '未扫描到WiFi网络', + style: TextStyle(fontSize: 14, color: Color(0xFF9CA3AF)), + ), + ), + GestureDetector( + onTap: _requestWifiScan, + child: const Text( + '重新扫描', + style: TextStyle( + fontSize: 14, + color: Color(0xFF8B5CF6), + decoration: TextDecoration.underline, + ), + ), + ), + ], + ) + else + Column( + children: [ + ..._wifiList.map((wifi) => _buildWifiItem(wifi)), + const SizedBox(height: 8), + GestureDetector( + onTap: _requestWifiScan, + child: const Text( + '重新扫描', + style: TextStyle( + fontSize: 14, + color: Color(0xFF8B5CF6), + decoration: TextDecoration.underline, + ), + ), + ), + ], + ), ], ); } - Widget _buildWifiItem(Map wifi) { - bool isSelected = _selectedWifiSsid == wifi['ssid']; + Widget _buildWifiItem(ScannedWifi wifi) { + bool isSelected = _selectedWifiSsid == wifi.ssid; return GestureDetector( onTap: () { - setState(() => _selectedWifiSsid = wifi['ssid']); + setState(() => _selectedWifiSsid = wifi.ssid); }, child: Container( padding: const EdgeInsets.all(16), @@ -317,27 +457,23 @@ class _WifiConfigPageState extends ConsumerState children: [ Expanded( child: Text( - wifi['ssid'], + wifi.ssid, style: const TextStyle( - fontSize: 16, fontWeight: FontWeight.w500, color: Color(0xFF1F2937), ), ), ), - // HTML uses per-level SVG icons: wifi-1.svg to wifi-4.svg - Opacity( - opacity: 0.8, - child: SvgPicture.asset( - 'assets/www/icons/wifi-${wifi['level']}.svg', - width: 24, - height: 24, - colorFilter: const ColorFilter.mode( - Color(0xFF6B7280), - BlendMode.srcIn, - ), - ), + // WiFi 信号图标 + Icon( + wifi.level >= 3 + ? Icons.wifi + : wifi.level == 2 + ? Icons.wifi_2_bar + : Icons.wifi_1_bar, + size: 24, + color: const Color(0xFF6B7280), ), ], ), @@ -345,7 +481,7 @@ class _WifiConfigPageState extends ConsumerState ); } - // Step 2: Enter Password + // Step 2: 输入密码 Widget _buildStep2() { return Column( children: [ @@ -366,16 +502,11 @@ class _WifiConfigPageState extends ConsumerState ), ), const SizedBox(height: 8), - Text( + const Text( '请输入WiFi密码', - style: TextStyle( - - fontSize: 14, - color: const Color(0xFF6B7280), - ), + style: TextStyle(fontSize: 14, color: Color(0xFF6B7280)), ), const SizedBox(height: 24), - TextField( controller: _passwordController, obscureText: _obscurePassword, @@ -398,9 +529,7 @@ class _WifiConfigPageState extends ConsumerState size: 22, ), onPressed: () { - setState(() { - _obscurePassword = !_obscurePassword; - }); + setState(() => _obscurePassword = !_obscurePassword); }, ), ), @@ -411,11 +540,10 @@ class _WifiConfigPageState extends ConsumerState ); } - // Step 3: Connecting + // Step 3: 正在连接 Widget _buildStep3() { return Column( children: [ - // Animation placeholder (using Icon for now, can be upgraded to Wave animation) SizedBox( height: 120, child: Center( @@ -429,8 +557,7 @@ class _WifiConfigPageState extends ConsumerState color: const Color(0xFF8B5CF6).withOpacity(1 - value * 0.5), ); }, - onEnd: - () {}, // Repeat logic usually handled by AnimationController + onEnd: () {}, ), ), ), @@ -443,8 +570,6 @@ class _WifiConfigPageState extends ConsumerState ), ), const SizedBox(height: 32), - - // Progress Bar ClipRRect( borderRadius: BorderRadius.circular(3), child: SizedBox( @@ -452,7 +577,9 @@ class _WifiConfigPageState extends ConsumerState child: LinearProgressIndicator( value: _progress, backgroundColor: const Color(0xFF8B5CF6).withOpacity(0.2), - valueColor: const AlwaysStoppedAnimation(Color(0xFF8B5CF6)), + valueColor: AlwaysStoppedAnimation( + _connectFailed ? const Color(0xFFEF4444) : const Color(0xFF8B5CF6), + ), ), ), ), @@ -460,25 +587,42 @@ class _WifiConfigPageState extends ConsumerState Text( _progressText, style: TextStyle( - fontSize: 14, - color: const Color(0xFF6B7280), + color: _connectFailed ? const Color(0xFFEF4444) : const Color(0xFF6B7280), ), ), + if (_connectFailed) ...[ + const SizedBox(height: 24), + GestureDetector( + onTap: () { + setState(() { + _currentStep = 1; + _connectFailed = false; + _progress = 0.0; + }); + }, + child: const Text( + '返回重新选择', + style: TextStyle( + fontSize: 14, + color: Color(0xFF8B5CF6), + decoration: TextDecoration.underline, + ), + ), + ), + ], ], ); } - // Get device icon path based on device type String _getDeviceIconPath() { final type = _deviceInfo['type'] as String? ?? 'plush'; switch (type) { case 'plush_core': case 'plush': return 'assets/www/icons/pixel-capybara.svg'; - case 'badge_ai': + case 'badgeAi': return 'assets/www/icons/pixel-badge-ai.svg'; - case 'badge_basic': case 'badge': return 'assets/www/icons/pixel-badge-basic.svg'; default: @@ -486,17 +630,15 @@ class _WifiConfigPageState extends ConsumerState } } - // Step 4: Result (Success) - centered vertically + // Step 4: 配网成功 Widget _buildStep4() { return Column( children: [ const SizedBox(height: 80), - // Success Icon Stack - HTML: no white background Stack( clipBehavior: Clip.none, alignment: Alignment.center, children: [ - // Device icon container - 120x120 per HTML SizedBox( width: 120, height: 120, @@ -511,7 +653,6 @@ class _WifiConfigPageState extends ConsumerState ), ), ), - // Check badge Positioned( bottom: -5, right: -5, @@ -545,13 +686,9 @@ class _WifiConfigPageState extends ConsumerState ), ), const SizedBox(height: 8), - Text( + const Text( '设备已成功连接到网络', - style: TextStyle( - - fontSize: 14, - color: const Color(0xFF6B7280), - ), + style: TextStyle(fontSize: 14, color: Color(0xFF6B7280)), ), ], ); @@ -572,24 +709,24 @@ class _WifiConfigPageState extends ConsumerState } if (!showNext && _currentStep != 3) { - // Show cancel only? return Padding( padding: const EdgeInsets.all(32), child: TextButton( - onPressed: () => context.go('/bluetooth'), - child: Text( + onPressed: () { + _provService?.disconnect(); + context.go('/bluetooth'); + }, + child: const Text( '取消', - style: TextStyle( - - color: const Color(0xFF6B7280), - ), + style: TextStyle(color: Color(0xFF6B7280)), ), ), ); } - if (_currentStep == 3) - return const SizedBox(height: 100); // Hide buttons during connection + if (_currentStep == 3) { + return const SizedBox(height: 100); + } return Container( padding: EdgeInsets.fromLTRB( @@ -601,7 +738,6 @@ class _WifiConfigPageState extends ConsumerState child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - // Cancel button - frosted glass style if (_currentStep < 4) GestureDetector( onTap: _handleBack, @@ -626,8 +762,6 @@ class _WifiConfigPageState extends ConsumerState ), ), if (_currentStep < 4) const SizedBox(width: 16), - - // Constrained button (not full-width) GradientButton( text: nextText, onPressed: _handleNext, diff --git a/airhub_app/packages/ali_auth/android/src/main/kotlin/com/sean/rao/ali_auth/AliAuthPlugin.kt b/airhub_app/packages/ali_auth/android/src/main/kotlin/com/sean/rao/ali_auth/AliAuthPlugin.kt new file mode 100644 index 0000000..5db34de --- /dev/null +++ b/airhub_app/packages/ali_auth/android/src/main/kotlin/com/sean/rao/ali_auth/AliAuthPlugin.kt @@ -0,0 +1,22 @@ +package com.sean.rao.ali_auth + +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +class AliAuthPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { + private lateinit var channel: MethodChannel + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel = MethodChannel(binding.binaryMessenger, "ali_auth") + channel.setMethodCallHandler(this) + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + result.notImplemented() + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + } +} diff --git a/airhub_app/packages/ali_auth/ios/Classes/AliAuthPlugin.swift b/airhub_app/packages/ali_auth/ios/Classes/AliAuthPlugin.swift new file mode 100644 index 0000000..fb965bd --- /dev/null +++ b/airhub_app/packages/ali_auth/ios/Classes/AliAuthPlugin.swift @@ -0,0 +1,13 @@ +import Flutter + +public class AliAuthPlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "ali_auth", binaryMessenger: registrar.messenger()) + let instance = AliAuthPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + result(FlutterMethodNotImplemented) + } +} diff --git a/airhub_app/packages/ali_auth/ios/ali_auth.podspec b/airhub_app/packages/ali_auth/ios/ali_auth.podspec new file mode 100644 index 0000000..537f534 --- /dev/null +++ b/airhub_app/packages/ali_auth/ios/ali_auth.podspec @@ -0,0 +1,13 @@ +Pod::Spec.new do |s| + s.name = 'ali_auth' + s.version = '1.3.7' + s.summary = 'Alibaba Cloud phone auth plugin for Flutter.' + s.homepage = 'https://github.com/CodeGather/flutter_ali_auth' + s.license = { :type => 'MIT' } + s.author = { 'sean' => 'author@example.com' } + s.source = { :http => 'https://github.com/CodeGather/flutter_ali_auth' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.platform = :ios, '13.0' + s.swift_version = '5.0' +end diff --git a/airhub_app/pubspec.lock b/airhub_app/pubspec.lock index 72b06ee..ced9ab7 100644 --- a/airhub_app/pubspec.lock +++ b/airhub_app/pubspec.lock @@ -9,13 +9,6 @@ packages: url: "https://pub.dev" source: hosted version: "91.0.0" - ali_auth: - dependency: "direct main" - description: - path: "packages/ali_auth" - relative: true - source: path - version: "1.3.7" analyzer: dependency: transitive description: diff --git a/airhub_app/pubspec.yaml b/airhub_app/pubspec.yaml index e62c8c5..8ddadd7 100644 --- a/airhub_app/pubspec.yaml +++ b/airhub_app/pubspec.yaml @@ -54,8 +54,8 @@ dependencies: dio: ^5.7.0 shared_preferences: ^2.3.0 - # Aliyun Phone Auth (一键登录) - ali_auth: ^1.3.7 + # Aliyun Phone Auth (一键登录) — 本地 Web 调试时禁用 + # ali_auth: ^1.3.7 # Existing dependencies webview_flutter: ^4.4.2 @@ -66,10 +66,6 @@ dependencies: image_picker: ^1.2.1 just_audio: ^0.9.42 -dependency_overrides: - ali_auth: - path: packages/ali_auth - flutter: uses-material-design: true assets: diff --git a/本地localhost运行.md b/本地localhost运行.md new file mode 100644 index 0000000..ff854a9 --- /dev/null +++ b/本地localhost运行.md @@ -0,0 +1,130 @@ +# Flutter Web 本地调试启动指南 + +> 本文档供 AI 编码助手阅读,用于在本项目中正确启动 Flutter Web 调试环境。 + +## 项目结构 + +- Flutter 应用目录:`airhub_app/` +- 后端服务入口:`server.py`(根目录,FastAPI + Uvicorn,端口 3000) +- 前端端口:`8080` + +## 环境要求 + +- Flutter SDK(3.x) +- Python 3.x(后端服务) +- PowerShell(Windows 环境) + +## 操作系统 + +Windows(所有命令均为 PowerShell 语法) + +--- + +## 启动流程(严格按顺序执行) + +### 1. 杀掉旧进程并确认端口空闲 + +```powershell +# 杀掉占用 8080 和 3000 的旧进程 +Get-NetTCPConnection -LocalPort 8080 -ErrorAction SilentlyContinue | ForEach-Object { taskkill /F /PID $_.OwningProcess 2>$null } +Get-NetTCPConnection -LocalPort 3000 -ErrorAction SilentlyContinue | ForEach-Object { taskkill /F /PID $_.OwningProcess 2>$null } + +# 等待端口释放 +Start-Sleep -Seconds 3 + +# 确认端口已空闲(无输出 = 空闲) +Get-NetTCPConnection -LocalPort 8080 -ErrorAction SilentlyContinue +Get-NetTCPConnection -LocalPort 3000 -ErrorAction SilentlyContinue +``` + +### 2. 启动后端服务器(音乐生成功能依赖此服务) + +```powershell +# 工作目录:项目根目录 +cd d:\Airhub +python server.py +``` + +成功标志: +``` +INFO: Uvicorn running on http://0.0.0.0:3000 (Press CTRL+C to quit) +[Server] Music Server running on http://localhost:3000 +``` + +### 3. 设置国内镜像源 + 启动 Flutter Web Server + +```powershell +# 工作目录:airhub_app 子目录 +cd d:\Airhub\airhub_app + +# 设置镜像源(必须,否则网络超时) +$env:PUB_HOSTED_URL = "https://pub.flutter-io.cn" +$env:FLUTTER_STORAGE_BASE_URL = "https://storage.flutter-io.cn" + +# 启动 web-server 模式 +flutter run -d web-server --web-port=8080 --no-pub +``` + +成功标志: +``` +lib\main.dart is being served at http://localhost:8080 +``` + +### 4. 访问应用 + +浏览器打开:`http://localhost:8080` + +--- + +## 关键规则 + +### 必须使用 `web-server` 模式 +- **禁止**使用 `flutter run -d chrome`(会弹出系统 Chrome 窗口,不可控) +- **必须**使用 `flutter run -d web-server`(只启动 HTTP 服务,手动用浏览器访问) + +### `--no-pub` 的使用条件 +- 仅修改 Dart 代码(无新依赖、无新 asset)→ 加 `--no-pub`,编译更快 +- 新增了 `pubspec.yaml` 依赖或 `assets/` 资源文件 → **不能**加 `--no-pub` + +### 端口管理 +- 固定使用 8080(Flutter)和 3000(后端),不要换端口绕过占用 +- 每次启动前必须先确认端口空闲 +- 停止服务后等 3 秒再重新启动 + +### 热重载 +- 在 Flutter 终端按 `r` = 热重载(保留页面状态) +- 按 `R` = 热重启(重置页面状态) +- 浏览器 `Ctrl+Shift+R` = 强制刷新 + +--- + +## 停止服务 + +```powershell +# 方法1:在 Flutter 终端按 q 退出 + +# 方法2:强制杀进程 +Get-NetTCPConnection -LocalPort 8080 | ForEach-Object { taskkill /F /PID $_.OwningProcess } +Get-NetTCPConnection -LocalPort 3000 | ForEach-Object { taskkill /F /PID $_.OwningProcess } +``` + +--- + +## 常见问题排查 + +| 问题 | 原因 | 解决方案 | +|------|------|---------| +| 端口被占用 | 旧进程未退出 | 执行第1步杀进程,等3秒 | +| 编译报错找不到包 | 使用了 `--no-pub` 但有新依赖 | 去掉 `--no-pub` 重新编译 | +| 网络超时 | 未设置镜像源 | 设置 `PUB_HOSTED_URL` 和 `FLUTTER_STORAGE_BASE_URL` | +| 页面白屏 | 缓存问题 | 浏览器 `Ctrl+Shift+R` 强刷 | +| 音乐功能不工作 | 后端未启动 | 先启动 `python server.py` | + +--- + +## 编译耗时参考 + +- 首次完整编译(含 pub get):90-120 秒 +- 增量编译(`--no-pub`):60-90 秒 +- 热重载(按 r):3-5 秒 +- 热重启(按 R):10-20 秒