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">
<!-- Bluetooth permissions -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<application
android:label="airhub_app"
android:name="${applicationName}"

View File

@ -1,40 +1,79 @@
PODS:
- ali_auth (1.3.7):
- Flutter
- MJExtension
- SDWebImage
- audio_session (0.0.1):
- Flutter
- Flutter (1.0.0)
- flutter_blue_plus_darwin (0.0.2):
- Flutter
- FlutterMacOS
- image_picker_ios (0.0.1):
- Flutter
- just_audio (0.0.1):
- Flutter
- FlutterMacOS
- MJExtension (3.4.2)
- permission_handler_apple (9.3.0):
- Flutter
- SDWebImage (5.21.6):
- SDWebImage/Core (= 5.21.6)
- SDWebImage/Core (5.21.6)
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- webview_flutter_wkwebview (0.0.1):
- Flutter
- FlutterMacOS
DEPENDENCIES:
- ali_auth (from `.symlinks/plugins/ali_auth/ios`)
- audio_session (from `.symlinks/plugins/audio_session/ios`)
- Flutter (from `Flutter`)
- flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- just_audio (from `.symlinks/plugins/just_audio/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
SPEC REPOS:
trunk:
- MJExtension
- SDWebImage
EXTERNAL SOURCES:
ali_auth:
:path: ".symlinks/plugins/ali_auth/ios"
audio_session:
:path: ".symlinks/plugins/audio_session/ios"
Flutter:
:path: Flutter
flutter_blue_plus_darwin:
:path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
just_audio:
:path: ".symlinks/plugins/just_audio/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
webview_flutter_wkwebview:
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
SPEC CHECKSUMS:
ali_auth: fe9a6188a90eb39227f3674c05a71383ac4ec6a2
audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed
MJExtension: e97d164cb411aa9795cf576093a1fa208b4a8dd8
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
SDWebImage: 1bb6a1b84b6fe87b972a102bdc77dd589df33477
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e

View File

@ -45,6 +45,12 @@
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>需要蓝牙权限来搜索和连接您的设备</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>需要蓝牙权限来搜索和连接您的设备</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>需要位置权限以扫描附近的蓝牙设备</string>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>

View File

@ -128,6 +128,7 @@ class _AuthInterceptor extends Interceptor {
'/auth/phone-login/',
'/auth/refresh/',
'/version/check/',
'/devices/query-by-mac/',
];
final needsAuth = !noAuthPaths.any((p) => options.path.contains(p));

View File

@ -1,6 +1,6 @@
class ApiConfig {
/// IP
static const String baseUrl = 'http://127.0.0.1:8000';
static const String baseUrl = 'http://192.168.124.24:8000';
/// App API
static const String apiPrefix = '/api/v1';

View File

@ -44,7 +44,9 @@ GoRouter goRouter(Ref ref) {
),
GoRoute(
path: '/wifi-config',
builder: (context, state) => const WifiConfigPage(),
builder: (context, state) => WifiConfigPage(
extra: state.extra as Map<String, dynamic>?,
),
),
GoRoute(
path: '/device-control',

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 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/foundation.dart' show debugPrint, kIsWeb;
import 'package:riverpod_annotation/riverpod_annotation.dart';
// Web stub ali_auth
import 'phone_auth_service_stub.dart'
if (dart.library.io) 'package:ali_auth/ali_auth.dart';
// Web 使 stubali_auth Dart
import 'phone_auth_service_stub.dart';
part 'phone_auth_service.g.dart';
@ -22,12 +21,19 @@ PhoneAuthService phoneAuthService(Ref ref) {
class PhoneAuthService {
bool _initialized = false;
String? _lastError;
/// UI
String? get lastError => _lastError;
/// SDK
Future<void> init() async {
debugPrint('[AliAuth] init() called, _initialized=$_initialized, kIsWeb=$kIsWeb');
if (_initialized) return;
// Web
if (kIsWeb) return;
if (kIsWeb) {
_lastError = '不支持 Web 平台';
return;
}
try {
await AliAuth.initSdk(
@ -40,37 +46,45 @@ class PhoneAuthService {
),
);
_initialized = true;
_lastError = null;
debugPrint('[AliAuth] SDK 初始化成功');
} catch (e) {
// SDK App
_initialized = false;
_lastError = 'SDK初始化失败: $e';
debugPrint('[AliAuth] $_lastError');
}
}
/// token
/// null
Future<String?> getLoginToken() async {
debugPrint('[AliAuth] getLoginToken() called, _initialized=$_initialized');
if (!_initialized) {
await init();
}
if (!_initialized) return null;
if (!_initialized) {
debugPrint('[AliAuth] SDK 未初始化,返回 null, error=$_lastError');
return null;
}
final completer = Completer<String?>();
AliAuth.loginListen(onEvent: (event) {
debugPrint('[AliAuth] loginListen event: $event');
final code = event['code'] as String?;
if (code == '600000' && event['data'] != null) {
// token
if (!completer.isCompleted) {
completer.complete(event['data'] as String);
}
} else if (code == '700000' || code == '700001') {
//
_lastError = '用户取消';
if (!completer.isCompleted) {
completer.complete(null);
}
} else if (code != null && code.startsWith('6') && code != '600000') {
// 6xxxxx
_lastError = '错误码$code: ${event['msg']}';
debugPrint('[AliAuth] $_lastError');
if (!completer.isCompleted) {
completer.complete(null);
}
@ -79,7 +93,11 @@ class PhoneAuthService {
return completer.future.timeout(
const Duration(seconds: 30),
onTimeout: () => null,
onTimeout: () {
_lastError = '请求超时(30s)';
debugPrint('[AliAuth] $_lastError');
return null;
},
);
}
}

View File

@ -12,6 +12,7 @@ import '../../../../theme/app_colors.dart';
import '../../../../widgets/animated_gradient_background.dart';
import '../../../../widgets/gradient_button.dart';
import '../../../../widgets/ios_toast.dart';
import '../../../device/presentation/controllers/device_controller.dart';
import '../controllers/auth_controller.dart';
import '../widgets/floating_mascot.dart';
@ -205,21 +206,25 @@ class _LoginPageState extends ConsumerState<LoginPage> {
// Logic Methods
Future<void> _doOneClickLogin() async {
// SDK token
debugPrint('[Login] _doOneClickLogin() 开始');
final phoneAuthService = ref.read(phoneAuthServiceProvider);
final token = await phoneAuthService.getLoginToken();
debugPrint('[Login] getLoginToken 返回: $token');
if (token == null) {
if (mounted) _showToast('一键登录取消或失败,请使用验证码登录', isError: true);
final error = phoneAuthService.lastError ?? '未知错误';
if (mounted) _showToast('一键登录失败: $error', isError: true);
return;
}
if (!mounted) return;
final success = await ref.read(authControllerProvider.notifier).tokenLogin(token);
debugPrint('[Login] tokenLogin 结果: $success');
if (success && mounted) {
context.go('/home');
await _navigateAfterLogin();
}
}
void _handleOneClickLogin() {
debugPrint('[Login] _handleOneClickLogin() agreed=$_agreed');
if (!_agreed) {
_showAgreementDialog(action: 'oneclick');
return;
@ -269,7 +274,25 @@ class _LoginPageState extends ConsumerState<LoginPage> {
.read(authControllerProvider.notifier)
.codeLogin(_phoneController.text, _codeController.text);
if (success && mounted) {
context.go('/home');
await _navigateAfterLogin();
}
}
Future<void> _navigateAfterLogin() async {
if (!mounted) return;
try {
final devices = await ref.read(deviceControllerProvider.future);
if (!mounted) return;
if (devices.isNotEmpty) {
debugPrint('[Login] User has ${devices.length} device(s), navigating to device control');
context.go('/device-control');
} else {
debugPrint('[Login] No devices, navigating to home');
context.go('/home');
}
} catch (e) {
debugPrint('[Login] Device check failed: $e');
if (mounted) context.go('/home');
}
}

View File

@ -1,13 +1,19 @@
import 'dart:async';
import 'dart:math';
import 'dart:io';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../core/services/ble_provisioning_service.dart';
import '../features/device/data/datasources/device_remote_data_source.dart';
import '../features/device/presentation/controllers/device_controller.dart';
import '../widgets/animated_gradient_background.dart';
import '../theme/app_colors.dart';
import '../widgets/gradient_button.dart';
import '../widgets/glass_dialog.dart';
///
enum DeviceType { plush, badgeAi, badge }
@ -16,14 +22,20 @@ enum DeviceType { plush, badgeAi, badge }
class MockDevice {
final String sn;
final String name;
final String macAddress;
final DeviceType type;
final bool hasAI;
final bool isNetworkRequired;
final BluetoothDevice? bleDevice;
const MockDevice({
required this.sn,
required this.name,
required this.macAddress,
required this.type,
required this.hasAI,
this.isNetworkRequired = true,
this.bleDevice,
});
String get iconPath {
@ -50,53 +62,39 @@ class MockDevice {
}
///
class BluetoothPage extends StatefulWidget {
class BluetoothPage extends ConsumerStatefulWidget {
const BluetoothPage({super.key});
@override
State<BluetoothPage> createState() => _BluetoothPageState();
ConsumerState<BluetoothPage> createState() => _BluetoothPageState();
}
class _BluetoothPageState extends State<BluetoothPage>
class _BluetoothPageState extends ConsumerState<BluetoothPage>
with TickerProviderStateMixin {
/// Airhub 广: Airhub_ + MAC
static const _airhubPrefix = 'Airhub_';
//
bool _isSearching = true;
bool _isBluetoothOn = false;
List<MockDevice> _devices = [];
int _currentIndex = 0;
// MAC API
final Map<String, Map<String, dynamic>> _macInfoCache = {};
//
late AnimationController _searchAnimController;
//
late FixedExtentScrollController _wheelController;
//
static const List<MockDevice> _mockDevices = [
MockDevice(
sn: 'PLUSH_01',
name: '卡皮巴拉-001',
type: DeviceType.plush,
hasAI: true,
),
MockDevice(
sn: 'BADGE_01',
name: 'AI电子吧唧-001',
type: DeviceType.badgeAi,
hasAI: true,
),
MockDevice(
sn: 'BADGE_02',
name: '电子吧唧-001',
type: DeviceType.badge,
hasAI: false,
),
MockDevice(
sn: 'PLUSH_02',
name: '卡皮巴拉-002',
type: DeviceType.plush,
hasAI: true,
),
];
//
StreamSubscription<BluetoothAdapterState>? _bluetoothSubscription;
StreamSubscription<List<ScanResult>>? _scanSubscription;
//
bool _hasShownBluetoothDialog = false;
@override
void initState() {
@ -111,61 +109,315 @@ class _BluetoothPageState extends State<BluetoothPage>
//
_wheelController = FixedExtentScrollController(initialItem: _currentIndex);
//
_startSearch();
//
_listenBluetoothState();
}
@override
void dispose() {
_bluetoothSubscription?.cancel();
_scanSubscription?.cancel();
FlutterBluePlus.stopScan();
_searchAnimController.dispose();
_wheelController.dispose();
super.dispose();
}
/// ()
///
void _listenBluetoothState() {
_bluetoothSubscription = FlutterBluePlus.adapterState.listen((state) {
if (!mounted) return;
final isOn = state == BluetoothAdapterState.on;
setState(() => _isBluetoothOn = isOn);
if (isOn) {
_startSearch();
} else if (state == BluetoothAdapterState.off) {
FlutterBluePlus.stopScan();
setState(() {
_isSearching = false;
_devices.clear();
});
if (!_hasShownBluetoothDialog) {
_hasShownBluetoothDialog = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _showBluetoothOffDialog();
});
}
}
});
}
/// MAC : Airhub_XXXXXXXXXXXX Airhub_XX:XX:XX:XX:XX:XX
/// XX:XX:XX:XX:XX:XX null
String? _extractMacFromName(String bleName) {
if (!bleName.startsWith(_airhubPrefix)) return null;
final rawMac = bleName.substring(_airhubPrefix.length).trim();
if (rawMac.isEmpty) return null;
// /
final hex = rawMac.replaceAll(RegExp(r'[:\-]'), '').toUpperCase();
if (hex.length != 12 || !RegExp(r'^[0-9A-F]{12}$').hasMatch(hex)) {
debugPrint('[BLE Scan] MAC 格式异常: $rawMac');
return null;
}
// XX:XX:XX:XX:XX:XX
return '${hex.substring(0, 2)}:${hex.substring(2, 4)}:${hex.substring(4, 6)}:'
'${hex.substring(6, 8)}:${hex.substring(8, 10)}:${hex.substring(10, 12)}';
}
// API Airhub BLE
final Map<String, BluetoothDevice> _pendingBleDevices = {};
/// BLE API
Future<void> _startSearch() async {
//
if (!_isBluetoothOn) {
_showBluetoothOffDialog();
return;
}
await _requestPermissions();
// 2
await Future.delayed(const Duration(seconds: 2));
if (!mounted) return;
setState(() {
_isSearching = true;
_devices.clear();
_currentIndex = 0;
});
_pendingBleDevices.clear();
_scanSubscription?.cancel();
_scanSubscription = FlutterBluePlus.onScanResults.listen((results) {
if (!mounted) return;
for (final r in results) {
final name = r.device.platformName;
if (name.isEmpty) continue;
final mac = _extractMacFromName(name);
if (mac == null) continue;
// BLE
_pendingBleDevices[mac] = r.device;
// MAC API
if (!_macInfoCache.containsKey(mac)) {
_macInfoCache[mac] = {}; //
_queryDeviceByMac(mac);
}
}
});
// _queryDeviceByMac
await FlutterBluePlus.startScan(
timeout: const Duration(seconds: 30),
androidUsesFineLocation: true,
);
// 30
if (mounted && _isSearching) {
setState(() => _isSearching = false);
}
}
/// MAC API
///
Future<void> _queryDeviceByMac(String mac) async {
try {
final dataSource = ref.read(deviceRemoteDataSourceProvider);
debugPrint('[Bluetooth] queryByMac: $mac');
final data = await dataSource.queryByMac(mac);
debugPrint('[Bluetooth] queryByMac 返回: $data');
if (!mounted) return;
_macInfoCache[mac] = data;
final deviceTypeName = data['device_type']?['name'] as String? ?? '';
final sn = data['sn'] as String? ?? '';
final isNetworkRequired = data['device_type']?['is_network_required'] as bool? ?? true;
final bleDevice = _pendingBleDevices[mac];
// API
final displayName = deviceTypeName.isNotEmpty ? deviceTypeName : 'Airhub 设备';
if (mounted) {
// 1-4
final count = Random().nextInt(4) + 1;
setState(() {
_devices = _mockDevices.take(count).toList();
//
if (!_devices.any((d) => d.macAddress == mac)) {
_devices.add(MockDevice(
sn: sn,
name: displayName,
macAddress: mac,
type: _inferDeviceType(displayName),
hasAI: _inferHasAI(displayName),
isNetworkRequired: isNetworkRequired,
bleDevice: bleDevice,
));
}
//
_isSearching = false;
});
//
try { await FlutterBluePlus.stopScan(); } catch (_) {}
debugPrint('[Bluetooth] 设备已就绪: $mac$displayName');
} catch (e) {
debugPrint('[Bluetooth] queryByMac 失败($mac): $e');
// API BLE fallback
if (!mounted) return;
final bleDevice = _pendingBleDevices[mac];
setState(() {
if (!_devices.any((d) => d.macAddress == mac)) {
_devices.add(MockDevice(
sn: '',
name: '${_airhubPrefix}设备',
macAddress: mac,
type: DeviceType.plush,
hasAI: true,
bleDevice: bleDevice,
));
}
_isSearching = false;
});
try { await FlutterBluePlus.stopScan(); } catch (_) {}
}
}
/// mock
///
DeviceType _inferDeviceType(String name) {
final lower = name.toLowerCase();
if (lower.contains('plush') || lower.contains('卡皮巴拉') || lower.contains('机芯') || lower.contains('airhub')) {
return DeviceType.plush;
}
if (lower.contains('ai') || lower.contains('智能')) {
return DeviceType.badgeAi;
}
return DeviceType.badge;
}
/// AI
bool _inferHasAI(String name) {
final lower = name.toLowerCase();
return lower.contains('ai') || lower.contains('plush') ||
lower.contains('卡皮巴拉') || lower.contains('机芯') || lower.contains('智能') || lower.contains('airhub');
}
///
Future<void> _requestPermissions() async {
try {
await Permission.bluetooth.request();
await Permission.bluetoothScan.request();
await Permission.bluetoothConnect.request();
await Permission.location.request();
} catch (_) {
// mock
if (Platform.isAndroid) {
// Android BLE
await Permission.bluetoothScan.request();
await Permission.bluetoothConnect.request();
await Permission.location.request();
} else {
// iOS
await Permission.bluetooth.request();
}
} catch (e) {
debugPrint('[Bluetooth] 权限请求异常: $e');
}
}
///
void _showBluetoothOffDialog() {
if (!mounted) return;
showGlassDialog(
context: context,
title: '蓝牙未开启',
description: '请开启蓝牙以搜索附近的设备',
cancelText: '取消',
confirmText: Platform.isAndroid ? '开启蓝牙' : '去设置',
onConfirm: () {
Navigator.of(context).pop();
if (Platform.isAndroid) {
// Android
FlutterBluePlus.turnOn();
} else {
// iOS
openAppSettings();
}
},
);
}
bool _isConnecting = false;
///
void _handleConnect() {
if (_devices.isEmpty) return;
Future<void> _handleConnect() async {
if (_devices.isEmpty || _isConnecting) return;
//
if (!_isBluetoothOn) {
_showBluetoothOffDialog();
return;
}
final device = _devices[_currentIndex];
// TODO:
debugPrint('[Bluetooth] 连接设备: sn=${device.sn}, mac=${device.macAddress}, needsNetwork=${device.isNetworkRequired}');
if (device.type == DeviceType.badge) {
// ->
if (!device.isNetworkRequired) {
// ->
context.go('/device-control');
} else {
// -> WiFi
context.go('/wifi-config');
return;
}
// Web BLE WiFi
if (kIsWeb) {
debugPrint('[Bluetooth] Web 环境,直接绑定 sn=${device.sn}');
setState(() => _isConnecting = true);
if (device.sn.isNotEmpty) {
await ref.read(deviceControllerProvider.notifier).bindDevice(device.sn);
}
if (!mounted) return;
setState(() => _isConnecting = false);
context.go('/device-control');
return;
}
// -> BLE WiFi
final bleDevice = device.bleDevice;
if (bleDevice == null) {
debugPrint('[Bluetooth] 无 BLE 句柄,无法连接');
return;
}
setState(() => _isConnecting = true);
// iOS
try {
await FlutterBluePlus.stopScan();
} catch (_) {}
await Future.delayed(const Duration(milliseconds: 300));
final provService = BleProvisioningService();
final ok = await provService.connect(bleDevice);
if (!mounted) return;
setState(() => _isConnecting = false);
if (!ok) {
showGlassDialog(
context: context,
title: '连接失败',
description: '无法连接到设备,请确认设备已开机并靠近手机',
confirmText: '确定',
onConfirm: () => Navigator.of(context).pop(),
);
return;
}
// BLE WiFi service
context.go('/wifi-config', extra: {
'provService': provService,
'sn': device.sn,
'name': device.name,
'mac': device.macAddress,
'type': device.type.name,
});
}
@override
@ -564,10 +816,10 @@ class _BluetoothPageState extends State<BluetoothPage>
if (!_isSearching && _devices.isNotEmpty) ...[
const SizedBox(width: 16), // HTML: gap 16px
GradientButton(
text: '连接设备',
text: _isConnecting ? '连接中...' : '连接设备',
width: 180,
height: 52,
onPressed: _handleConnect,
onPressed: _isConnecting ? null : _handleConnect,
),
],
],

View File

@ -35,7 +35,12 @@ class _ProfileInfoPageState extends ConsumerState<ProfileInfoPage> {
super.dispose();
}
static const _genderToDisplay = {'male': '', 'female': ''};
static const _genderToDisplay = {
'male': '',
'female': '',
'M': '',
'F': '',
};
static const _displayToGender = {'': 'male', '': 'female'};
void _initFromUser() {

View File

@ -4,12 +4,15 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../core/services/ble_provisioning_service.dart';
import '../features/device/presentation/controllers/device_controller.dart';
import '../widgets/animated_gradient_background.dart';
import '../widgets/gradient_button.dart';
import '../features/device/presentation/controllers/device_controller.dart';
class WifiConfigPage extends ConsumerStatefulWidget {
const WifiConfigPage({super.key});
final Map<String, dynamic>? extra;
const WifiConfigPage({super.key, this.extra});
@override
ConsumerState<WifiConfigPage> createState() => _WifiConfigPageState();
@ -25,36 +28,112 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
// Progress State
double _progress = 0.0;
String _progressText = '正在连接WiFi...';
bool _connectFailed = false;
// Device Info (Mock or from Route Args)
// We'll try to get it from arguments, default to a fallback
// Device Info
Map<String, dynamic> _deviceInfo = {};
// Mock WiFi List
final List<Map<String, dynamic>> _wifiList = [
{'ssid': 'Home_5G', 'level': 4},
{'ssid': 'Office_WiFi', 'level': 3},
{'ssid': 'Guest_Network', 'level': 2},
];
// BLE Provisioning
BleProvisioningService? _provService;
List<ScannedWifi> _wifiList = [];
bool _isScanning = false;
// Subscriptions
StreamSubscription? _wifiListSub;
StreamSubscription? _wifiStatusSub;
StreamSubscription? _disconnectSub;
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Retrieve device info from arguments
final args = ModalRoute.of(context)?.settings.arguments;
if (args is Map<String, dynamic>) {
_deviceInfo = args;
void initState() {
super.initState();
_deviceInfo = widget.extra ?? {};
_provService = _deviceInfo['provService'] as BleProvisioningService?;
if (_provService != null) {
_setupBleListeners();
// WiFi
_requestWifiScan();
}
}
void _handleNext() {
@override
void dispose() {
_wifiListSub?.cancel();
_wifiStatusSub?.cancel();
_disconnectSub?.cancel();
_provService?.dispose();
_passwordController.dispose();
super.dispose();
}
void _setupBleListeners() {
// WiFi
_wifiListSub = _provService!.onWifiList.listen((list) {
if (!mounted) return;
debugPrint('[WiFi Config] 收到 WiFi 列表: ${list.length}');
setState(() {
_wifiList = list;
_isScanning = false;
});
});
// WiFi
_wifiStatusSub = _provService!.onWifiStatus.listen((result) {
if (!mounted) return;
debugPrint('[WiFi Config] WiFi 状态: success=${result.success}, reason=${result.reasonCode}');
if (result.success) {
setState(() {
_progress = 1.0;
_progressText = '配网成功!';
_currentStep = 4;
});
} else {
setState(() {
_connectFailed = true;
_progressText = '连接失败 (错误码: ${result.reasonCode})';
});
}
});
// BLE
_disconnectSub = _provService!.onDisconnect.listen((_) {
if (!mounted) return;
debugPrint('[WiFi Config] BLE 连接已断开');
//
if (_currentStep == 3 && !_connectFailed) {
setState(() {
_progress = 1.0;
_progressText = '设备正在重启...';
_currentStep = 4;
});
}
});
}
Future<void> _requestWifiScan() async {
if (_provService == null) return;
setState(() => _isScanning = true);
await _provService!.requestWifiScan();
// WiFi onWifiList stream
// 10
Future.delayed(const Duration(seconds: 10), () {
if (mounted && _isScanning) {
setState(() => _isScanning = false);
}
});
}
Future<void> _handleNext() async {
if (_currentStep == 1 && _selectedWifiSsid.isEmpty) return;
if (_currentStep == 2 && _passwordController.text.isEmpty) return;
if (_currentStep == 4) {
// Navigate to Device Control
// Use pushNamedAndRemoveUntil to remove Bluetooth and WiFi pages from stack
// but keep Home page so back button goes to Home
final sn = _deviceInfo['sn'] as String? ?? '';
if (sn.isNotEmpty) {
debugPrint('[WiFi Config] Binding device sn=$sn');
await ref.read(deviceControllerProvider.notifier).bindDevice(sn);
}
if (!mounted) return;
context.go('/device-control');
return;
}
@ -72,13 +151,58 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
if (_currentStep > 1) {
setState(() {
_currentStep--;
if (_currentStep == 1) {
_connectFailed = false;
_progress = 0.0;
}
});
} else {
context.go('/home');
_provService?.disconnect();
context.go('/bluetooth');
}
}
void _startConnecting() {
Future<void> _startConnecting() async {
setState(() {
_progress = 0.1;
_progressText = '正在发送WiFi信息...';
_connectFailed = false;
});
if (_provService != null && _provService!.isConnected) {
// BLE WiFi
setState(() {
_progress = 0.3;
_progressText = '正在发送WiFi凭证...';
});
await _provService!.sendWifiCredentials(
_selectedWifiSsid,
_passwordController.text,
);
setState(() {
_progress = 0.5;
_progressText = '等待设备连接WiFi...';
});
// WiFi onWifiStatus stream
// 60
Future.delayed(const Duration(seconds: 60), () {
if (mounted && _currentStep == 3 && !_connectFailed) {
setState(() {
_connectFailed = true;
_progressText = '连接超时,请重试';
});
}
});
} else {
// BLE 使 mock
_startMockConnecting();
}
}
void _startMockConnecting() {
const steps = [
{'progress': 0.3, 'text': '正在连接WiFi...'},
{'progress': 0.6, 'text': '正在验证密码...'},
@ -98,27 +222,13 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
stepIndex++;
} else {
timer.cancel();
// Record WiFi config on server
_recordWifiConfig();
if (mounted) {
setState(() {
_currentStep = 4;
});
setState(() => _currentStep = 4);
}
}
});
}
Future<void> _recordWifiConfig() async {
final userDeviceId = _deviceInfo['userDeviceId'] as int?;
if (userDeviceId != null && _selectedWifiSsid.isNotEmpty) {
final controller = ref.read(
deviceDetailControllerProvider(userDeviceId).notifier,
);
await controller.configWifi(_selectedWifiSsid);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -126,34 +236,24 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
resizeToAvoidBottomInset: true,
body: Stack(
children: [
// Background
_buildGradientBackground(),
const AnimatedGradientBackground(),
Positioned.fill(
child: SafeArea(
child: Column(
children: [
// Header
_buildHeader(),
// Content
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
children: [
// Steps Indicator
_buildStepIndicator(),
const SizedBox(height: 32),
// Dynamic Step Content
_buildCurrentStepContent(),
],
),
),
),
// Footer
_buildFooter(),
],
),
@ -164,17 +264,11 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
);
}
// Common Gradient Background
Widget _buildGradientBackground() {
return const AnimatedGradientBackground();
}
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Row(
children: [
// Back button - HTML: bg rgba(255,255,255,0.6), border-radius: 12px, color #4B5563
GestureDetector(
onTap: _handleBack,
child: Container(
@ -187,7 +281,7 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
child: const Icon(
Icons.arrow_back_ios_new,
size: 18,
color: Color(0xFF4B5563), // Gray per HTML, not purple
color: Color(0xFF4B5563),
),
),
),
@ -202,7 +296,7 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
),
),
),
const SizedBox(width: 48), // Balance back button
const SizedBox(width: 48),
],
),
);
@ -223,10 +317,10 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
margin: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
color: isCompleted
? const Color(0xFF22C55E) // Green for completed
? const Color(0xFF22C55E)
: isActive
? const Color(0xFF8B5CF6) // Purple for active
: const Color(0xFF8B5CF6).withOpacity(0.3), // Faded purple
? const Color(0xFF8B5CF6)
: const Color(0xFF8B5CF6).withOpacity(0.3),
borderRadius: BorderRadius.circular(4),
),
);
@ -249,11 +343,10 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
}
}
// Step 1: Select Network
// Step 1: WiFi
Widget _buildStep1() {
return Column(
children: [
// Icon
Container(
margin: const EdgeInsets.only(bottom: 24),
child: const Icon(Icons.wifi, size: 80, color: Color(0xFF8B5CF6)),
@ -269,27 +362,74 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
const SizedBox(height: 8),
const Text(
'设备需要连接WiFi以使用AI功能',
style: TextStyle(
fontSize: 14,
color: Color(0xFF6B7280),
),
style: TextStyle(fontSize: 14, color: Color(0xFF6B7280)),
),
const SizedBox(height: 24),
// List
Column(
children: _wifiList.map((wifi) => _buildWifiItem(wifi)).toList(),
),
if (_isScanning)
const Padding(
padding: EdgeInsets.symmetric(vertical: 40),
child: Column(
children: [
CircularProgressIndicator(color: Color(0xFF8B5CF6)),
SizedBox(height: 16),
Text(
'正在通过设备扫描WiFi...',
style: TextStyle(fontSize: 14, color: Color(0xFF6B7280)),
),
],
),
)
else if (_wifiList.isEmpty)
Column(
children: [
const Padding(
padding: EdgeInsets.symmetric(vertical: 40),
child: Text(
'未扫描到WiFi网络',
style: TextStyle(fontSize: 14, color: Color(0xFF9CA3AF)),
),
),
GestureDetector(
onTap: _requestWifiScan,
child: const Text(
'重新扫描',
style: TextStyle(
fontSize: 14,
color: Color(0xFF8B5CF6),
decoration: TextDecoration.underline,
),
),
),
],
)
else
Column(
children: [
..._wifiList.map((wifi) => _buildWifiItem(wifi)),
const SizedBox(height: 8),
GestureDetector(
onTap: _requestWifiScan,
child: const Text(
'重新扫描',
style: TextStyle(
fontSize: 14,
color: Color(0xFF8B5CF6),
decoration: TextDecoration.underline,
),
),
),
],
),
],
);
}
Widget _buildWifiItem(Map<String, dynamic> wifi) {
bool isSelected = _selectedWifiSsid == wifi['ssid'];
Widget _buildWifiItem(ScannedWifi wifi) {
bool isSelected = _selectedWifiSsid == wifi.ssid;
return GestureDetector(
onTap: () {
setState(() => _selectedWifiSsid = wifi['ssid']);
setState(() => _selectedWifiSsid = wifi.ssid);
},
child: Container(
padding: const EdgeInsets.all(16),
@ -317,27 +457,23 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
children: [
Expanded(
child: Text(
wifi['ssid'],
wifi.ssid,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Color(0xFF1F2937),
),
),
),
// HTML uses per-level SVG icons: wifi-1.svg to wifi-4.svg
Opacity(
opacity: 0.8,
child: SvgPicture.asset(
'assets/www/icons/wifi-${wifi['level']}.svg',
width: 24,
height: 24,
colorFilter: const ColorFilter.mode(
Color(0xFF6B7280),
BlendMode.srcIn,
),
),
// WiFi
Icon(
wifi.level >= 3
? Icons.wifi
: wifi.level == 2
? Icons.wifi_2_bar
: Icons.wifi_1_bar,
size: 24,
color: const Color(0xFF6B7280),
),
],
),
@ -345,7 +481,7 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
);
}
// Step 2: Enter Password
// Step 2:
Widget _buildStep2() {
return Column(
children: [
@ -366,16 +502,11 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
),
),
const SizedBox(height: 8),
Text(
const Text(
'请输入WiFi密码',
style: TextStyle(
fontSize: 14,
color: const Color(0xFF6B7280),
),
style: TextStyle(fontSize: 14, color: Color(0xFF6B7280)),
),
const SizedBox(height: 24),
TextField(
controller: _passwordController,
obscureText: _obscurePassword,
@ -398,9 +529,7 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
size: 22,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
setState(() => _obscurePassword = !_obscurePassword);
},
),
),
@ -411,11 +540,10 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
);
}
// Step 3: Connecting
// Step 3:
Widget _buildStep3() {
return Column(
children: [
// Animation placeholder (using Icon for now, can be upgraded to Wave animation)
SizedBox(
height: 120,
child: Center(
@ -429,8 +557,7 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
color: const Color(0xFF8B5CF6).withOpacity(1 - value * 0.5),
);
},
onEnd:
() {}, // Repeat logic usually handled by AnimationController
onEnd: () {},
),
),
),
@ -443,8 +570,6 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
),
),
const SizedBox(height: 32),
// Progress Bar
ClipRRect(
borderRadius: BorderRadius.circular(3),
child: SizedBox(
@ -452,7 +577,9 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
child: LinearProgressIndicator(
value: _progress,
backgroundColor: const Color(0xFF8B5CF6).withOpacity(0.2),
valueColor: const AlwaysStoppedAnimation(Color(0xFF8B5CF6)),
valueColor: AlwaysStoppedAnimation(
_connectFailed ? const Color(0xFFEF4444) : const Color(0xFF8B5CF6),
),
),
),
),
@ -460,25 +587,42 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
Text(
_progressText,
style: TextStyle(
fontSize: 14,
color: const Color(0xFF6B7280),
color: _connectFailed ? const Color(0xFFEF4444) : const Color(0xFF6B7280),
),
),
if (_connectFailed) ...[
const SizedBox(height: 24),
GestureDetector(
onTap: () {
setState(() {
_currentStep = 1;
_connectFailed = false;
_progress = 0.0;
});
},
child: const Text(
'返回重新选择',
style: TextStyle(
fontSize: 14,
color: Color(0xFF8B5CF6),
decoration: TextDecoration.underline,
),
),
),
],
],
);
}
// Get device icon path based on device type
String _getDeviceIconPath() {
final type = _deviceInfo['type'] as String? ?? 'plush';
switch (type) {
case 'plush_core':
case 'plush':
return 'assets/www/icons/pixel-capybara.svg';
case 'badge_ai':
case 'badgeAi':
return 'assets/www/icons/pixel-badge-ai.svg';
case 'badge_basic':
case 'badge':
return 'assets/www/icons/pixel-badge-basic.svg';
default:
@ -486,17 +630,15 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
}
}
// Step 4: Result (Success) - centered vertically
// Step 4:
Widget _buildStep4() {
return Column(
children: [
const SizedBox(height: 80),
// Success Icon Stack - HTML: no white background
Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
// Device icon container - 120x120 per HTML
SizedBox(
width: 120,
height: 120,
@ -511,7 +653,6 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
),
),
),
// Check badge
Positioned(
bottom: -5,
right: -5,
@ -545,13 +686,9 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
),
),
const SizedBox(height: 8),
Text(
const Text(
'设备已成功连接到网络',
style: TextStyle(
fontSize: 14,
color: const Color(0xFF6B7280),
),
style: TextStyle(fontSize: 14, color: Color(0xFF6B7280)),
),
],
);
@ -572,24 +709,24 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
}
if (!showNext && _currentStep != 3) {
// Show cancel only?
return Padding(
padding: const EdgeInsets.all(32),
child: TextButton(
onPressed: () => context.go('/bluetooth'),
child: Text(
onPressed: () {
_provService?.disconnect();
context.go('/bluetooth');
},
child: const Text(
'取消',
style: TextStyle(
color: const Color(0xFF6B7280),
),
style: TextStyle(color: Color(0xFF6B7280)),
),
),
);
}
if (_currentStep == 3)
return const SizedBox(height: 100); // Hide buttons during connection
if (_currentStep == 3) {
return const SizedBox(height: 100);
}
return Container(
padding: EdgeInsets.fromLTRB(
@ -601,7 +738,6 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Cancel button - frosted glass style
if (_currentStep < 4)
GestureDetector(
onTap: _handleBack,
@ -626,8 +762,6 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
),
),
if (_currentStep < 4) const SizedBox(width: 16),
// Constrained button (not full-width)
GradientButton(
text: nextText,
onPressed: _handleNext,

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"
source: hosted
version: "91.0.0"
ali_auth:
dependency: "direct main"
description:
path: "packages/ali_auth"
relative: true
source: path
version: "1.3.7"
analyzer:
dependency: transitive
description:

View File

@ -54,8 +54,8 @@ dependencies:
dio: ^5.7.0
shared_preferences: ^2.3.0
# Aliyun Phone Auth (一键登录)
ali_auth: ^1.3.7
# Aliyun Phone Auth (一键登录) — 本地 Web 调试时禁用
# ali_auth: ^1.3.7
# Existing dependencies
webview_flutter: ^4.4.2
@ -66,10 +66,6 @@ dependencies:
image_picker: ^1.2.1
just_audio: ^0.9.42
dependency_overrides:
ali_auth:
path: packages/ali_auth
flutter:
uses-material-design: true
assets:

130
本地localhost运行.md Normal file
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 秒