- 通过 qemu-user-static 实现 ARM64 主机编译 Android APK (51MB) - 修复 Gradle: Aliyun 镜像 + PREFER_SETTINGS + JVM 内存 1536M - 部署 APK 到 configs/downloads/, Web 下载接口已验证 (HTTP 200) - 新增 Flutter TODO.md: 10项待优化 (P0/P1/P2 分级) - 新增 pm_soul.md, 更新 routes.rs APK 下载路由 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
598 lines
18 KiB
Dart
598 lines
18 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../models/ble_models.dart';
|
|
import '../providers/ble_provider.dart';
|
|
|
|
class BleProvisionScreen extends StatefulWidget {
|
|
const BleProvisionScreen({super.key, this.provider});
|
|
|
|
final BleProvider? provider;
|
|
|
|
@override
|
|
State<BleProvisionScreen> createState() => _BleProvisionScreenState();
|
|
}
|
|
|
|
class _BleProvisionScreenState extends State<BleProvisionScreen> {
|
|
late final BleProvider _provider;
|
|
late final bool _ownsProvider;
|
|
final TextEditingController _ssidController = TextEditingController();
|
|
final TextEditingController _passwordController = TextEditingController();
|
|
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_ownsProvider = widget.provider == null;
|
|
_provider = widget.provider ?? BleProvider();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_provider.startScan();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_ssidController.dispose();
|
|
_passwordController.dispose();
|
|
if (_ownsProvider) {
|
|
_provider.dispose();
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(title: const Text('Showen BLE 配网')),
|
|
body: AnimatedBuilder(
|
|
animation: _provider,
|
|
builder: (BuildContext context, _) {
|
|
return Container(
|
|
decoration: const BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: <Color>[Color(0xFF0F172A), Color(0xFF111827)],
|
|
),
|
|
),
|
|
child: SafeArea(
|
|
child: ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: <Widget>[
|
|
_StatusBanner(provider: _provider),
|
|
const SizedBox(height: 16),
|
|
_ProgressCard(state: _provider.provisioningState),
|
|
const SizedBox(height: 16),
|
|
_buildDevicesCard(),
|
|
const SizedBox(height: 16),
|
|
_buildWifiCard(),
|
|
const SizedBox(height: 16),
|
|
_buildResultCard(),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDevicesCard() {
|
|
return _GlassCard(
|
|
title: '1. 扫描 Showen 设备',
|
|
trailing: TextButton.icon(
|
|
onPressed: _provider.isProvisioning ? null : _provider.retryScan,
|
|
icon: _provider.isScanning
|
|
? const SizedBox(
|
|
width: 14,
|
|
height: 14,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.refresh),
|
|
label: Text(_provider.isScanning ? '扫描中' : '重新扫描'),
|
|
),
|
|
child: Column(
|
|
children: <Widget>[
|
|
if (_provider.devices.isEmpty)
|
|
const _EmptyState(
|
|
icon: Icons.bluetooth_searching,
|
|
title: '未发现 Showen 设备',
|
|
subtitle: '请确认设备已开机,且蓝牙广播名称包含 Showen。',
|
|
)
|
|
else
|
|
..._provider.devices.map((BleDevice device) {
|
|
final bool selected = _provider.selectedDevice?.id == device.id;
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(14),
|
|
onTap: _provider.isConnecting || _provider.isProvisioning
|
|
? null
|
|
: () => _handleDeviceSelected(device),
|
|
child: Ink(
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(
|
|
color: selected
|
|
? const Color(0xFF22C55E)
|
|
: Colors.white24,
|
|
),
|
|
color: selected
|
|
? const Color(0x1A22C55E)
|
|
: const Color(0x141E293B),
|
|
),
|
|
padding: const EdgeInsets.all(14),
|
|
child: Row(
|
|
children: <Widget>[
|
|
Icon(
|
|
selected ? Icons.bluetooth_connected : Icons.bluetooth,
|
|
color: Colors.white,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Text(
|
|
device.name,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
device.id,
|
|
style: const TextStyle(
|
|
color: Color(0xFF94A3B8),
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
_SignalBadge(rssi: device.rssi),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildWifiCard() {
|
|
return _GlassCard(
|
|
title: '2. 输入 WiFi 凭据',
|
|
child: Form(
|
|
key: _formKey,
|
|
child: Column(
|
|
children: <Widget>[
|
|
TextFormField(
|
|
controller: _ssidController,
|
|
enabled: !_provider.isProvisioning,
|
|
style: const TextStyle(color: Colors.white),
|
|
decoration: _inputDecoration('WiFi SSID', Icons.wifi),
|
|
validator: (String? value) {
|
|
if ((value ?? '').trim().isEmpty) {
|
|
return '请输入 WiFi 名称';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextFormField(
|
|
controller: _passwordController,
|
|
enabled: !_provider.isProvisioning,
|
|
obscureText: true,
|
|
style: const TextStyle(color: Colors.white),
|
|
decoration: _inputDecoration('WiFi 密码', Icons.lock_outline),
|
|
),
|
|
const SizedBox(height: 16),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: FilledButton.icon(
|
|
onPressed: (_provider.selectedDevice == null ||
|
|
!_provider.isConnected ||
|
|
_provider.isProvisioning)
|
|
? null
|
|
: _handleProvisioning,
|
|
icon: _provider.isProvisioning
|
|
? const SizedBox(
|
|
width: 18,
|
|
height: 18,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.router_outlined),
|
|
label: Text(_provider.isProvisioning ? '配网中...' : '开始配网'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildResultCard() {
|
|
final BleStatus? status = _provider.latestStatus;
|
|
final bool success = _provider.provisioningState == ProvisioningState.success;
|
|
final bool failed = _provider.provisioningState == ProvisioningState.failed;
|
|
|
|
return _GlassCard(
|
|
title: '3. 配网结果',
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Row(
|
|
children: <Widget>[
|
|
Icon(
|
|
success
|
|
? Icons.check_circle_outline
|
|
: failed
|
|
? Icons.error_outline
|
|
: Icons.info_outline,
|
|
color: success
|
|
? const Color(0xFF22C55E)
|
|
: failed
|
|
? const Color(0xFFF87171)
|
|
: const Color(0xFF38BDF8),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
success
|
|
? 'WiFi 已连接成功'
|
|
: failed
|
|
? (_provider.errorMessage ?? '配网失败')
|
|
: '等待设备返回状态',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (status != null) ...<Widget>[
|
|
const SizedBox(height: 12),
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withValues(alpha: 0.18),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: Colors.white12),
|
|
),
|
|
child: Text(
|
|
'action: ${status.action}\nstate: ${status.state ?? '-'}\nerror: ${status.error ?? '-'}',
|
|
style: const TextStyle(
|
|
color: Color(0xFFCBD5E1),
|
|
height: 1.5,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
if (failed) ...<Widget>[
|
|
const SizedBox(height: 16),
|
|
OutlinedButton.icon(
|
|
onPressed: _provider.retryScan,
|
|
icon: const Icon(Icons.replay),
|
|
label: const Text('重试'),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _handleDeviceSelected(BleDevice device) async {
|
|
try {
|
|
await _provider.connectToDevice(device);
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('已连接 ${device.name}')),
|
|
);
|
|
} catch (error) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('连接失败: $error')),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _handleProvisioning() async {
|
|
if (!_formKey.currentState!.validate()) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await _provider.provisionWifi(
|
|
_ssidController.text.trim(),
|
|
_passwordController.text,
|
|
);
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('设备已完成 WiFi 配网')),
|
|
);
|
|
} catch (error) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('配网失败: $error')),
|
|
);
|
|
}
|
|
}
|
|
|
|
InputDecoration _inputDecoration(String label, IconData icon) {
|
|
return InputDecoration(
|
|
labelText: label,
|
|
labelStyle: const TextStyle(color: Color(0xFFCBD5E1)),
|
|
prefixIcon: Icon(icon, color: const Color(0xFF38BDF8)),
|
|
filled: true,
|
|
fillColor: const Color(0x141E293B),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: const BorderSide(color: Colors.white24),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: const BorderSide(color: Color(0xFF38BDF8), width: 1.2),
|
|
),
|
|
errorBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: const BorderSide(color: Color(0xFFF87171)),
|
|
),
|
|
focusedErrorBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: const BorderSide(color: Color(0xFFF87171), width: 1.2),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StatusBanner extends StatelessWidget {
|
|
const _StatusBanner({required this.provider});
|
|
|
|
final BleProvider provider;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final bool connected = provider.isConnected;
|
|
final String headline = connected
|
|
? '已连接 ${provider.selectedDevice?.name ?? 'Showen 设备'}'
|
|
: provider.isScanning
|
|
? '正在扫描附近 Showen 设备'
|
|
: '选择设备后即可开始 BLE 配网';
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.all(18),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(20),
|
|
gradient: const LinearGradient(
|
|
colors: <Color>[Color(0xFF0EA5E9), Color(0xFF22C55E)],
|
|
),
|
|
boxShadow: const <BoxShadow>[
|
|
BoxShadow(
|
|
color: Color(0x330EA5E9),
|
|
blurRadius: 24,
|
|
offset: Offset(0, 10),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
const Text(
|
|
'ShowenV2 BLE Provisioning',
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 22,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
headline,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
height: 1.5,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ProgressCard extends StatelessWidget {
|
|
const _ProgressCard({required this.state});
|
|
|
|
final ProvisioningState state;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final List<_StepMeta> steps = <_StepMeta>[
|
|
_StepMeta('扫描设备', state.index >= ProvisioningState.scanning.index),
|
|
_StepMeta('连接设备', state.index >= ProvisioningState.connecting.index),
|
|
_StepMeta('写入凭据', state.index >= ProvisioningState.writingCredentials.index),
|
|
_StepMeta('连接 WiFi', state.index >= ProvisioningState.connectingWifi.index),
|
|
_StepMeta('完成', state == ProvisioningState.success),
|
|
];
|
|
|
|
return _GlassCard(
|
|
title: '当前进度',
|
|
child: Wrap(
|
|
runSpacing: 12,
|
|
spacing: 12,
|
|
children: steps.map(( _StepMeta step) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
|
decoration: BoxDecoration(
|
|
color: step.active
|
|
? const Color(0x1A22C55E)
|
|
: Colors.black.withValues(alpha: 0.14),
|
|
borderRadius: BorderRadius.circular(999),
|
|
border: Border.all(
|
|
color: step.active ? const Color(0xFF22C55E) : Colors.white24,
|
|
),
|
|
),
|
|
child: Text(
|
|
step.label,
|
|
style: TextStyle(
|
|
color:
|
|
step.active ? const Color(0xFFF8FAFC) : const Color(0xFF94A3B8),
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StepMeta {
|
|
const _StepMeta(this.label, this.active);
|
|
|
|
final String label;
|
|
final bool active;
|
|
}
|
|
|
|
class _GlassCard extends StatelessWidget {
|
|
const _GlassCard({
|
|
required this.title,
|
|
required this.child,
|
|
this.trailing,
|
|
});
|
|
|
|
final String title;
|
|
final Widget child;
|
|
final Widget? trailing;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xB31E293B),
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(color: Colors.white10),
|
|
),
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Row(
|
|
children: <Widget>[
|
|
Expanded(
|
|
child: Text(
|
|
title,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
if (trailing != null) trailing!,
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
child,
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SignalBadge extends StatelessWidget {
|
|
const _SignalBadge({required this.rssi});
|
|
|
|
final int rssi;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final IconData icon = switch (rssi) {
|
|
>= -60 => Icons.network_wifi,
|
|
>= -75 => Icons.network_wifi_3_bar,
|
|
>= -90 => Icons.network_wifi_2_bar,
|
|
_ => Icons.network_wifi_1_bar,
|
|
};
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withValues(alpha: 0.2),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: <Widget>[
|
|
Icon(icon, color: const Color(0xFF38BDF8), size: 18),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
'$rssi dBm',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _EmptyState extends StatelessWidget {
|
|
const _EmptyState({
|
|
required this.icon,
|
|
required this.title,
|
|
required this.subtitle,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String title;
|
|
final String subtitle;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(20),
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withValues(alpha: 0.15),
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(color: Colors.white10),
|
|
),
|
|
child: Column(
|
|
children: <Widget>[
|
|
Icon(icon, color: const Color(0xFF38BDF8), size: 32),
|
|
const SizedBox(height: 10),
|
|
Text(
|
|
title,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
subtitle,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(color: Color(0xFF94A3B8), height: 1.5),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|