Files
ShowenV2/clients/flutter/lib/screens/ble_provision_screen.dart
showen 8ed9cb2d9d feat: Flutter APK 编译成功 + Gradle 配置修复 + APK 下载部署 + 待优化清单
- 通过 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>
2026-03-14 06:43:55 +08:00

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),
),
],
),
);
}
}