fix wify 配网
This commit is contained in:
parent
8ed21ca4a4
commit
4983553261
@ -1,4 +1,11 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Bluetooth permissions -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
|
||||
<application
|
||||
android:label="airhub_app"
|
||||
android:name="${applicationName}"
|
||||
|
||||
@ -1,40 +1,79 @@
|
||||
PODS:
|
||||
- ali_auth (1.3.7):
|
||||
- Flutter
|
||||
- MJExtension
|
||||
- SDWebImage
|
||||
- audio_session (0.0.1):
|
||||
- Flutter
|
||||
- Flutter (1.0.0)
|
||||
- flutter_blue_plus_darwin (0.0.2):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- image_picker_ios (0.0.1):
|
||||
- Flutter
|
||||
- just_audio (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- MJExtension (3.4.2)
|
||||
- permission_handler_apple (9.3.0):
|
||||
- Flutter
|
||||
- SDWebImage (5.21.6):
|
||||
- SDWebImage/Core (= 5.21.6)
|
||||
- SDWebImage/Core (5.21.6)
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- webview_flutter_wkwebview (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
|
||||
DEPENDENCIES:
|
||||
- ali_auth (from `.symlinks/plugins/ali_auth/ios`)
|
||||
- audio_session (from `.symlinks/plugins/audio_session/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- just_audio (from `.symlinks/plugins/just_audio/darwin`)
|
||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- MJExtension
|
||||
- SDWebImage
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
ali_auth:
|
||||
:path: ".symlinks/plugins/ali_auth/ios"
|
||||
audio_session:
|
||||
:path: ".symlinks/plugins/audio_session/ios"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
flutter_blue_plus_darwin:
|
||||
:path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin"
|
||||
image_picker_ios:
|
||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||
just_audio:
|
||||
:path: ".symlinks/plugins/just_audio/darwin"
|
||||
permission_handler_apple:
|
||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||
shared_preferences_foundation:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||
webview_flutter_wkwebview:
|
||||
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
ali_auth: fe9a6188a90eb39227f3674c05a71383ac4ec6a2
|
||||
audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
|
||||
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
||||
just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed
|
||||
MJExtension: e97d164cb411aa9795cf576093a1fa208b4a8dd8
|
||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||
SDWebImage: 1bb6a1b84b6fe87b972a102bdc77dd589df33477
|
||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
|
||||
|
||||
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
||||
|
||||
@ -45,6 +45,12 @@
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>需要蓝牙权限来搜索和连接您的设备</string>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
<string>需要蓝牙权限来搜索和连接您的设备</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>需要位置权限以扫描附近的蓝牙设备</string>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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<String, dynamic>?,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/device-control',
|
||||
|
||||
282
airhub_app/lib/core/services/ble_provisioning_service.dart
Normal file
282
airhub_app/lib/core/services/ble_provisioning_service.dart
Normal file
@ -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<List<ScannedWifi>>.broadcast();
|
||||
Stream<List<ScannedWifi>> get onWifiList => _wifiListController.stream;
|
||||
|
||||
/// 用于传递 WiFi 连接状态
|
||||
final _wifiStatusController = StreamController<WifiResult>.broadcast();
|
||||
Stream<WifiResult> get onWifiStatus => _wifiStatusController.stream;
|
||||
|
||||
/// 用于传递连接断开事件
|
||||
final _disconnectController = StreamController<void>.broadcast();
|
||||
Stream<void> get onDisconnect => _disconnectController.stream;
|
||||
|
||||
/// 临时存储 WiFi 列表条目
|
||||
List<ScannedWifi> _pendingWifiList = [];
|
||||
|
||||
/// 连接到 BLE 设备并发现配网服务
|
||||
Future<bool> 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<void> requestWifiScan() async {
|
||||
_pendingWifiList = [];
|
||||
await _write([_ProvCmd.getWifiList]);
|
||||
debugPrint('[BLE Prov] 已发送 WiFi 扫描命令');
|
||||
}
|
||||
|
||||
/// 发送 WiFi 凭证并触发连接
|
||||
Future<void> 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<void> 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<void> _write(List<int> data) async {
|
||||
if (_writeChar == null) {
|
||||
debugPrint('[BLE Prov] writeChar 未就绪');
|
||||
return;
|
||||
}
|
||||
await _writeChar!.write(data, withoutResponse: false);
|
||||
}
|
||||
|
||||
/// 处理 Notify 数据
|
||||
void _handleNotify(List<int> 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<int> 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<int> 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<int> 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<void> 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<String?> 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<String?>();
|
||||
|
||||
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;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<LoginPage> {
|
||||
|
||||
// Logic Methods
|
||||
Future<void> _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<LoginPage> {
|
||||
.read(authControllerProvider.notifier)
|
||||
.codeLogin(_phoneController.text, _codeController.text);
|
||||
if (success && mounted) {
|
||||
context.go('/home');
|
||||
await _navigateAfterLogin();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<BluetoothPage> createState() => _BluetoothPageState();
|
||||
ConsumerState<BluetoothPage> createState() => _BluetoothPageState();
|
||||
}
|
||||
|
||||
class _BluetoothPageState extends State<BluetoothPage>
|
||||
class _BluetoothPageState extends ConsumerState<BluetoothPage>
|
||||
with TickerProviderStateMixin {
|
||||
/// Airhub 设备名前缀(硬件广播格式: Airhub_ + MAC)
|
||||
static const _airhubPrefix = 'Airhub_';
|
||||
|
||||
// 状态
|
||||
bool _isSearching = true;
|
||||
bool _isBluetoothOn = false;
|
||||
List<MockDevice> _devices = [];
|
||||
int _currentIndex = 0;
|
||||
|
||||
// 已查询过的 MAC → 设备信息缓存(避免重复调 API)
|
||||
final Map<String, Map<String, dynamic>> _macInfoCache = {};
|
||||
|
||||
// 动画控制器
|
||||
late AnimationController _searchAnimController;
|
||||
|
||||
// 滚轮控制器
|
||||
late FixedExtentScrollController _wheelController;
|
||||
|
||||
// 模拟设备数据
|
||||
static const List<MockDevice> _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<BluetoothAdapterState>? _bluetoothSubscription;
|
||||
StreamSubscription<List<ScanResult>>? _scanSubscription;
|
||||
|
||||
// 是否已弹过蓝牙关闭提示(避免重复弹窗)
|
||||
bool _hasShownBluetoothDialog = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -111,61 +109,315 @@ class _BluetoothPageState extends State<BluetoothPage>
|
||||
// 滚轮控制器
|
||||
_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<String, BluetoothDevice> _pendingBleDevices = {};
|
||||
|
||||
/// 开始 BLE 扫描(持续扫描,直到找到设备并完成 API 查询)
|
||||
Future<void> _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<void> _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<void> _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<void> _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<BluetoothPage>
|
||||
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,
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@ -35,7 +35,12 @@ class _ProfileInfoPageState extends ConsumerState<ProfileInfoPage> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
static const _genderToDisplay = {'male': '男', 'female': '女'};
|
||||
static const _genderToDisplay = {
|
||||
'male': '男',
|
||||
'female': '女',
|
||||
'M': '男',
|
||||
'F': '女',
|
||||
};
|
||||
static const _displayToGender = {'男': 'male', '女': 'female'};
|
||||
|
||||
void _initFromUser() {
|
||||
|
||||
@ -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<String, dynamic>? extra;
|
||||
|
||||
const WifiConfigPage({super.key, this.extra});
|
||||
|
||||
@override
|
||||
ConsumerState<WifiConfigPage> createState() => _WifiConfigPageState();
|
||||
@ -25,36 +28,112 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
||||
// 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<String, dynamic> _deviceInfo = {};
|
||||
|
||||
// Mock WiFi List
|
||||
final List<Map<String, dynamic>> _wifiList = [
|
||||
{'ssid': 'Home_5G', 'level': 4},
|
||||
{'ssid': 'Office_WiFi', 'level': 3},
|
||||
{'ssid': 'Guest_Network', 'level': 2},
|
||||
];
|
||||
// BLE Provisioning
|
||||
BleProvisioningService? _provService;
|
||||
List<ScannedWifi> _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<String, dynamic>) {
|
||||
_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<void> _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<void> _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<WifiConfigPage>
|
||||
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<void> _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<WifiConfigPage>
|
||||
stepIndex++;
|
||||
} else {
|
||||
timer.cancel();
|
||||
// Record WiFi config on server
|
||||
_recordWifiConfig();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentStep = 4;
|
||||
});
|
||||
setState(() => _currentStep = 4);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _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<WifiConfigPage>
|
||||
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<WifiConfigPage>
|
||||
);
|
||||
}
|
||||
|
||||
// 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<WifiConfigPage>
|
||||
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<WifiConfigPage>
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 48), // Balance back button
|
||||
const SizedBox(width: 48),
|
||||
],
|
||||
),
|
||||
);
|
||||
@ -223,10 +317,10 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
||||
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<WifiConfigPage>
|
||||
}
|
||||
}
|
||||
|
||||
// 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<WifiConfigPage>
|
||||
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<String, dynamic> 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<WifiConfigPage>
|
||||
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<WifiConfigPage>
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: Enter Password
|
||||
// Step 2: 输入密码
|
||||
Widget _buildStep2() {
|
||||
return Column(
|
||||
children: [
|
||||
@ -366,16 +502,11 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
||||
),
|
||||
),
|
||||
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<WifiConfigPage>
|
||||
size: 22,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
setState(() => _obscurePassword = !_obscurePassword);
|
||||
},
|
||||
),
|
||||
),
|
||||
@ -411,11 +540,10 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
||||
);
|
||||
}
|
||||
|
||||
// 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<WifiConfigPage>
|
||||
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<WifiConfigPage>
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Progress Bar
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
child: SizedBox(
|
||||
@ -452,7 +577,9 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
||||
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<WifiConfigPage>
|
||||
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<WifiConfigPage>
|
||||
}
|
||||
}
|
||||
|
||||
// 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<WifiConfigPage>
|
||||
),
|
||||
),
|
||||
),
|
||||
// Check badge
|
||||
Positioned(
|
||||
bottom: -5,
|
||||
right: -5,
|
||||
@ -545,13 +686,9 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
||||
),
|
||||
),
|
||||
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<WifiConfigPage>
|
||||
}
|
||||
|
||||
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<WifiConfigPage>
|
||||
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<WifiConfigPage>
|
||||
),
|
||||
),
|
||||
if (_currentStep < 4) const SizedBox(width: 16),
|
||||
|
||||
// Constrained button (not full-width)
|
||||
GradientButton(
|
||||
text: nextText,
|
||||
onPressed: _handleNext,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
13
airhub_app/packages/ali_auth/ios/Classes/AliAuthPlugin.swift
Normal file
13
airhub_app/packages/ali_auth/ios/Classes/AliAuthPlugin.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
13
airhub_app/packages/ali_auth/ios/ali_auth.podspec
Normal file
13
airhub_app/packages/ali_auth/ios/ali_auth.podspec
Normal file
@ -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
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
130
本地localhost运行.md
Normal file
130
本地localhost运行.md
Normal file
@ -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 秒
|
||||
Loading…
x
Reference in New Issue
Block a user