feat: Flutter 客户端 App + Web UI APK 下载入口
- 新增 Flutter 跨平台客户端项目 (clients/flutter/)
- 29 个 Dart 文件: 服务层/状态管理/5个页面/BLE配网
- BLE 蓝牙配网: 扫描设备、写入WiFi凭据、配网状态监听
- HTTP API 客户端: 覆盖全部端点 (播放/场景/WiFi/视频/配置/文件/插件)
- WebSocket 实时通信: 事件流 + 自动重连
- 暗色主题 Material 3 UI, 中文界面
- Android 配置: minSdkVersion 21, BLE/网络权限
- PRD 产品需求文档 + 开发任务看板
- Web UI 添加 APK 下载入口 (routes.rs)
- 下载弹窗 + 二维码 + /download/{filename} 静态文件路由
- BLE 插件增加自动重连循环 (ble/mod.rs)
- BLE 默认设备名修正为 'Showen' (config.rs)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
597
clients/flutter/lib/screens/ble_provision_screen.dart
Normal file
597
clients/flutter/lib/screens/ble_provision_screen.dart
Normal file
@@ -0,0 +1,597 @@
|
||||
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.withOpacity(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.withOpacity(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.withOpacity(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.withOpacity(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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user