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