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:
@@ -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),
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user