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">
|
<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
|
<application
|
||||||
android:label="airhub_app"
|
android:label="airhub_app"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|||||||
@ -1,40 +1,79 @@
|
|||||||
PODS:
|
PODS:
|
||||||
|
- ali_auth (1.3.7):
|
||||||
|
- Flutter
|
||||||
|
- MJExtension
|
||||||
|
- SDWebImage
|
||||||
|
- audio_session (0.0.1):
|
||||||
|
- Flutter
|
||||||
- Flutter (1.0.0)
|
- Flutter (1.0.0)
|
||||||
- flutter_blue_plus_darwin (0.0.2):
|
- flutter_blue_plus_darwin (0.0.2):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- image_picker_ios (0.0.1):
|
- image_picker_ios (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- just_audio (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
|
- MJExtension (3.4.2)
|
||||||
- permission_handler_apple (9.3.0):
|
- permission_handler_apple (9.3.0):
|
||||||
- Flutter
|
- 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):
|
- webview_flutter_wkwebview (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
|
- ali_auth (from `.symlinks/plugins/ali_auth/ios`)
|
||||||
|
- audio_session (from `.symlinks/plugins/audio_session/ios`)
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
- flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
|
- flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
|
||||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
- 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`)
|
- 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`)
|
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
|
||||||
|
|
||||||
|
SPEC REPOS:
|
||||||
|
trunk:
|
||||||
|
- MJExtension
|
||||||
|
- SDWebImage
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
|
ali_auth:
|
||||||
|
:path: ".symlinks/plugins/ali_auth/ios"
|
||||||
|
audio_session:
|
||||||
|
:path: ".symlinks/plugins/audio_session/ios"
|
||||||
Flutter:
|
Flutter:
|
||||||
:path: Flutter
|
:path: Flutter
|
||||||
flutter_blue_plus_darwin:
|
flutter_blue_plus_darwin:
|
||||||
:path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin"
|
:path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin"
|
||||||
image_picker_ios:
|
image_picker_ios:
|
||||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||||
|
just_audio:
|
||||||
|
:path: ".symlinks/plugins/just_audio/darwin"
|
||||||
permission_handler_apple:
|
permission_handler_apple:
|
||||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||||
|
shared_preferences_foundation:
|
||||||
|
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||||
webview_flutter_wkwebview:
|
webview_flutter_wkwebview:
|
||||||
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
|
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
|
ali_auth: fe9a6188a90eb39227f3674c05a71383ac4ec6a2
|
||||||
|
audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
|
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
|
||||||
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
||||||
|
just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed
|
||||||
|
MJExtension: e97d164cb411aa9795cf576093a1fa208b4a8dd8
|
||||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||||
|
SDWebImage: 1bb6a1b84b6fe87b972a102bdc77dd589df33477
|
||||||
|
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||||
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
|
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
|
||||||
|
|
||||||
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
||||||
|
|||||||
@ -45,6 +45,12 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||||
|
<string>需要蓝牙权限来搜索和连接您的设备</string>
|
||||||
|
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||||
|
<string>需要蓝牙权限来搜索和连接您的设备</string>
|
||||||
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
|
<string>需要位置权限以扫描附近的蓝牙设备</string>
|
||||||
<key>UILaunchScreen</key>
|
<key>UILaunchScreen</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIColorName</key>
|
<key>UIColorName</key>
|
||||||
|
|||||||
@ -128,6 +128,7 @@ class _AuthInterceptor extends Interceptor {
|
|||||||
'/auth/phone-login/',
|
'/auth/phone-login/',
|
||||||
'/auth/refresh/',
|
'/auth/refresh/',
|
||||||
'/version/check/',
|
'/version/check/',
|
||||||
|
'/devices/query-by-mac/',
|
||||||
];
|
];
|
||||||
|
|
||||||
final needsAuth = !noAuthPaths.any((p) => options.path.contains(p));
|
final needsAuth = !noAuthPaths.any((p) => options.path.contains(p));
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
class ApiConfig {
|
class ApiConfig {
|
||||||
/// 后端服务器地址(开发环境请替换为实际 IP)
|
/// 后端服务器地址(开发环境请替换为实际 IP)
|
||||||
static const String baseUrl = 'http://127.0.0.1:8000';
|
static const String baseUrl = 'http://192.168.124.24:8000';
|
||||||
|
|
||||||
/// App 端 API 前缀
|
/// App 端 API 前缀
|
||||||
static const String apiPrefix = '/api/v1';
|
static const String apiPrefix = '/api/v1';
|
||||||
|
|||||||
@ -44,7 +44,9 @@ GoRouter goRouter(Ref ref) {
|
|||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/wifi-config',
|
path: '/wifi-config',
|
||||||
builder: (context, state) => const WifiConfigPage(),
|
builder: (context, state) => WifiConfigPage(
|
||||||
|
extra: state.extra as Map<String, dynamic>?,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/device-control',
|
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 'dart:async';
|
||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
import 'package:flutter/foundation.dart' show debugPrint, kIsWeb;
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
// 条件导入:Web 用 stub,原生用真实 ali_auth
|
// 本地 Web 调试:始终使用 stub(ali_auth 不兼容当前 Dart 版本)
|
||||||
import 'phone_auth_service_stub.dart'
|
import 'phone_auth_service_stub.dart';
|
||||||
if (dart.library.io) 'package:ali_auth/ali_auth.dart';
|
|
||||||
|
|
||||||
part 'phone_auth_service.g.dart';
|
part 'phone_auth_service.g.dart';
|
||||||
|
|
||||||
@ -22,12 +21,19 @@ PhoneAuthService phoneAuthService(Ref ref) {
|
|||||||
|
|
||||||
class PhoneAuthService {
|
class PhoneAuthService {
|
||||||
bool _initialized = false;
|
bool _initialized = false;
|
||||||
|
String? _lastError;
|
||||||
|
|
||||||
|
/// 最近一次错误信息(用于 UI 展示)
|
||||||
|
String? get lastError => _lastError;
|
||||||
|
|
||||||
/// 初始化 SDK(只需调用一次)
|
/// 初始化 SDK(只需调用一次)
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
|
debugPrint('[AliAuth] init() called, _initialized=$_initialized, kIsWeb=$kIsWeb');
|
||||||
if (_initialized) return;
|
if (_initialized) return;
|
||||||
// 真机才初始化,Web 跳过
|
if (kIsWeb) {
|
||||||
if (kIsWeb) return;
|
_lastError = '不支持 Web 平台';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await AliAuth.initSdk(
|
await AliAuth.initSdk(
|
||||||
@ -40,37 +46,45 @@ class PhoneAuthService {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
_initialized = true;
|
_initialized = true;
|
||||||
|
_lastError = null;
|
||||||
|
debugPrint('[AliAuth] SDK 初始化成功');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// SDK 初始化失败不阻塞 App 启动
|
|
||||||
_initialized = false;
|
_initialized = false;
|
||||||
|
_lastError = 'SDK初始化失败: $e';
|
||||||
|
debugPrint('[AliAuth] $_lastError');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 一键登录,返回阿里云 token(用于发给后端换手机号)
|
/// 一键登录,返回阿里云 token(用于发给后端换手机号)
|
||||||
/// 返回 null 表示用户取消或认证失败
|
/// 返回 null 表示用户取消或认证失败
|
||||||
Future<String?> getLoginToken() async {
|
Future<String?> getLoginToken() async {
|
||||||
|
debugPrint('[AliAuth] getLoginToken() called, _initialized=$_initialized');
|
||||||
if (!_initialized) {
|
if (!_initialized) {
|
||||||
await init();
|
await init();
|
||||||
}
|
}
|
||||||
if (!_initialized) return null;
|
if (!_initialized) {
|
||||||
|
debugPrint('[AliAuth] SDK 未初始化,返回 null, error=$_lastError');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
final completer = Completer<String?>();
|
final completer = Completer<String?>();
|
||||||
|
|
||||||
AliAuth.loginListen(onEvent: (event) {
|
AliAuth.loginListen(onEvent: (event) {
|
||||||
|
debugPrint('[AliAuth] loginListen event: $event');
|
||||||
final code = event['code'] as String?;
|
final code = event['code'] as String?;
|
||||||
|
|
||||||
if (code == '600000' && event['data'] != null) {
|
if (code == '600000' && event['data'] != null) {
|
||||||
// 成功获取 token
|
|
||||||
if (!completer.isCompleted) {
|
if (!completer.isCompleted) {
|
||||||
completer.complete(event['data'] as String);
|
completer.complete(event['data'] as String);
|
||||||
}
|
}
|
||||||
} else if (code == '700000' || code == '700001') {
|
} else if (code == '700000' || code == '700001') {
|
||||||
// 用户取消
|
_lastError = '用户取消';
|
||||||
if (!completer.isCompleted) {
|
if (!completer.isCompleted) {
|
||||||
completer.complete(null);
|
completer.complete(null);
|
||||||
}
|
}
|
||||||
} else if (code != null && code.startsWith('6') && code != '600000') {
|
} else if (code != null && code.startsWith('6') && code != '600000') {
|
||||||
// 其他 6xxxxx 错误码
|
_lastError = '错误码$code: ${event['msg']}';
|
||||||
|
debugPrint('[AliAuth] $_lastError');
|
||||||
if (!completer.isCompleted) {
|
if (!completer.isCompleted) {
|
||||||
completer.complete(null);
|
completer.complete(null);
|
||||||
}
|
}
|
||||||
@ -79,7 +93,11 @@ class PhoneAuthService {
|
|||||||
|
|
||||||
return completer.future.timeout(
|
return completer.future.timeout(
|
||||||
const Duration(seconds: 30),
|
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/animated_gradient_background.dart';
|
||||||
import '../../../../widgets/gradient_button.dart';
|
import '../../../../widgets/gradient_button.dart';
|
||||||
import '../../../../widgets/ios_toast.dart';
|
import '../../../../widgets/ios_toast.dart';
|
||||||
|
import '../../../device/presentation/controllers/device_controller.dart';
|
||||||
import '../controllers/auth_controller.dart';
|
import '../controllers/auth_controller.dart';
|
||||||
import '../widgets/floating_mascot.dart';
|
import '../widgets/floating_mascot.dart';
|
||||||
|
|
||||||
@ -205,21 +206,25 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
|
|
||||||
// Logic Methods
|
// Logic Methods
|
||||||
Future<void> _doOneClickLogin() async {
|
Future<void> _doOneClickLogin() async {
|
||||||
// 通过阿里云号码认证 SDK 获取 token
|
debugPrint('[Login] _doOneClickLogin() 开始');
|
||||||
final phoneAuthService = ref.read(phoneAuthServiceProvider);
|
final phoneAuthService = ref.read(phoneAuthServiceProvider);
|
||||||
final token = await phoneAuthService.getLoginToken();
|
final token = await phoneAuthService.getLoginToken();
|
||||||
|
debugPrint('[Login] getLoginToken 返回: $token');
|
||||||
if (token == null) {
|
if (token == null) {
|
||||||
if (mounted) _showToast('一键登录取消或失败,请使用验证码登录', isError: true);
|
final error = phoneAuthService.lastError ?? '未知错误';
|
||||||
|
if (mounted) _showToast('一键登录失败: $error', isError: true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final success = await ref.read(authControllerProvider.notifier).tokenLogin(token);
|
final success = await ref.read(authControllerProvider.notifier).tokenLogin(token);
|
||||||
|
debugPrint('[Login] tokenLogin 结果: $success');
|
||||||
if (success && mounted) {
|
if (success && mounted) {
|
||||||
context.go('/home');
|
await _navigateAfterLogin();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleOneClickLogin() {
|
void _handleOneClickLogin() {
|
||||||
|
debugPrint('[Login] _handleOneClickLogin() agreed=$_agreed');
|
||||||
if (!_agreed) {
|
if (!_agreed) {
|
||||||
_showAgreementDialog(action: 'oneclick');
|
_showAgreementDialog(action: 'oneclick');
|
||||||
return;
|
return;
|
||||||
@ -269,8 +274,26 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
.read(authControllerProvider.notifier)
|
.read(authControllerProvider.notifier)
|
||||||
.codeLogin(_phoneController.text, _codeController.text);
|
.codeLogin(_phoneController.text, _codeController.text);
|
||||||
if (success && mounted) {
|
if (success && mounted) {
|
||||||
|
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');
|
context.go('/home');
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[Login] Device check failed: $e');
|
||||||
|
if (mounted) context.go('/home');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildGradientBackground() {
|
Widget _buildGradientBackground() {
|
||||||
|
|||||||
@ -1,13 +1,19 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:math';
|
import 'dart:io';
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
import 'package:flutter/material.dart';
|
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:go_router/go_router.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.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 '../widgets/animated_gradient_background.dart';
|
||||||
import '../theme/app_colors.dart';
|
|
||||||
import '../widgets/gradient_button.dart';
|
import '../widgets/gradient_button.dart';
|
||||||
|
import '../widgets/glass_dialog.dart';
|
||||||
|
|
||||||
/// 设备类型
|
/// 设备类型
|
||||||
enum DeviceType { plush, badgeAi, badge }
|
enum DeviceType { plush, badgeAi, badge }
|
||||||
@ -16,14 +22,20 @@ enum DeviceType { plush, badgeAi, badge }
|
|||||||
class MockDevice {
|
class MockDevice {
|
||||||
final String sn;
|
final String sn;
|
||||||
final String name;
|
final String name;
|
||||||
|
final String macAddress;
|
||||||
final DeviceType type;
|
final DeviceType type;
|
||||||
final bool hasAI;
|
final bool hasAI;
|
||||||
|
final bool isNetworkRequired;
|
||||||
|
final BluetoothDevice? bleDevice;
|
||||||
|
|
||||||
const MockDevice({
|
const MockDevice({
|
||||||
required this.sn,
|
required this.sn,
|
||||||
required this.name,
|
required this.name,
|
||||||
|
required this.macAddress,
|
||||||
required this.type,
|
required this.type,
|
||||||
required this.hasAI,
|
required this.hasAI,
|
||||||
|
this.isNetworkRequired = true,
|
||||||
|
this.bleDevice,
|
||||||
});
|
});
|
||||||
|
|
||||||
String get iconPath {
|
String get iconPath {
|
||||||
@ -50,53 +62,39 @@ class MockDevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 蓝牙搜索页面
|
/// 蓝牙搜索页面
|
||||||
class BluetoothPage extends StatefulWidget {
|
class BluetoothPage extends ConsumerStatefulWidget {
|
||||||
const BluetoothPage({super.key});
|
const BluetoothPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<BluetoothPage> createState() => _BluetoothPageState();
|
ConsumerState<BluetoothPage> createState() => _BluetoothPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _BluetoothPageState extends State<BluetoothPage>
|
class _BluetoothPageState extends ConsumerState<BluetoothPage>
|
||||||
with TickerProviderStateMixin {
|
with TickerProviderStateMixin {
|
||||||
|
/// Airhub 设备名前缀(硬件广播格式: Airhub_ + MAC)
|
||||||
|
static const _airhubPrefix = 'Airhub_';
|
||||||
|
|
||||||
// 状态
|
// 状态
|
||||||
bool _isSearching = true;
|
bool _isSearching = true;
|
||||||
|
bool _isBluetoothOn = false;
|
||||||
List<MockDevice> _devices = [];
|
List<MockDevice> _devices = [];
|
||||||
int _currentIndex = 0;
|
int _currentIndex = 0;
|
||||||
|
|
||||||
|
// 已查询过的 MAC → 设备信息缓存(避免重复调 API)
|
||||||
|
final Map<String, Map<String, dynamic>> _macInfoCache = {};
|
||||||
|
|
||||||
// 动画控制器
|
// 动画控制器
|
||||||
late AnimationController _searchAnimController;
|
late AnimationController _searchAnimController;
|
||||||
|
|
||||||
// 滚轮控制器
|
// 滚轮控制器
|
||||||
late FixedExtentScrollController _wheelController;
|
late FixedExtentScrollController _wheelController;
|
||||||
|
|
||||||
// 模拟设备数据
|
// 蓝牙订阅
|
||||||
static const List<MockDevice> _mockDevices = [
|
StreamSubscription<BluetoothAdapterState>? _bluetoothSubscription;
|
||||||
MockDevice(
|
StreamSubscription<List<ScanResult>>? _scanSubscription;
|
||||||
sn: 'PLUSH_01',
|
|
||||||
name: '卡皮巴拉-001',
|
// 是否已弹过蓝牙关闭提示(避免重复弹窗)
|
||||||
type: DeviceType.plush,
|
bool _hasShownBluetoothDialog = false;
|
||||||
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,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -111,61 +109,315 @@ class _BluetoothPageState extends State<BluetoothPage>
|
|||||||
// 滚轮控制器
|
// 滚轮控制器
|
||||||
_wheelController = FixedExtentScrollController(initialItem: _currentIndex);
|
_wheelController = FixedExtentScrollController(initialItem: _currentIndex);
|
||||||
|
|
||||||
// 模拟搜索延迟
|
// 监听蓝牙适配器状态
|
||||||
_startSearch();
|
_listenBluetoothState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_bluetoothSubscription?.cancel();
|
||||||
|
_scanSubscription?.cancel();
|
||||||
|
FlutterBluePlus.stopScan();
|
||||||
_searchAnimController.dispose();
|
_searchAnimController.dispose();
|
||||||
_wheelController.dispose();
|
_wheelController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 开始搜索 (模拟)
|
/// 监听蓝牙适配器状态
|
||||||
Future<void> _startSearch() async {
|
void _listenBluetoothState() {
|
||||||
// 请求蓝牙权限
|
_bluetoothSubscription = FlutterBluePlus.adapterState.listen((state) {
|
||||||
await _requestPermissions();
|
if (!mounted) return;
|
||||||
|
|
||||||
// 模拟 2 秒搜索延迟
|
final isOn = state == BluetoothAdapterState.on;
|
||||||
await Future.delayed(const Duration(seconds: 2));
|
setState(() => _isBluetoothOn = isOn);
|
||||||
|
|
||||||
if (mounted) {
|
if (isOn) {
|
||||||
// 随机选择 1-4 个设备
|
_startSearch();
|
||||||
final count = Random().nextInt(4) + 1;
|
} else if (state == BluetoothAdapterState.off) {
|
||||||
|
FlutterBluePlus.stopScan();
|
||||||
setState(() {
|
setState(() {
|
||||||
_devices = _mockDevices.take(count).toList();
|
|
||||||
_isSearching = false;
|
_isSearching = false;
|
||||||
|
_devices.clear();
|
||||||
|
});
|
||||||
|
if (!_hasShownBluetoothDialog) {
|
||||||
|
_hasShownBluetoothDialog = true;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) _showBluetoothOffDialog();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// 请求蓝牙权限(模拟器上可能失败,不影响 mock 搜索)
|
/// 从设备名中提取 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();
|
||||||
|
|
||||||
|
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 设备';
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
// 避免重复添加
|
||||||
|
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 (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据设备名称推断设备类型
|
||||||
|
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 {
|
Future<void> _requestPermissions() async {
|
||||||
try {
|
try {
|
||||||
await Permission.bluetooth.request();
|
if (Platform.isAndroid) {
|
||||||
|
// Android 需要位置权限才能扫描 BLE
|
||||||
await Permission.bluetoothScan.request();
|
await Permission.bluetoothScan.request();
|
||||||
await Permission.bluetoothConnect.request();
|
await Permission.bluetoothConnect.request();
|
||||||
await Permission.location.request();
|
await Permission.location.request();
|
||||||
} catch (_) {
|
} else {
|
||||||
// 模拟器上蓝牙不可用,忽略权限错误,继续用 mock 数据
|
// 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() {
|
Future<void> _handleConnect() async {
|
||||||
if (_devices.isEmpty) return;
|
if (_devices.isEmpty || _isConnecting) return;
|
||||||
|
|
||||||
|
// 检查蓝牙状态
|
||||||
|
if (!_isBluetoothOn) {
|
||||||
|
_showBluetoothOffDialog();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final device = _devices[_currentIndex];
|
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');
|
context.go('/device-control');
|
||||||
} else {
|
return;
|
||||||
// 其他 -> WiFi 配网页
|
|
||||||
context.go('/wifi-config');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
@override
|
||||||
@ -564,10 +816,10 @@ class _BluetoothPageState extends State<BluetoothPage>
|
|||||||
if (!_isSearching && _devices.isNotEmpty) ...[
|
if (!_isSearching && _devices.isNotEmpty) ...[
|
||||||
const SizedBox(width: 16), // HTML: gap 16px
|
const SizedBox(width: 16), // HTML: gap 16px
|
||||||
GradientButton(
|
GradientButton(
|
||||||
text: '连接设备',
|
text: _isConnecting ? '连接中...' : '连接设备',
|
||||||
width: 180,
|
width: 180,
|
||||||
height: 52,
|
height: 52,
|
||||||
onPressed: _handleConnect,
|
onPressed: _isConnecting ? null : _handleConnect,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@ -35,7 +35,12 @@ class _ProfileInfoPageState extends ConsumerState<ProfileInfoPage> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
static const _genderToDisplay = {'male': '男', 'female': '女'};
|
static const _genderToDisplay = {
|
||||||
|
'male': '男',
|
||||||
|
'female': '女',
|
||||||
|
'M': '男',
|
||||||
|
'F': '女',
|
||||||
|
};
|
||||||
static const _displayToGender = {'男': 'male', '女': 'female'};
|
static const _displayToGender = {'男': 'male', '女': 'female'};
|
||||||
|
|
||||||
void _initFromUser() {
|
void _initFromUser() {
|
||||||
|
|||||||
@ -4,12 +4,15 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.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/animated_gradient_background.dart';
|
||||||
import '../widgets/gradient_button.dart';
|
import '../widgets/gradient_button.dart';
|
||||||
import '../features/device/presentation/controllers/device_controller.dart';
|
|
||||||
|
|
||||||
class WifiConfigPage extends ConsumerStatefulWidget {
|
class WifiConfigPage extends ConsumerStatefulWidget {
|
||||||
const WifiConfigPage({super.key});
|
final Map<String, dynamic>? extra;
|
||||||
|
|
||||||
|
const WifiConfigPage({super.key, this.extra});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<WifiConfigPage> createState() => _WifiConfigPageState();
|
ConsumerState<WifiConfigPage> createState() => _WifiConfigPageState();
|
||||||
@ -25,36 +28,112 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
|||||||
// Progress State
|
// Progress State
|
||||||
double _progress = 0.0;
|
double _progress = 0.0;
|
||||||
String _progressText = '正在连接WiFi...';
|
String _progressText = '正在连接WiFi...';
|
||||||
|
bool _connectFailed = false;
|
||||||
|
|
||||||
// Device Info (Mock or from Route Args)
|
// Device Info
|
||||||
// We'll try to get it from arguments, default to a fallback
|
|
||||||
Map<String, dynamic> _deviceInfo = {};
|
Map<String, dynamic> _deviceInfo = {};
|
||||||
|
|
||||||
// Mock WiFi List
|
// BLE Provisioning
|
||||||
final List<Map<String, dynamic>> _wifiList = [
|
BleProvisioningService? _provService;
|
||||||
{'ssid': 'Home_5G', 'level': 4},
|
List<ScannedWifi> _wifiList = [];
|
||||||
{'ssid': 'Office_WiFi', 'level': 3},
|
bool _isScanning = false;
|
||||||
{'ssid': 'Guest_Network', 'level': 2},
|
|
||||||
];
|
// Subscriptions
|
||||||
|
StreamSubscription? _wifiListSub;
|
||||||
|
StreamSubscription? _wifiStatusSub;
|
||||||
|
StreamSubscription? _disconnectSub;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void initState() {
|
||||||
super.didChangeDependencies();
|
super.initState();
|
||||||
// Retrieve device info from arguments
|
_deviceInfo = widget.extra ?? {};
|
||||||
final args = ModalRoute.of(context)?.settings.arguments;
|
_provService = _deviceInfo['provService'] as BleProvisioningService?;
|
||||||
if (args is Map<String, dynamic>) {
|
|
||||||
_deviceInfo = args;
|
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 == 1 && _selectedWifiSsid.isEmpty) return;
|
||||||
if (_currentStep == 2 && _passwordController.text.isEmpty) return;
|
if (_currentStep == 2 && _passwordController.text.isEmpty) return;
|
||||||
|
|
||||||
if (_currentStep == 4) {
|
if (_currentStep == 4) {
|
||||||
// Navigate to Device Control
|
final sn = _deviceInfo['sn'] as String? ?? '';
|
||||||
// Use pushNamedAndRemoveUntil to remove Bluetooth and WiFi pages from stack
|
if (sn.isNotEmpty) {
|
||||||
// but keep Home page so back button goes to Home
|
debugPrint('[WiFi Config] Binding device sn=$sn');
|
||||||
|
await ref.read(deviceControllerProvider.notifier).bindDevice(sn);
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
context.go('/device-control');
|
context.go('/device-control');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -72,13 +151,58 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
|||||||
if (_currentStep > 1) {
|
if (_currentStep > 1) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentStep--;
|
_currentStep--;
|
||||||
|
if (_currentStep == 1) {
|
||||||
|
_connectFailed = false;
|
||||||
|
_progress = 0.0;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} 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 = [
|
const steps = [
|
||||||
{'progress': 0.3, 'text': '正在连接WiFi...'},
|
{'progress': 0.3, 'text': '正在连接WiFi...'},
|
||||||
{'progress': 0.6, 'text': '正在验证密码...'},
|
{'progress': 0.6, 'text': '正在验证密码...'},
|
||||||
@ -98,27 +222,13 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
|||||||
stepIndex++;
|
stepIndex++;
|
||||||
} else {
|
} else {
|
||||||
timer.cancel();
|
timer.cancel();
|
||||||
// Record WiFi config on server
|
|
||||||
_recordWifiConfig();
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() => _currentStep = 4);
|
||||||
_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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@ -126,34 +236,24 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
|||||||
resizeToAvoidBottomInset: true,
|
resizeToAvoidBottomInset: true,
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
// Background
|
const AnimatedGradientBackground(),
|
||||||
_buildGradientBackground(),
|
|
||||||
|
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// Header
|
|
||||||
_buildHeader(),
|
_buildHeader(),
|
||||||
|
|
||||||
// Content
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// Steps Indicator
|
|
||||||
_buildStepIndicator(),
|
_buildStepIndicator(),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
// Dynamic Step Content
|
|
||||||
_buildCurrentStepContent(),
|
_buildCurrentStepContent(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Footer
|
|
||||||
_buildFooter(),
|
_buildFooter(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -164,17 +264,11 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Common Gradient Background
|
|
||||||
Widget _buildGradientBackground() {
|
|
||||||
return const AnimatedGradientBackground();
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildHeader() {
|
Widget _buildHeader() {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// Back button - HTML: bg rgba(255,255,255,0.6), border-radius: 12px, color #4B5563
|
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: _handleBack,
|
onTap: _handleBack,
|
||||||
child: Container(
|
child: Container(
|
||||||
@ -187,7 +281,7 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
|||||||
child: const Icon(
|
child: const Icon(
|
||||||
Icons.arrow_back_ios_new,
|
Icons.arrow_back_ios_new,
|
||||||
size: 18,
|
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),
|
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isCompleted
|
color: isCompleted
|
||||||
? const Color(0xFF22C55E) // Green for completed
|
? const Color(0xFF22C55E)
|
||||||
: isActive
|
: isActive
|
||||||
? const Color(0xFF8B5CF6) // Purple for active
|
? const Color(0xFF8B5CF6)
|
||||||
: const Color(0xFF8B5CF6).withOpacity(0.3), // Faded purple
|
: const Color(0xFF8B5CF6).withOpacity(0.3),
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -249,11 +343,10 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Select Network
|
// Step 1: 选择 WiFi 网络
|
||||||
Widget _buildStep1() {
|
Widget _buildStep1() {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
// Icon
|
|
||||||
Container(
|
Container(
|
||||||
margin: const EdgeInsets.only(bottom: 24),
|
margin: const EdgeInsets.only(bottom: 24),
|
||||||
child: const Icon(Icons.wifi, size: 80, color: Color(0xFF8B5CF6)),
|
child: const Icon(Icons.wifi, size: 80, color: Color(0xFF8B5CF6)),
|
||||||
@ -269,27 +362,74 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
const Text(
|
const Text(
|
||||||
'设备需要连接WiFi以使用AI功能',
|
'设备需要连接WiFi以使用AI功能',
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 14, color: Color(0xFF6B7280)),
|
||||||
|
|
||||||
fontSize: 14,
|
|
||||||
color: Color(0xFF6B7280),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// List
|
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(
|
Column(
|
||||||
children: _wifiList.map((wifi) => _buildWifiItem(wifi)).toList(),
|
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) {
|
Widget _buildWifiItem(ScannedWifi wifi) {
|
||||||
bool isSelected = _selectedWifiSsid == wifi['ssid'];
|
bool isSelected = _selectedWifiSsid == wifi.ssid;
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
setState(() => _selectedWifiSsid = wifi['ssid']);
|
setState(() => _selectedWifiSsid = wifi.ssid);
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@ -317,27 +457,23 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
wifi['ssid'],
|
wifi.ssid,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
|
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: Color(0xFF1F2937),
|
color: Color(0xFF1F2937),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// HTML uses per-level SVG icons: wifi-1.svg to wifi-4.svg
|
// WiFi 信号图标
|
||||||
Opacity(
|
Icon(
|
||||||
opacity: 0.8,
|
wifi.level >= 3
|
||||||
child: SvgPicture.asset(
|
? Icons.wifi
|
||||||
'assets/www/icons/wifi-${wifi['level']}.svg',
|
: wifi.level == 2
|
||||||
width: 24,
|
? Icons.wifi_2_bar
|
||||||
height: 24,
|
: Icons.wifi_1_bar,
|
||||||
colorFilter: const ColorFilter.mode(
|
size: 24,
|
||||||
Color(0xFF6B7280),
|
color: const Color(0xFF6B7280),
|
||||||
BlendMode.srcIn,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -345,7 +481,7 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Enter Password
|
// Step 2: 输入密码
|
||||||
Widget _buildStep2() {
|
Widget _buildStep2() {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
@ -366,16 +502,11 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
const Text(
|
||||||
'请输入WiFi密码',
|
'请输入WiFi密码',
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 14, color: Color(0xFF6B7280)),
|
||||||
|
|
||||||
fontSize: 14,
|
|
||||||
color: const Color(0xFF6B7280),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
TextField(
|
TextField(
|
||||||
controller: _passwordController,
|
controller: _passwordController,
|
||||||
obscureText: _obscurePassword,
|
obscureText: _obscurePassword,
|
||||||
@ -398,9 +529,7 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
|||||||
size: 22,
|
size: 22,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() {
|
setState(() => _obscurePassword = !_obscurePassword);
|
||||||
_obscurePassword = !_obscurePassword;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -411,11 +540,10 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Connecting
|
// Step 3: 正在连接
|
||||||
Widget _buildStep3() {
|
Widget _buildStep3() {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
// Animation placeholder (using Icon for now, can be upgraded to Wave animation)
|
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 120,
|
height: 120,
|
||||||
child: Center(
|
child: Center(
|
||||||
@ -429,8 +557,7 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
|||||||
color: const Color(0xFF8B5CF6).withOpacity(1 - value * 0.5),
|
color: const Color(0xFF8B5CF6).withOpacity(1 - value * 0.5),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onEnd:
|
onEnd: () {},
|
||||||
() {}, // Repeat logic usually handled by AnimationController
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -443,8 +570,6 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
// Progress Bar
|
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(3),
|
borderRadius: BorderRadius.circular(3),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
@ -452,7 +577,9 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
|||||||
child: LinearProgressIndicator(
|
child: LinearProgressIndicator(
|
||||||
value: _progress,
|
value: _progress,
|
||||||
backgroundColor: const Color(0xFF8B5CF6).withOpacity(0.2),
|
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(
|
Text(
|
||||||
_progressText,
|
_progressText,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
|
|
||||||
fontSize: 14,
|
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() {
|
String _getDeviceIconPath() {
|
||||||
final type = _deviceInfo['type'] as String? ?? 'plush';
|
final type = _deviceInfo['type'] as String? ?? 'plush';
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'plush_core':
|
case 'plush_core':
|
||||||
case 'plush':
|
case 'plush':
|
||||||
return 'assets/www/icons/pixel-capybara.svg';
|
return 'assets/www/icons/pixel-capybara.svg';
|
||||||
case 'badge_ai':
|
case 'badgeAi':
|
||||||
return 'assets/www/icons/pixel-badge-ai.svg';
|
return 'assets/www/icons/pixel-badge-ai.svg';
|
||||||
case 'badge_basic':
|
|
||||||
case 'badge':
|
case 'badge':
|
||||||
return 'assets/www/icons/pixel-badge-basic.svg';
|
return 'assets/www/icons/pixel-badge-basic.svg';
|
||||||
default:
|
default:
|
||||||
@ -486,17 +630,15 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Result (Success) - centered vertically
|
// Step 4: 配网成功
|
||||||
Widget _buildStep4() {
|
Widget _buildStep4() {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 80),
|
const SizedBox(height: 80),
|
||||||
// Success Icon Stack - HTML: no white background
|
|
||||||
Stack(
|
Stack(
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
// Device icon container - 120x120 per HTML
|
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 120,
|
width: 120,
|
||||||
height: 120,
|
height: 120,
|
||||||
@ -511,7 +653,6 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Check badge
|
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: -5,
|
bottom: -5,
|
||||||
right: -5,
|
right: -5,
|
||||||
@ -545,13 +686,9 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
const Text(
|
||||||
'设备已成功连接到网络',
|
'设备已成功连接到网络',
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 14, color: Color(0xFF6B7280)),
|
||||||
|
|
||||||
fontSize: 14,
|
|
||||||
color: const Color(0xFF6B7280),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@ -572,24 +709,24 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!showNext && _currentStep != 3) {
|
if (!showNext && _currentStep != 3) {
|
||||||
// Show cancel only?
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.all(32),
|
||||||
child: TextButton(
|
child: TextButton(
|
||||||
onPressed: () => context.go('/bluetooth'),
|
onPressed: () {
|
||||||
child: Text(
|
_provService?.disconnect();
|
||||||
|
context.go('/bluetooth');
|
||||||
|
},
|
||||||
|
child: const Text(
|
||||||
'取消',
|
'取消',
|
||||||
style: TextStyle(
|
style: TextStyle(color: Color(0xFF6B7280)),
|
||||||
|
|
||||||
color: const Color(0xFF6B7280),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_currentStep == 3)
|
if (_currentStep == 3) {
|
||||||
return const SizedBox(height: 100); // Hide buttons during connection
|
return const SizedBox(height: 100);
|
||||||
|
}
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: EdgeInsets.fromLTRB(
|
padding: EdgeInsets.fromLTRB(
|
||||||
@ -601,7 +738,6 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
// Cancel button - frosted glass style
|
|
||||||
if (_currentStep < 4)
|
if (_currentStep < 4)
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: _handleBack,
|
onTap: _handleBack,
|
||||||
@ -626,8 +762,6 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_currentStep < 4) const SizedBox(width: 16),
|
if (_currentStep < 4) const SizedBox(width: 16),
|
||||||
|
|
||||||
// Constrained button (not full-width)
|
|
||||||
GradientButton(
|
GradientButton(
|
||||||
text: nextText,
|
text: nextText,
|
||||||
onPressed: _handleNext,
|
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"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "91.0.0"
|
version: "91.0.0"
|
||||||
ali_auth:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
path: "packages/ali_auth"
|
|
||||||
relative: true
|
|
||||||
source: path
|
|
||||||
version: "1.3.7"
|
|
||||||
analyzer:
|
analyzer:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -54,8 +54,8 @@ dependencies:
|
|||||||
dio: ^5.7.0
|
dio: ^5.7.0
|
||||||
shared_preferences: ^2.3.0
|
shared_preferences: ^2.3.0
|
||||||
|
|
||||||
# Aliyun Phone Auth (一键登录)
|
# Aliyun Phone Auth (一键登录) — 本地 Web 调试时禁用
|
||||||
ali_auth: ^1.3.7
|
# ali_auth: ^1.3.7
|
||||||
|
|
||||||
# Existing dependencies
|
# Existing dependencies
|
||||||
webview_flutter: ^4.4.2
|
webview_flutter: ^4.4.2
|
||||||
@ -66,10 +66,6 @@ dependencies:
|
|||||||
image_picker: ^1.2.1
|
image_picker: ^1.2.1
|
||||||
just_audio: ^0.9.42
|
just_audio: ^0.9.42
|
||||||
|
|
||||||
dependency_overrides:
|
|
||||||
ali_auth:
|
|
||||||
path: packages/ali_auth
|
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
assets:
|
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