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:
51
clients/flutter/lib/screens/app_shell.dart
Normal file
51
clients/flutter/lib/screens/app_shell.dart
Normal file
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class AppShell extends StatelessWidget {
|
||||
const AppShell({required this.navigationShell, super.key});
|
||||
|
||||
final StatefulNavigationShell navigationShell;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: navigationShell,
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: navigationShell.currentIndex,
|
||||
onDestinationSelected: (index) {
|
||||
navigationShell.goBranch(
|
||||
index,
|
||||
initialLocation: index == navigationShell.currentIndex,
|
||||
);
|
||||
},
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
selectedIcon: Icon(Icons.home),
|
||||
label: '首页',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.play_circle_outline),
|
||||
selectedIcon: Icon(Icons.play_circle),
|
||||
label: '播放',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.bolt_outlined),
|
||||
selectedIcon: Icon(Icons.bolt),
|
||||
label: '触发',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.wifi_outlined),
|
||||
selectedIcon: Icon(Icons.wifi),
|
||||
label: '网络',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
label: '设置',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
146
clients/flutter/lib/screens/home_screen.dart
Normal file
146
clients/flutter/lib/screens/home_screen.dart
Normal file
@@ -0,0 +1,146 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../providers/device_provider.dart';
|
||||
import '../providers/player_provider.dart';
|
||||
import '../providers/wifi_provider.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../widgets/control_button.dart';
|
||||
import '../widgets/status_card.dart';
|
||||
|
||||
class HomeScreen extends StatelessWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final deviceProvider = context.watch<DeviceProvider>();
|
||||
final playerProvider = context.watch<PlayerProvider>();
|
||||
final wifiProvider = context.watch<WifiProvider>();
|
||||
final device = deviceProvider.status;
|
||||
final player = playerProvider.status;
|
||||
final wifi = wifiProvider.status;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('ShowenV2 控制台')),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
await Future.wait<void>([
|
||||
context.read<DeviceProvider>().refresh(),
|
||||
context.read<PlayerProvider>().bootstrap(),
|
||||
context.read<WifiProvider>().bootstrap(),
|
||||
]);
|
||||
},
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
children: [
|
||||
StatusCard(
|
||||
title: '设备连接',
|
||||
value: device.connected ? '已连接' : '未连接',
|
||||
subtitle: '${device.ipAddress ?? deviceProvider.deviceIp} · ${device.connectionType.toUpperCase()}',
|
||||
icon: Icons.devices_rounded,
|
||||
accentColor: device.connected ? AppColors.success : AppColors.warning,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
StatusCard(
|
||||
title: '当前播放状态',
|
||||
value: player.currentVideo ?? '暂无播放视频',
|
||||
subtitle: player.running
|
||||
? (player.paused ? '已暂停' : '播放中')
|
||||
: '等待播放',
|
||||
icon: Icons.play_circle_outline_rounded,
|
||||
accentColor: player.paused ? AppColors.warning : AppColors.primary,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
StatusCard(
|
||||
title: 'WiFi 摘要',
|
||||
value: wifi.connected ? (wifi.ssid ?? '已连接') : '未连接网络',
|
||||
subtitle: wifi.ip ?? '可通过热点或 BLE 配网',
|
||||
icon: Icons.wifi_rounded,
|
||||
accentColor: wifi.connected ? AppColors.info : AppColors.border,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Text('快捷控制', style: Theme.of(context).textTheme.headlineSmall),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ControlButton(
|
||||
label: player.running && !player.paused ? '暂停' : '播放',
|
||||
icon: player.running && !player.paused
|
||||
? Icons.pause_rounded
|
||||
: Icons.play_arrow_rounded,
|
||||
onPressed: playerProvider.isLoading
|
||||
? null
|
||||
: () => context.read<PlayerProvider>().togglePlayPause(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: ControlButton(
|
||||
label: '上一个',
|
||||
icon: Icons.skip_previous_rounded,
|
||||
isFilled: false,
|
||||
onPressed: playerProvider.isLoading
|
||||
? null
|
||||
: () => context.read<PlayerProvider>().previous(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: ControlButton(
|
||||
label: '下一个',
|
||||
icon: Icons.skip_next_rounded,
|
||||
isFilled: false,
|
||||
onPressed: playerProvider.isLoading
|
||||
? null
|
||||
: () => context.read<PlayerProvider>().next(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('连接详情', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_InfoRow(label: '设备 IP', value: device.ipAddress ?? deviceProvider.deviceIp),
|
||||
_InfoRow(label: '连接方式', value: device.connectionType.toUpperCase()),
|
||||
_InfoRow(
|
||||
label: '实时通道',
|
||||
value: deviceProvider.webSocketConnected ? 'WebSocket 已连接' : 'WebSocket 重连中',
|
||||
),
|
||||
_InfoRow(label: '播放索引', value: '${player.currentIndex + 1}/${player.playlistLength}'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
const _InfoRow({required this.label, required this.value});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(label, style: Theme.of(context).textTheme.bodyMedium)),
|
||||
Text(value, style: Theme.of(context).textTheme.bodyLarge),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
201
clients/flutter/lib/screens/network_screen.dart
Normal file
201
clients/flutter/lib/screens/network_screen.dart
Normal file
@@ -0,0 +1,201 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../providers/device_provider.dart';
|
||||
import '../providers/wifi_provider.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../widgets/control_button.dart';
|
||||
import '../widgets/status_card.dart';
|
||||
import '../widgets/wifi_list_tile.dart';
|
||||
|
||||
class NetworkScreen extends StatefulWidget {
|
||||
const NetworkScreen({super.key});
|
||||
|
||||
@override
|
||||
State<NetworkScreen> createState() => _NetworkScreenState();
|
||||
}
|
||||
|
||||
class _NetworkScreenState extends State<NetworkScreen> {
|
||||
final TextEditingController _ssidController = TextEditingController();
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
final TextEditingController _apSsidController = TextEditingController(text: 'showen');
|
||||
final TextEditingController _apPasswordController = TextEditingController(text: '12345678');
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ssidController.dispose();
|
||||
_passwordController.dispose();
|
||||
_apSsidController.dispose();
|
||||
_apPasswordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final wifiProvider = context.watch<WifiProvider>();
|
||||
final deviceProvider = context.watch<DeviceProvider>();
|
||||
final wifiStatus = wifiProvider.status;
|
||||
final bleStatus = deviceProvider.status.bleStatus;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('网络设置')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
children: [
|
||||
StatusCard(
|
||||
title: 'WiFi 状态',
|
||||
value: wifiStatus.connected ? (wifiStatus.ssid ?? '已连接') : '未连接',
|
||||
subtitle: wifiStatus.ip ?? '尚未获取 IP 地址',
|
||||
icon: Icons.router_rounded,
|
||||
accentColor: wifiStatus.connected ? AppColors.success : AppColors.warning,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('连接 WiFi', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
TextField(
|
||||
controller: _ssidController,
|
||||
decoration: const InputDecoration(labelText: 'WiFi 名称'),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(labelText: 'WiFi 密码'),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: wifiProvider.isLoading ? null : _handleConnectWifi,
|
||||
icon: const Icon(Icons.wifi_password_rounded),
|
||||
label: const Text('连接当前 WiFi'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ControlButton(
|
||||
label: '扫描 WiFi',
|
||||
icon: Icons.wifi_find_rounded,
|
||||
onPressed: wifiProvider.isLoading
|
||||
? null
|
||||
: () => context.read<WifiProvider>().scanNetworks(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: ControlButton(
|
||||
label: bleStatus?.running == true ? 'BLE 已就绪' : '启动 BLE',
|
||||
icon: Icons.bluetooth_rounded,
|
||||
isFilled: false,
|
||||
onPressed: deviceProvider.isLoading
|
||||
? null
|
||||
: () => context.read<DeviceProvider>().startBle(deviceName: 'showen'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Text('扫描结果', style: Theme.of(context).textTheme.headlineSmall),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
if (wifiProvider.networks.isEmpty)
|
||||
const Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(AppSpacing.md),
|
||||
child: Text('暂无扫描结果'),
|
||||
),
|
||||
),
|
||||
...wifiProvider.networks.map(
|
||||
(network) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||
child: WifiListTile(
|
||||
network: network,
|
||||
onTap: () {
|
||||
_ssidController.text = network.ssid;
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text('热点开关', style: Theme.of(context).textTheme.titleMedium),
|
||||
),
|
||||
Switch(
|
||||
value: wifiProvider.hotspotEnabled,
|
||||
onChanged: (enabled) {
|
||||
if (enabled) {
|
||||
context.read<WifiProvider>().startHotspot(
|
||||
ssid: _apSsidController.text.trim(),
|
||||
password: _apPasswordController.text,
|
||||
);
|
||||
return;
|
||||
}
|
||||
context.read<WifiProvider>().stopHotspot();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
TextField(
|
||||
controller: _apSsidController,
|
||||
decoration: const InputDecoration(labelText: '热点 SSID'),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
TextField(
|
||||
controller: _apPasswordController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(labelText: '热点密码'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: () => context.push('/network/ble-provision'),
|
||||
child: const Text('进入 BLE 配网页面'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleConnectWifi() {
|
||||
final ssid = _ssidController.text.trim();
|
||||
if (ssid.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('请输入 WiFi 名称')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
context.read<WifiProvider>().connect(
|
||||
ssid: ssid,
|
||||
password: _passwordController.text,
|
||||
);
|
||||
}
|
||||
}
|
||||
182
clients/flutter/lib/screens/playback_screen.dart
Normal file
182
clients/flutter/lib/screens/playback_screen.dart
Normal file
@@ -0,0 +1,182 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../providers/player_provider.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../widgets/control_button.dart';
|
||||
|
||||
class PlaybackScreen extends StatefulWidget {
|
||||
const PlaybackScreen({super.key});
|
||||
|
||||
@override
|
||||
State<PlaybackScreen> createState() => _PlaybackScreenState();
|
||||
}
|
||||
|
||||
class _PlaybackScreenState extends State<PlaybackScreen> {
|
||||
final TextEditingController _indexController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_indexController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = context.watch<PlayerProvider>();
|
||||
final status = provider.status;
|
||||
final playlist = provider.playlist;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('播放控制')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
status.currentVideo ?? '暂无播放内容',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
SizedBox(
|
||||
width: 132,
|
||||
height: 132,
|
||||
child: DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: AppColors.primaryGradient,
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed: provider.isLoading
|
||||
? null
|
||||
: () => context.read<PlayerProvider>().togglePlayPause(),
|
||||
iconSize: 56,
|
||||
color: Colors.white,
|
||||
icon: Icon(
|
||||
status.running && !status.paused
|
||||
? Icons.pause_rounded
|
||||
: Icons.play_arrow_rounded,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Text(
|
||||
status.running ? (status.paused ? '已暂停' : '播放中') : '未开始播放',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ControlButton(
|
||||
label: '上一个',
|
||||
icon: Icons.skip_previous_rounded,
|
||||
isFilled: false,
|
||||
onPressed: provider.isLoading
|
||||
? null
|
||||
: () => context.read<PlayerProvider>().previous(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: ControlButton(
|
||||
label: '下一个',
|
||||
icon: Icons.skip_next_rounded,
|
||||
isFilled: false,
|
||||
onPressed: provider.isLoading
|
||||
? null
|
||||
: () => context.read<PlayerProvider>().next(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('跳转到指定索引', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _indexController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '输入 0 开始的索引',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
FilledButton(
|
||||
onPressed: provider.isLoading ? null : _handleGoto,
|
||||
child: const Text('跳转'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Text('播放列表', style: Theme.of(context).textTheme.headlineSmall),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
if (playlist.isEmpty)
|
||||
const Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(AppSpacing.md),
|
||||
child: Text('当前没有可播放视频'),
|
||||
),
|
||||
),
|
||||
...playlist.asMap().entries.map((entry) {
|
||||
final selected = entry.key == status.currentIndex;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||
child: Card(
|
||||
color: selected ? AppColors.primary.withOpacity(0.16) : null,
|
||||
child: ListTile(
|
||||
onTap: provider.isLoading
|
||||
? null
|
||||
: () => context.read<PlayerProvider>().gotoIndex(entry.key),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: selected ? AppColors.primary : AppColors.border,
|
||||
child: Text('${entry.key + 1}'),
|
||||
),
|
||||
title: Text(entry.value),
|
||||
subtitle: Text(selected ? '当前播放' : '点击跳转'),
|
||||
trailing: Icon(
|
||||
selected ? Icons.equalizer_rounded : Icons.chevron_right_rounded,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleGoto() {
|
||||
final index = int.tryParse(_indexController.text.trim());
|
||||
if (index == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('请输入有效索引')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
context.read<PlayerProvider>().gotoIndex(index);
|
||||
}
|
||||
}
|
||||
338
clients/flutter/lib/screens/settings_screen.dart
Normal file
338
clients/flutter/lib/screens/settings_screen.dart
Normal file
@@ -0,0 +1,338 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../providers/device_provider.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final TextEditingController _ipController = TextEditingController();
|
||||
final TextEditingController _titleController = TextEditingController();
|
||||
final TextEditingController _rotationController = TextEditingController();
|
||||
final TextEditingController _widthController = TextEditingController();
|
||||
final TextEditingController _heightController = TextEditingController();
|
||||
final TextEditingController _hsvMinController = TextEditingController();
|
||||
final TextEditingController _hsvMaxController = TextEditingController();
|
||||
final TextEditingController _pointsController = TextEditingController();
|
||||
|
||||
Map<String, dynamic>? _fullConfig;
|
||||
List<String> _availableConfigs = const <String>[];
|
||||
String? _activeConfig;
|
||||
bool _isFullscreen = false;
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _loadData());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ipController.dispose();
|
||||
_titleController.dispose();
|
||||
_rotationController.dispose();
|
||||
_widthController.dispose();
|
||||
_heightController.dispose();
|
||||
_hsvMinController.dispose();
|
||||
_hsvMaxController.dispose();
|
||||
_pointsController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = context.watch<DeviceProvider>();
|
||||
final status = provider.status;
|
||||
_ipController.text = _ipController.text.isEmpty ? provider.deviceIp : _ipController.text;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('设置')),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ListView(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('设备 IP 配置', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
TextField(
|
||||
controller: _ipController,
|
||||
decoration: const InputDecoration(labelText: '设备 IP 地址'),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: () async {
|
||||
await context.read<DeviceProvider>().updateDeviceIp(
|
||||
_ipController.text.trim(),
|
||||
);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
await _loadData();
|
||||
},
|
||||
child: const Text('保存并重连'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('可用配置文件', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
DropdownButtonFormField<String>(
|
||||
value: _activeConfig,
|
||||
items: _availableConfigs
|
||||
.map(
|
||||
(item) => DropdownMenuItem<String>(
|
||||
value: item,
|
||||
child: Text(item),
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
onChanged: (value) => setState(() => _activeConfig = value),
|
||||
decoration: const InputDecoration(labelText: '当前配置'),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: _activeConfig == null ? null : _handleSwitchConfig,
|
||||
child: const Text('切换配置'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('显示设置', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: _isFullscreen,
|
||||
onChanged: (value) => setState(() => _isFullscreen = value),
|
||||
title: const Text('全屏模式'),
|
||||
),
|
||||
TextField(
|
||||
controller: _titleController,
|
||||
decoration: const InputDecoration(labelText: '窗口标题'),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _rotationController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: '旋转角度'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _widthController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: '渲染宽度'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _heightController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: '渲染高度'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
TextField(
|
||||
controller: _hsvMinController,
|
||||
decoration: const InputDecoration(labelText: '色键下限 HSV (逗号分隔)'),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
TextField(
|
||||
controller: _hsvMaxController,
|
||||
decoration: const InputDecoration(labelText: '色键上限 HSV (逗号分隔)'),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
TextField(
|
||||
controller: _pointsController,
|
||||
minLines: 3,
|
||||
maxLines: 5,
|
||||
decoration: const InputDecoration(labelText: '透视点 JSON'),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: _handleSaveDisplayConfig,
|
||||
child: const Text('保存显示设置'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('关于信息', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_InfoRow(label: '设备名称', value: status.deviceName ?? 'ShowenV2'),
|
||||
_InfoRow(label: '连接方式', value: status.connectionType.toUpperCase()),
|
||||
_InfoRow(label: '设备地址', value: status.ipAddress ?? provider.deviceIp),
|
||||
_InfoRow(
|
||||
label: '实时通道',
|
||||
value: provider.webSocketConnected ? '已连接' : '未连接',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadData() async {
|
||||
final service = context.read<DeviceProvider>().httpApiService;
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final results = await Future.wait<dynamic>([
|
||||
service.getConfig(),
|
||||
service.getAvailableConfigs(),
|
||||
]);
|
||||
_fullConfig = Map<String, dynamic>.from(results[0] as Map<String, dynamic>);
|
||||
final available = Map<String, dynamic>.from(results[1] as Map<String, dynamic>);
|
||||
_availableConfigs = (available['configs'] as List<dynamic>? ?? const <dynamic>[])
|
||||
.map((item) => item.toString())
|
||||
.toList(growable: false);
|
||||
_activeConfig = available['active']?.toString();
|
||||
_applyDisplayConfig(Map<String, dynamic>.from(_fullConfig?['display'] as Map? ?? const <String, dynamic>{}));
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleSwitchConfig() async {
|
||||
final activeConfig = _activeConfig;
|
||||
if (activeConfig == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await context.read<DeviceProvider>().httpApiService.switchConfig(activeConfig);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
await _loadData();
|
||||
}
|
||||
|
||||
Future<void> _handleSaveDisplayConfig() async {
|
||||
final config = _fullConfig;
|
||||
if (config == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final nextConfig = Map<String, dynamic>.from(config);
|
||||
nextConfig['display'] = <String, dynamic>{
|
||||
...Map<String, dynamic>.from(config['display'] as Map? ?? const <String, dynamic>{}),
|
||||
'fullscreen': _isFullscreen,
|
||||
'window_title': _titleController.text.trim(),
|
||||
'rotation': int.tryParse(_rotationController.text.trim()) ?? 0,
|
||||
'render_width': int.tryParse(_widthController.text.trim()) ?? 1024,
|
||||
'render_height': int.tryParse(_heightController.text.trim()) ?? 1024,
|
||||
'chroma_key': <String, dynamic>{
|
||||
'hsv_min': _parseIntList(_hsvMinController.text),
|
||||
'hsv_max': _parseIntList(_hsvMaxController.text),
|
||||
},
|
||||
'perspective_correction': <String, dynamic>{
|
||||
'points': jsonDecode(_pointsController.text.trim().isEmpty ? '[]' : _pointsController.text.trim()),
|
||||
},
|
||||
};
|
||||
|
||||
await context.read<DeviceProvider>().httpApiService.updateConfig(nextConfig);
|
||||
_fullConfig = nextConfig;
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('显示设置已保存')),
|
||||
);
|
||||
}
|
||||
|
||||
void _applyDisplayConfig(Map<String, dynamic> display) {
|
||||
_isFullscreen = display['fullscreen'] as bool? ?? false;
|
||||
_titleController.text = display['window_title']?.toString() ?? '';
|
||||
_rotationController.text = '${display['rotation'] ?? 0}';
|
||||
_widthController.text = '${display['render_width'] ?? 1024}';
|
||||
_heightController.text = '${display['render_height'] ?? 1024}';
|
||||
final chromaKey = Map<String, dynamic>.from(display['chroma_key'] as Map? ?? const <String, dynamic>{});
|
||||
_hsvMinController.text = (chromaKey['hsv_min'] as List<dynamic>? ?? const <dynamic>[0, 0, 200]).join(',');
|
||||
_hsvMaxController.text = (chromaKey['hsv_max'] as List<dynamic>? ?? const <dynamic>[180, 30, 255]).join(',');
|
||||
final perspective = Map<String, dynamic>.from(display['perspective_correction'] as Map? ?? const <String, dynamic>{});
|
||||
_pointsController.text = jsonEncode(perspective['points'] ?? const <dynamic>[]);
|
||||
}
|
||||
|
||||
List<int> _parseIntList(String raw) {
|
||||
return raw
|
||||
.split(',')
|
||||
.map((item) => int.tryParse(item.trim()) ?? 0)
|
||||
.toList(growable: false);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
const _InfoRow({required this.label, required this.value});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(label, style: Theme.of(context).textTheme.bodyMedium)),
|
||||
Text(value, style: Theme.of(context).textTheme.bodyLarge),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
194
clients/flutter/lib/screens/trigger_screen.dart
Normal file
194
clients/flutter/lib/screens/trigger_screen.dart
Normal file
@@ -0,0 +1,194 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../providers/player_provider.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
|
||||
class TriggerScreen extends StatefulWidget {
|
||||
const TriggerScreen({super.key});
|
||||
|
||||
@override
|
||||
State<TriggerScreen> createState() => _TriggerScreenState();
|
||||
}
|
||||
|
||||
class _TriggerScreenState extends State<TriggerScreen> {
|
||||
final TextEditingController _triggerController = TextEditingController();
|
||||
final TextEditingController _valueController = TextEditingController();
|
||||
String? _selectedScene;
|
||||
|
||||
static const List<_PresetTrigger> _presets = <_PresetTrigger>[
|
||||
_PresetTrigger(label: '语音唤醒', name: 'wake', icon: Icons.mic_rounded),
|
||||
_PresetTrigger(label: '按钮 1', name: 'button1', icon: Icons.filter_1_rounded),
|
||||
_PresetTrigger(label: '按钮 2', name: 'button2', icon: Icons.filter_2_rounded),
|
||||
_PresetTrigger(label: '触摸传感器', name: 'touch', icon: Icons.touch_app_rounded),
|
||||
];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_triggerController.dispose();
|
||||
_valueController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = context.watch<PlayerProvider>();
|
||||
_selectedScene ??= provider.sceneOptions.isNotEmpty ? provider.sceneOptions.first : null;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('状态机触发')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('当前状态', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
provider.currentState,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Text('预设触发器', style: Theme.of(context).textTheme.headlineSmall),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
GridView.count(
|
||||
crossAxisCount: 2,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
crossAxisSpacing: AppSpacing.md,
|
||||
mainAxisSpacing: AppSpacing.md,
|
||||
childAspectRatio: 1.35,
|
||||
children: _presets.map((preset) {
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(AppRadius.large),
|
||||
onTap: provider.isLoading
|
||||
? null
|
||||
: () => context.read<PlayerProvider>().triggerEvent(preset.name),
|
||||
child: Ink(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.card,
|
||||
borderRadius: BorderRadius.circular(AppRadius.large),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(preset.icon, color: AppColors.accent, size: 32),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(preset.label),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(growable: false),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('自定义触发器', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
TextField(
|
||||
controller: _triggerController,
|
||||
decoration: const InputDecoration(labelText: '触发器名称'),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
TextField(
|
||||
controller: _valueController,
|
||||
decoration: const InputDecoration(labelText: '可选参数值'),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: provider.isLoading ? null : _handleCustomTrigger,
|
||||
icon: const Icon(Icons.send_rounded),
|
||||
label: const Text('发送触发器'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('场景切换', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
DropdownButtonFormField<String>(
|
||||
value: provider.sceneOptions.contains(_selectedScene) ? _selectedScene : null,
|
||||
items: provider.sceneOptions
|
||||
.map(
|
||||
(scene) => DropdownMenuItem<String>(
|
||||
value: scene,
|
||||
child: Text(scene),
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
onChanged: (value) => setState(() => _selectedScene = value),
|
||||
decoration: const InputDecoration(labelText: '选择场景'),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: provider.isLoading || _selectedScene == null
|
||||
? null
|
||||
: () => context.read<PlayerProvider>().switchScene(_selectedScene!),
|
||||
child: const Text('切换场景'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleCustomTrigger() {
|
||||
final name = _triggerController.text.trim();
|
||||
final value = _valueController.text.trim();
|
||||
if (name.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('请输入触发器名称')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
context.read<PlayerProvider>().triggerEvent(
|
||||
name,
|
||||
value: value.isEmpty ? null : value,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PresetTrigger {
|
||||
const _PresetTrigger({
|
||||
required this.label,
|
||||
required this.name,
|
||||
required this.icon,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String name;
|
||||
final IconData icon;
|
||||
}
|
||||
Reference in New Issue
Block a user