feat: M1.1 完成 + M1.2 启动 — 全量更新

M1.1 收尾:
- 24项 P0/P1/P2 bug 修复 (Rust 107 tests + Flutter 15 tests)
- Flutter App v0.3: cupertino_icons 修复, 单元测试, 调试面板, APK 52.6MB
- 示例插件完善: manifest.json + 请求/响应示范 + 7个测试
- API 文档重写 (以 routes.rs 为唯一权威)
- MILESTONES.md 更新至 100%

M1.2 启动:
- P0: 插件管理 API 闭环 (handle_manager_message Custom 分支 + broadcast_plugin_states)
- ServiceManager 集成测试 8/8 (tests/m1_2_service_manager.rs)
- M1.2 测试计划 (docs/M1.2_TEST_PLAN.md, 18个E2E场景)
- 动态插件系统: auto_rollback + version_manager GC + 路径穿越防护

总计: Rust 115/115 测试, Flutter 15/15 测试, 零 warning

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
showen
2026-03-14 18:12:42 +08:00
parent 8ed9cb2d9d
commit d30c111c71
68 changed files with 8115 additions and 1201 deletions

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../widgets/connection_status_banner.dart';
class AppShell extends StatelessWidget {
const AppShell({required this.navigationShell, super.key});
@@ -9,7 +11,12 @@ class AppShell extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: navigationShell,
body: Column(
children: [
const ConnectionStatusBanner(),
Expanded(child: navigationShell),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: navigationShell.currentIndex,
onDestinationSelected: (index) {
@@ -44,6 +51,11 @@ class AppShell extends StatelessWidget {
selectedIcon: Icon(Icons.settings),
label: '设置',
),
NavigationDestination(
icon: Icon(Icons.bug_report_outlined),
selectedIcon: Icon(Icons.bug_report),
label: '调试',
),
],
),
);

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/ble_models.dart';
import '../providers/ble_provider.dart';
@@ -22,8 +23,8 @@ class _BleProvisionScreenState extends State<BleProvisionScreen> {
@override
void initState() {
super.initState();
_ownsProvider = widget.provider == null;
_provider = widget.provider ?? BleProvider();
_ownsProvider = false;
_provider = widget.provider ?? context.read<BleProvider>();
WidgetsBinding.instance.addPostFrameCallback((_) {
_provider.startScan();
});

View File

@@ -0,0 +1,301 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/debug_provider.dart';
import '../theme/app_colors.dart';
class DebugScreen extends StatelessWidget {
const DebugScreen({super.key});
@override
Widget build(BuildContext context) {
final provider = context.watch<DebugProvider>();
final entries = provider.filteredEntries;
return Scaffold(
appBar: AppBar(
title: const Text('调试日志'),
actions: [
IconButton(
onPressed: provider.entries.isEmpty ? null : provider.clearLogs,
icon: const Icon(Icons.delete_sweep_outlined),
tooltip: '清空日志',
),
],
),
body: Column(
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(
AppSpacing.md,
AppSpacing.md,
AppSpacing.md,
AppSpacing.sm,
),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
AppColors.card,
AppColors.card.withValues(alpha: 0.72),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
border: const Border(bottom: BorderSide(color: AppColors.border)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'事件时间线',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: AppSpacing.xs),
Text(
'保留最近 ${DebugProvider.maxEntries} 条日志,支持按链路筛选。',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.textSecondary,
),
),
const SizedBox(height: AppSpacing.md),
Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: [
_FilterChipItem(
label: '全部',
selected: provider.filter == DebugLogFilter.all,
color: AppColors.textSecondary,
onTap: () => provider.setFilter(DebugLogFilter.all),
),
_FilterChipItem(
label: 'BLE',
selected: provider.filter == DebugLogFilter.ble,
color: AppColors.info,
onTap: () => provider.setFilter(DebugLogFilter.ble),
),
_FilterChipItem(
label: 'WS',
selected: provider.filter == DebugLogFilter.ws,
color: AppColors.success,
onTap: () => provider.setFilter(DebugLogFilter.ws),
),
_FilterChipItem(
label: 'HTTP',
selected: provider.filter == DebugLogFilter.http,
color: AppColors.warning,
onTap: () => provider.setFilter(DebugLogFilter.http),
),
],
),
],
),
),
Expanded(
child: entries.isEmpty
? const _EmptyDebugState()
: ListView.separated(
padding: const EdgeInsets.all(AppSpacing.md),
itemCount: entries.length,
separatorBuilder: (_, __) =>
const SizedBox(height: AppSpacing.sm),
itemBuilder: (context, index) {
return _DebugLogCard(entry: entries[index]);
},
),
),
],
),
);
}
}
class _FilterChipItem extends StatelessWidget {
const _FilterChipItem({
required this.label,
required this.selected,
required this.color,
required this.onTap,
});
final String label;
final bool selected;
final Color color;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return FilterChip(
label: Text(label),
selected: selected,
onSelected: (_) => onTap(),
showCheckmark: false,
selectedColor: color.withValues(alpha: 0.18),
side: BorderSide(color: selected ? color : AppColors.border),
labelStyle: Theme.of(context).textTheme.labelLarge?.copyWith(
color: selected ? color : AppColors.textSecondary,
fontWeight: FontWeight.w600,
),
backgroundColor: AppColors.card,
);
}
}
class _DebugLogCard extends StatelessWidget {
const _DebugLogCard({required this.entry});
final DebugLogEntry entry;
@override
Widget build(BuildContext context) {
final color = _typeColor(entry.type);
return Container(
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(AppRadius.large),
border: Border.all(color: AppColors.border),
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
width: 4,
decoration: BoxDecoration(
color: color,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(AppRadius.large),
bottomLeft: Radius.circular(AppRadius.large),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: AppSpacing.xs,
),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: color.withValues(alpha: 0.35),
),
),
child: Text(
entry.label,
style:
Theme.of(context).textTheme.labelMedium?.copyWith(
color: color,
fontWeight: FontWeight.w700,
),
),
),
const Spacer(),
Text(
_formatTimestamp(entry.timestamp),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.textSecondary,
),
),
],
),
const SizedBox(height: AppSpacing.sm),
Text(
entry.summary,
style: Theme.of(context).textTheme.bodyLarge,
),
if (entry.details != null && entry.details!.isNotEmpty) ...[
const SizedBox(height: AppSpacing.sm),
Text(
entry.details!,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.textSecondary,
),
),
],
],
),
),
),
],
),
),
);
}
Color _typeColor(DebugLogType type) {
switch (type) {
case DebugLogType.ble:
return AppColors.info;
case DebugLogType.ws:
return AppColors.success;
case DebugLogType.http:
return AppColors.warning;
}
}
String _formatTimestamp(DateTime timestamp) {
final hh = timestamp.hour.toString().padLeft(2, '0');
final mm = timestamp.minute.toString().padLeft(2, '0');
final ss = timestamp.second.toString().padLeft(2, '0');
final ms = timestamp.millisecond.toString().padLeft(3, '0');
return '$hh:$mm:$ss.$ms';
}
}
class _EmptyDebugState extends StatelessWidget {
const _EmptyDebugState();
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.xl),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
AppColors.info.withValues(alpha: 0.24),
AppColors.success.withValues(alpha: 0.16),
],
),
borderRadius: BorderRadius.circular(24),
),
child: const Icon(Icons.bug_report_outlined, size: 34),
),
const SizedBox(height: AppSpacing.md),
Text(
'当前没有调试事件',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: AppSpacing.xs),
Text(
'连接设备、触发播放、执行网络或 BLE 操作后,日志会按时间顺序出现在这里。',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppColors.textSecondary,
),
),
],
),
),
);
}
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import '../providers/ble_provider.dart';
import '../providers/device_provider.dart';
import '../providers/wifi_provider.dart';
import '../theme/app_colors.dart';
@@ -33,6 +34,7 @@ class _NetworkScreenState extends State<NetworkScreen> {
@override
Widget build(BuildContext context) {
final bleProvider = context.watch<BleProvider>();
final wifiProvider = context.watch<WifiProvider>();
final deviceProvider = context.watch<DeviceProvider>();
final wifiStatus = wifiProvider.status;
@@ -40,9 +42,12 @@ class _NetworkScreenState extends State<NetworkScreen> {
return Scaffold(
appBar: AppBar(title: const Text('网络设置')),
body: ListView(
padding: const EdgeInsets.all(AppSpacing.md),
children: [
body: RefreshIndicator(
onRefresh: _handleRefresh,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(AppSpacing.md),
children: [
StatusCard(
title: 'WiFi 状态',
value: wifiStatus.connected ? (wifiStatus.ssid ?? '已连接') : '未连接',
@@ -107,6 +112,75 @@ class _NetworkScreenState extends State<NetworkScreen> {
),
],
),
if (bleProvider.isConnected) ...[
const SizedBox(height: AppSpacing.lg),
Card(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('BLE 控制', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: AppSpacing.sm),
Text(
bleProvider.selectedDevice?.name ?? '已连接 BLE 设备',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: AppSpacing.md),
Row(
children: [
Expanded(
child: ControlButton(
label: '播放',
icon: Icons.play_arrow_rounded,
onPressed: bleProvider.isSendingCommand
? null
: () => _sendBleCommand('play'),
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: ControlButton(
label: '暂停',
icon: Icons.pause_rounded,
onPressed: bleProvider.isSendingCommand
? null
: () => _sendBleCommand('pause'),
),
),
],
),
const SizedBox(height: AppSpacing.md),
Row(
children: [
Expanded(
child: ControlButton(
label: '上一个',
icon: Icons.skip_previous_rounded,
isFilled: false,
onPressed: bleProvider.isSendingCommand
? null
: () => _sendBleCommand('prev'),
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: ControlButton(
label: '下一个',
icon: Icons.skip_next_rounded,
isFilled: false,
onPressed: bleProvider.isSendingCommand
? null
: () => _sendBleCommand('next'),
),
),
],
),
],
),
),
),
],
const SizedBox(height: AppSpacing.lg),
Text('扫描结果', style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: AppSpacing.md),
@@ -179,11 +253,19 @@ class _NetworkScreenState extends State<NetworkScreen> {
child: const Text('进入 BLE 配网页面'),
),
),
],
],
),
),
);
}
Future<void> _handleRefresh() async {
await Future.wait<void>([
context.read<WifiProvider>().bootstrap(),
context.read<DeviceProvider>().refresh(),
]);
}
void _handleConnectWifi() {
final ssid = _ssidController.text.trim();
if (ssid.isEmpty) {
@@ -198,4 +280,23 @@ class _NetworkScreenState extends State<NetworkScreen> {
password: _passwordController.text,
);
}
Future<void> _sendBleCommand(String command) async {
try {
await context.read<BleProvider>().sendCommand(command);
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('已发送 BLE 指令: $command')),
);
} catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('BLE 指令发送失败: $error')),
);
}
}
}

View File

@@ -29,9 +29,12 @@ class _PlaybackScreenState extends State<PlaybackScreen> {
return Scaffold(
appBar: AppBar(title: const Text('播放控制')),
body: ListView(
padding: const EdgeInsets.all(AppSpacing.md),
children: [
body: RefreshIndicator(
onRefresh: _handleRefresh,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(AppSpacing.md),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg),
@@ -164,11 +167,20 @@ class _PlaybackScreenState extends State<PlaybackScreen> {
),
);
}),
],
],
),
),
);
}
Future<void> _handleRefresh() async {
final provider = context.read<PlayerProvider>();
await Future.wait<void>([
provider.fetchStatus(),
provider.fetchPlaylist(),
]);
}
void _handleGoto() {
final index = int.tryParse(_indexController.text.trim());
if (index == null) {

View File

@@ -1,9 +1,12 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../models/video_item.dart';
import '../providers/device_provider.dart';
import '../services/http_api_service.dart';
import '../theme/app_colors.dart';
class SettingsScreen extends StatefulWidget {
@@ -15,6 +18,7 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> {
final TextEditingController _ipController = TextEditingController();
final FocusNode _ipFocusNode = FocusNode();
final TextEditingController _titleController = TextEditingController();
final TextEditingController _rotationController = TextEditingController();
final TextEditingController _widthController = TextEditingController();
@@ -22,11 +26,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
final TextEditingController _hsvMinController = TextEditingController();
final TextEditingController _hsvMaxController = TextEditingController();
final TextEditingController _pointsController = TextEditingController();
final TextEditingController _jsonController = TextEditingController();
Map<String, dynamic>? _fullConfig;
List<String> _availableConfigs = const <String>[];
Future<List<VideoItem>>? _videosFuture;
String? _activeConfig;
bool _isFullscreen = false;
bool _jsonEditMode = false;
bool _loading = true;
@override
@@ -38,6 +45,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
@override
void dispose() {
_ipController.dispose();
_ipFocusNode.dispose();
_titleController.dispose();
_rotationController.dispose();
_widthController.dispose();
@@ -45,204 +53,425 @@ class _SettingsScreenState extends State<SettingsScreen> {
_hsvMinController.dispose();
_hsvMaxController.dispose();
_pointsController.dispose();
_jsonController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final provider = context.watch<DeviceProvider>();
final httpApiService = provider.httpApiService;
final status = provider.status;
_ipController.text = _ipController.text.isEmpty ? provider.deviceIp : _ipController.text;
final currentAddress = '${provider.deviceIp}:${provider.devicePort}';
if (!_ipFocusNode.hasFocus && _ipController.text != currentAddress) {
_ipController.value = TextEditingValue(
text: currentAddress,
selection: TextSelection.collapsed(offset: currentAddress.length),
);
}
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();
: RefreshIndicator(
onRefresh: _loadData,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
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,
focusNode: _ipFocusNode,
enabled: !provider.isLoading,
decoration: const InputDecoration(
labelText: '设备 IP 地址',
hintText: '例如 192.168.1.10:5000',
),
textInputAction: TextInputAction.done,
onSubmitted: (_) => _handleSwitchDevice(),
onTapOutside: (_) {
_ipFocusNode.unfocus();
_handleSwitchDevice();
},
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>(
initialValue: _activeConfig,
items: _availableConfigs
.map(
(item) => DropdownMenuItem<String>(
value: item,
child: Text(item),
const SizedBox(height: AppSpacing.md),
Row(
children: [
Expanded(
child: FilledButton(
onPressed: provider.isLoading
? null
: _handleSwitchDevice,
child: provider.isLoading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Text('切换设备'),
),
)
.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(width: AppSpacing.md),
Expanded(
child: FilledButton.tonal(
onPressed: provider.isLoading
? null
: () => _showDeviceListDialog(provider),
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(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>(
initialValue: _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: [
Row(
children: [
Expanded(
child: Text(
'配置编辑',
style:
Theme.of(context).textTheme.titleMedium,
),
),
TextButton(
onPressed: _fullConfig == null
? null
: _handleCopyJson,
child: const Text('复制JSON'),
),
],
),
const SizedBox(height: AppSpacing.md),
SegmentedButton<bool>(
segments: const [
ButtonSegment<bool>(
value: false,
label: Text('表单'),
icon: Icon(Icons.tune_rounded),
),
ButtonSegment<bool>(
value: true,
label: Text('JSON'),
icon: Icon(Icons.data_object_rounded),
),
],
selected: <bool>{_jsonEditMode},
onSelectionChanged: (selection) {
final jsonMode = selection.first;
setState(() {
_jsonEditMode = jsonMode;
if (jsonMode) {
_syncJsonControllerFromConfig();
}
});
},
),
const SizedBox(height: AppSpacing.md),
if (_jsonEditMode) ...[
TextField(
controller: _jsonController,
minLines: 15,
maxLines: 15,
decoration: const InputDecoration(
labelText: '完整配置 JSON',
alignLabelWithHint: true,
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: TextField(
controller: _widthController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: '渲染宽度'),
const SizedBox(height: AppSpacing.md),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _handleSaveJsonConfig,
child: const Text('保存 JSON'),
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: TextField(
controller: _heightController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: '渲染高度'),
] else ...[
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.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: [
Row(
children: [
Expanded(
child: Text(
'视频管理',
style:
Theme.of(context).textTheme.titleMedium,
),
),
FilledButton.icon(
onPressed: _handleUploadVideo,
icon: const Icon(Icons.upload_file_rounded),
label: const Text('上传视频'),
),
const SizedBox(width: AppSpacing.sm),
IconButton(
onPressed: _reloadVideos,
icon: const Icon(Icons.refresh_rounded),
tooltip: '刷新视频列表',
),
],
),
),
],
const SizedBox(height: AppSpacing.sm),
FutureBuilder<List<VideoItem>>(
future: _videosFuture,
builder: (context, snapshot) {
if (snapshot.connectionState ==
ConnectionState.waiting) {
return const Padding(
padding: EdgeInsets.symmetric(
vertical: AppSpacing.md),
child: Center(
child: CircularProgressIndicator(),
),
);
}
if (snapshot.hasError) {
return Padding(
padding: const EdgeInsets.symmetric(
vertical: AppSpacing.sm),
child: Text(
'视频列表加载失败: ${snapshot.error}',
style: TextStyle(
color:
Theme.of(context).colorScheme.error,
),
),
);
}
final videos =
snapshot.data ?? const <VideoItem>[];
if (videos.isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(
vertical: AppSpacing.sm),
child: Text('当前没有视频文件'),
);
}
return ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: videos.length,
separatorBuilder: (_, __) =>
const Divider(height: 1),
itemBuilder: (context, index) {
final video = videos[index];
return ListTile(
contentPadding: EdgeInsets.zero,
title: Text(video.name),
subtitle: Text(video.sizeLabel),
trailing: IconButton(
onPressed: () => _confirmDeleteVideo(
httpApiService, video),
icon: const Icon(
Icons.delete_outline_rounded),
tooltip: '删除视频',
),
);
},
);
},
),
],
),
),
),
),
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 ? '已连接' : '未连接',
),
],
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;
final service = _getHttpApiService();
setState(() => _loading = true);
try {
_videosFuture = service.getVideos();
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);
_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>{}));
_applyDisplayConfig(Map<String, dynamic>.from(
_fullConfig?['display'] as Map? ?? const <String, dynamic>{}));
_syncJsonControllerFromConfig();
} finally {
if (mounted) {
setState(() => _loading = false);
@@ -250,13 +479,132 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
}
Future<void> _handleSwitchDevice() async {
final input = _ipController.text.trim();
if (input.isEmpty) {
return;
}
final provider = context.read<DeviceProvider>();
try {
await provider.switchDevice(input);
} catch (error) {
if (!mounted) {
return;
}
_showSnackBar(error.toString(), isError: true);
return;
}
if (!mounted) {
return;
}
await _loadData();
if (!mounted) {
return;
}
_showSnackBar('设备切换成功');
}
Future<void> _showDeviceListDialog(DeviceProvider provider) async {
await showDialog<void>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: const Text('历史设备'),
content: SizedBox(
width: double.maxFinite,
child: provider.deviceList.isEmpty
? const Center(child: Text('暂无历史设备'))
: ListView.separated(
shrinkWrap: true,
itemCount: provider.deviceList.length,
separatorBuilder: (_, __) =>
const SizedBox(height: AppSpacing.sm),
itemBuilder: (context, index) {
final device = provider.deviceList[index];
return Dismissible(
key: ValueKey(device.address),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer,
borderRadius: BorderRadius.circular(16),
),
child: Icon(
Icons.delete_outline,
color:
Theme.of(context).colorScheme.onErrorContainer,
),
),
onDismissed: (_) async {
final deviceProvider = context.read<DeviceProvider>();
final navigator = Navigator.of(dialogContext);
await deviceProvider.removeStoredDevice(device);
if (!mounted) {
return;
}
navigator.pop();
await _showDeviceListDialog(deviceProvider);
},
child: Card(
margin: EdgeInsets.zero,
child: ListTile(
title: Text(device.name),
subtitle: Text(device.address),
trailing: provider.deviceIp == device.ip &&
provider.devicePort == device.port
? const Icon(Icons.check_circle_outline)
: null,
onTap: () async {
Navigator.of(dialogContext).pop();
try {
await context
.read<DeviceProvider>()
.switchDevice(
device.address,
name: device.name,
);
} catch (error) {
if (!mounted) {
return;
}
_showSnackBar(error.toString(), isError: true);
return;
}
if (!mounted) {
return;
}
await _loadData();
if (!mounted) {
return;
}
_showSnackBar('设备切换成功');
},
),
),
);
},
),
),
);
},
);
}
Future<void> _handleSwitchConfig() async {
final activeConfig = _activeConfig;
if (activeConfig == null) {
return;
}
await context.read<DeviceProvider>().httpApiService.switchConfig(activeConfig);
await context
.read<DeviceProvider>()
.httpApiService
.switchConfig(activeConfig);
if (!mounted) {
return;
}
@@ -271,7 +619,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
final nextConfig = Map<String, dynamic>.from(config);
nextConfig['display'] = <String, dynamic>{
...Map<String, dynamic>.from(config['display'] as Map? ?? const <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,
@@ -282,18 +631,119 @@ class _SettingsScreenState extends State<SettingsScreen> {
'hsv_max': _parseIntList(_hsvMaxController.text),
},
'perspective_correction': <String, dynamic>{
'points': jsonDecode(_pointsController.text.trim().isEmpty ? '[]' : _pointsController.text.trim()),
'points': jsonDecode(_pointsController.text.trim().isEmpty
? '[]'
: _pointsController.text.trim()),
},
};
await context.read<DeviceProvider>().httpApiService.updateConfig(nextConfig);
await context
.read<DeviceProvider>()
.httpApiService
.updateConfig(nextConfig);
_fullConfig = nextConfig;
_syncJsonControllerFromConfig();
if (!mounted) {
return;
}
_showSnackBar('显示设置已保存');
}
Future<void> _handleSaveJsonConfig() async {
final rawJson = _jsonController.text.trim();
if (rawJson.isEmpty) {
_showSnackBar('JSON 内容不能为空', isError: true);
return;
}
try {
final decoded = jsonDecode(rawJson);
if (decoded is! Map) {
throw const FormatException('根节点必须是 JSON 对象');
}
final nextConfig = Map<String, dynamic>.from(decoded);
await context
.read<DeviceProvider>()
.httpApiService
.updateConfig(nextConfig);
_fullConfig = nextConfig;
_applyDisplayConfig(Map<String, dynamic>.from(
nextConfig['display'] as Map? ?? const <String, dynamic>{}));
_syncJsonControllerFromConfig();
if (!mounted) {
return;
}
_showSnackBar('JSON 配置已保存');
} on FormatException catch (error) {
_showSnackBar('JSON 解析失败: ${error.message}', isError: true);
} catch (error) {
_showSnackBar(error.toString(), isError: true);
}
}
Future<void> _handleCopyJson() async {
final config = _fullConfig;
if (config == null) {
return;
}
await Clipboard.setData(ClipboardData(
text: const JsonEncoder.withIndent(' ').convert(config)));
if (!mounted) {
return;
}
_showSnackBar('配置 JSON 已复制到剪贴板');
}
Future<void> _confirmDeleteVideo(
HttpApiService httpApiService, VideoItem video) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('确认删除'),
content: Text('确定删除视频 `${video.name}` 吗?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('删除'),
),
],
);
},
);
if (confirmed != true) {
return;
}
await httpApiService.deleteVideo(video.name);
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('显示设置已保存')),
SnackBar(content: Text('已删除 ${video.name}')),
);
_reloadVideos();
}
Future<void> _handleUploadVideo() async {
_showSnackBar('视频上传功能即将推出,请通过 Web UI 上传');
}
void _reloadVideos() {
setState(() {
_videosFuture = _getHttpApiService().getVideos();
});
}
HttpApiService _getHttpApiService() {
return context.read<DeviceProvider>().httpApiService;
}
void _applyDisplayConfig(Map<String, dynamic> display) {
@@ -302,11 +752,43 @@ class _SettingsScreenState extends State<SettingsScreen> {
_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>[]);
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>[]);
}
void _syncJsonControllerFromConfig() {
final config = _fullConfig;
if (config == null) {
_jsonController.clear();
return;
}
_jsonController.text = const JsonEncoder.withIndent(' ').convert(config);
}
void _showSnackBar(String message, {bool isError = false}) {
if (!mounted) {
return;
}
final messenger = ScaffoldMessenger.of(context);
messenger
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: isError ? Theme.of(context).colorScheme.error : null,
),
);
}
List<int> _parseIntList(String raw) {
@@ -329,7 +811,9 @@ class _InfoRow extends StatelessWidget {
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
child: Row(
children: [
Expanded(child: Text(label, style: Theme.of(context).textTheme.bodyMedium)),
Expanded(
child:
Text(label, style: Theme.of(context).textTheme.bodyMedium)),
Text(value, style: Theme.of(context).textTheme.bodyLarge),
],
),

View File

@@ -37,9 +37,12 @@ class _TriggerScreenState extends State<TriggerScreen> {
return Scaffold(
appBar: AppBar(title: const Text('状态机触发')),
body: ListView(
padding: const EdgeInsets.all(AppSpacing.md),
children: [
body: RefreshIndicator(
onRefresh: _handleRefresh,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(AppSpacing.md),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.md),
@@ -159,11 +162,16 @@ class _TriggerScreenState extends State<TriggerScreen> {
),
),
),
],
],
),
),
);
}
Future<void> _handleRefresh() {
return context.read<PlayerProvider>().bootstrap();
}
void _handleCustomTrigger() {
final name = _triggerController.text.trim();
final value = _valueController.text.trim();