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:
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