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 { const SettingsScreen({super.key}); @override State createState() => _SettingsScreenState(); } class _SettingsScreenState extends State { final TextEditingController _ipController = TextEditingController(); final FocusNode _ipFocusNode = FocusNode(); 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(); final TextEditingController _jsonController = TextEditingController(); Map? _fullConfig; List _availableConfigs = const []; Future>? _videosFuture; String? _activeConfig; bool _isFullscreen = false; bool _jsonEditMode = false; bool _loading = true; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) => _loadData()); } @override void dispose() { _ipController.dispose(); _ipFocusNode.dispose(); _titleController.dispose(); _rotationController.dispose(); _widthController.dispose(); _heightController.dispose(); _hsvMinController.dispose(); _hsvMaxController.dispose(); _pointsController.dispose(); _jsonController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final provider = context.watch(); final httpApiService = provider.httpApiService; final status = provider.status; 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()) : 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(); }, ), 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('切换设备'), ), ), 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), DropdownButtonFormField( initialValue: _activeConfig, items: _availableConfigs .map( (item) => DropdownMenuItem( 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( segments: const [ ButtonSegment( value: false, label: Text('表单'), icon: Icon(Icons.tune_rounded), ), ButtonSegment( value: true, label: Text('JSON'), icon: Icon(Icons.data_object_rounded), ), ], selected: {_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(height: AppSpacing.md), SizedBox( width: double.infinity, child: FilledButton( onPressed: _handleSaveJsonConfig, child: const Text('保存 JSON'), ), ), ] 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.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>( 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 []; 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 ? '已连接' : '未连接', ), ], ), ), ), ], ), ), ); } Future _loadData() async { final service = _getHttpApiService(); setState(() => _loading = true); try { _videosFuture = service.getVideos(); final results = await Future.wait([ service.getConfig(), service.getAvailableConfigs(), ]); _fullConfig = Map.from(results[0] as Map); final available = Map.from(results[1] as Map); _availableConfigs = (available['configs'] as List? ?? const []) .map((item) => item.toString()) .toList(growable: false); _activeConfig = available['active']?.toString(); _applyDisplayConfig(Map.from( _fullConfig?['display'] as Map? ?? const {})); _syncJsonControllerFromConfig(); } finally { if (mounted) { setState(() => _loading = false); } } } Future _handleSwitchDevice() async { final input = _ipController.text.trim(); if (input.isEmpty) { return; } final provider = context.read(); 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 _showDeviceListDialog(DeviceProvider provider) async { await showDialog( 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(); 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() .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 _handleSwitchConfig() async { final activeConfig = _activeConfig; if (activeConfig == null) { return; } await context .read() .httpApiService .switchConfig(activeConfig); if (!mounted) { return; } await _loadData(); } Future _handleSaveDisplayConfig() async { final config = _fullConfig; if (config == null) { return; } final nextConfig = Map.from(config); nextConfig['display'] = { ...Map.from( config['display'] as Map? ?? const {}), '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': { 'hsv_min': _parseIntList(_hsvMinController.text), 'hsv_max': _parseIntList(_hsvMaxController.text), }, 'perspective_correction': { 'points': jsonDecode(_pointsController.text.trim().isEmpty ? '[]' : _pointsController.text.trim()), }, }; await context .read() .httpApiService .updateConfig(nextConfig); _fullConfig = nextConfig; _syncJsonControllerFromConfig(); if (!mounted) { return; } _showSnackBar('显示设置已保存'); } Future _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.from(decoded); await context .read() .httpApiService .updateConfig(nextConfig); _fullConfig = nextConfig; _applyDisplayConfig(Map.from( nextConfig['display'] as Map? ?? const {})); _syncJsonControllerFromConfig(); if (!mounted) { return; } _showSnackBar('JSON 配置已保存'); } on FormatException catch (error) { _showSnackBar('JSON 解析失败: ${error.message}', isError: true); } catch (error) { _showSnackBar(error.toString(), isError: true); } } Future _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 _confirmDeleteVideo( HttpApiService httpApiService, VideoItem video) async { final confirmed = await showDialog( 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( SnackBar(content: Text('已删除 ${video.name}')), ); _reloadVideos(); } Future _handleUploadVideo() async { _showSnackBar('视频上传功能即将推出,请通过 Web UI 上传'); } void _reloadVideos() { setState(() { _videosFuture = _getHttpApiService().getVideos(); }); } HttpApiService _getHttpApiService() { return context.read().httpApiService; } void _applyDisplayConfig(Map 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.from( display['chroma_key'] as Map? ?? const {}); _hsvMinController.text = (chromaKey['hsv_min'] as List? ?? const [0, 0, 200]) .join(','); _hsvMaxController.text = (chromaKey['hsv_max'] as List? ?? const [180, 30, 255]) .join(','); final perspective = Map.from( display['perspective_correction'] as Map? ?? const {}); _pointsController.text = jsonEncode(perspective['points'] ?? const []); } 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 _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), ], ), ); } }