fix wify 配网

This commit is contained in:
zyc 2026-02-10 18:21:21 +08:00
parent 8ed21ca4a4
commit 4983553261
18 changed files with 1159 additions and 223 deletions

View File

@ -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}"

View File

@ -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

View File

@ -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>

View File

@ -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));

View File

@ -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';

View File

@ -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',

View 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);
}
});
// MTUiOS
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');
}
}
}

View File

@ -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 使 stubali_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;
},
); );
} }
} }

View File

@ -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() {

View File

@ -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,
), ),
], ],
], ],

View File

@ -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() {

View File

@ -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,

View File

@ -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)
}
}

View 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)
}
}

View 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

View File

@ -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:

View File

@ -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
View File

@ -0,0 +1,130 @@
# Flutter Web 本地调试启动指南
> 本文档供 AI 编码助手阅读,用于在本项目中正确启动 Flutter Web 调试环境。
## 项目结构
- Flutter 应用目录:`airhub_app/`
- 后端服务入口:`server.py`根目录FastAPI + Uvicorn端口 3000
- 前端端口:`8080`
## 环境要求
- Flutter SDK3.x
- Python 3.x后端服务
- PowerShellWindows 环境)
## 操作系统
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`
### 端口管理
- 固定使用 8080Flutter和 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 get90-120 秒
- 增量编译(`--no-pub`60-90 秒
- 热重载(按 r3-5 秒
- 热重启(按 R10-20 秒