Files
ShowenV2/clients/flutter/lib/screens/trigger_screen.dart
showen bff9ec535d 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>
2026-03-14 02:09:52 +08:00

195 lines
7.1 KiB
Dart

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;
}