import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../models/marquee_config.dart'; import '../../../models/marquee_message.dart'; import '../../../models/org_node.dart'; import '../../../models/store_info.dart'; import '../../../services/api_client.dart'; import '../../../services/marquee_service.dart'; import '../../../utils/i10n.dart'; import '../../../utils/session_org_scope.dart'; import '../../../widgets/org_picker_sheet.dart'; /// 滚动字幕管理页。 /// /// 支持按门店查看、新增、编辑、删除、查询、下发/取消下发滚动字幕。 class MarqueeManagementPage extends ConsumerStatefulWidget { const MarqueeManagementPage({super.key}); @override ConsumerState createState() => _MarqueeManagementPageState(); } class _MarqueeManagementPageState extends ConsumerState { final MarqueeService _service = MarqueeService(); final _searchController = TextEditingController(); List _items = []; List _stores = []; bool _loading = false; String? _error; String _keyword = ''; String? _selectedStoreId; AppLocalizations get t => AppLocalizations.of(context); @override void initState() { super.initState(); _init(); } /// 初始化:先加载门店,受限账号默认选中第一家门店(避免「全部门店」视图 /// 泄露跨组织字幕),再加载字幕列表。 Future _init() async { await _loadStores(); if (SessionOrgScope.instance.enabled && _selectedStoreId == null && _stores.isNotEmpty) { setState(() => _selectedStoreId = _stores.first.orgId); } await _loadData(); } @override void dispose() { _searchController.dispose(); super.dispose(); } Future _loadStores() async { try { final stores = await _service.getStores(); if (!mounted) return; setState(() => _stores = stores); } catch (_) { // 门店列表加载失败不影响主列表展示。 } } Future _loadData() async { setState(() { _loading = true; _error = null; }); try { final list = await _service.getMarqueeList( storeId: _selectedStoreId, keyword: _keyword.isEmpty ? null : _keyword, ); if (!mounted) return; setState(() { _items = list; _loading = false; }); } catch (e) { if (!mounted) return; setState(() { _error = e is ApiException ? e.message : e.toString(); _loading = false; }); } } Future _refresh() async => _loadData(); void _onSearchChanged(String value) { _keyword = value.trim(); _loadData(); } Future _showForm({MarqueeMessage? editing}) async { final result = await showModalBottomSheet>( context: context, isScrollControlled: true, builder: (ctx) => _MarqueeForm( stores: _stores, editing: editing, ), ); if (result == null) return; try { await _service.saveMarquee(result); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(t.saved)), ); _refresh(); } on ApiException catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(e.message)), ); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('${t.saveFailed}: $e')), ); } } Future _deleteMarquee(MarqueeMessage item) async { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(t.confirmDelete), content: Text(t.deleteConfirmContent(item.content)), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: Text(t.cancel), ), FilledButton( onPressed: () => Navigator.pop(ctx, true), child: Text(t.delete), ), ], ), ); if (confirmed != true) return; try { await _service.deleteMarquee(item.id); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(t.deleted)), ); _refresh(); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('${t.deleteFailed}: $e')), ); } } Future _enableMarquee(MarqueeMessage item) async { setState(() => _loading = true); try { await _service.setMarqueeStatus(item.id, status: 1); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(t.marqueeStatusEnabled)), ); _refresh(); } on ApiException catch (e) { if (!mounted) return; setState(() => _loading = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(e.message)), ); } catch (e) { if (!mounted) return; setState(() => _loading = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('${t.marqueeStatusEnabled} $e')), ); } } Future _disableMarquee(MarqueeMessage item) async { try { await _service.setMarqueeStatus(item.id, status: 0); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(t.marqueeStatusDisabled)), ); _refresh(); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('${t.marqueeStatusDisabled} $e')), ); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( automaticallyImplyLeading: false, title: Text(t.marqueeManagement), ), body: Column( children: [ _buildFilterBar(), Expanded(child: _buildBody()), ], ), floatingActionButton: FloatingActionButton( heroTag: 'marquee_add_fab', onPressed: () => _showForm(), child: const Icon(Icons.add), ), ); } Widget _buildFilterBar() { return Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), child: Column( children: [ TextField( controller: _searchController, decoration: InputDecoration( hintText: t.searchMarqueeHint, prefixIcon: const Icon(Icons.search), border: const OutlineInputBorder(), contentPadding: const EdgeInsets.symmetric(vertical: 12), ), onChanged: _onSearchChanged, ), const SizedBox(height: 8), DropdownButtonFormField( value: _selectedStoreId, decoration: InputDecoration( labelText: t.filterByStore, border: const OutlineInputBorder(), contentPadding: const EdgeInsets.symmetric(horizontal: 12), ), items: [ if (!SessionOrgScope.instance.enabled) DropdownMenuItem( value: null, child: Text(t.allStores), ), ..._stores.map((s) => DropdownMenuItem( value: s.orgId, child: Text(s.name), )), ], onChanged: (v) { setState(() => _selectedStoreId = v); _loadData(); }, ), ], ), ); } Widget _buildBody() { if (_loading && _items.isEmpty) { return const Center(child: CircularProgressIndicator()); } if (_error != null && _items.isEmpty) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text(_error!), const SizedBox(height: 8), FilledButton.tonal( onPressed: _refresh, child: Text(t.retry), ), ], ), ); } if (_items.isEmpty) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.subtitles_outlined, size: 64, color: Theme.of(context).colorScheme.outline), const SizedBox(height: 16), Text(t.noMarqueeData), ], ), ); } return RefreshIndicator( onRefresh: _refresh, child: ListView.builder( padding: const EdgeInsets.all(16), itemCount: _items.length, itemBuilder: (context, index) { final item = _items[index]; return _MarqueeCard( item: item, onEdit: () => _showForm(editing: item), onDelete: () => _deleteMarquee(item), onEnable: () => _enableMarquee(item), onDisable: () => _disableMarquee(item), ); }, ), ); } } class _MarqueeCard extends StatelessWidget { const _MarqueeCard({ required this.item, required this.onEdit, required this.onDelete, required this.onEnable, required this.onDisable, }); final MarqueeMessage item; final VoidCallback onEdit; final VoidCallback onDelete; final VoidCallback onEnable; final VoidCallback onDisable; @override Widget build(BuildContext context) { final t = AppLocalizations.of(context); final theme = Theme.of(context); final colorScheme = theme.colorScheme; return Card( margin: const EdgeInsets.only(bottom: 12), child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( item.storeName, style: theme.textTheme.titleMedium ?.copyWith(fontWeight: FontWeight.bold), ), ), _StatusChip(status: item.status), ], ), const SizedBox(height: 12), Container( width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: _parseColor(item.backgroundColor) ?? colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), border: Border.all( color: _parseColor(item.fontColor)?.withAlpha(76) ?? colorScheme.outlineVariant, ), ), child: Text( item.content, style: theme.textTheme.bodyLarge?.copyWith( color: _parseColor(item.fontColor), fontSize: item.fontSize?.toDouble(), ), ), ), const SizedBox(height: 8), Row( children: [ Icon(Icons.speed, size: 16, color: colorScheme.onSurfaceVariant), const SizedBox(width: 4), Text( '${t.marqueeSpeed}: ${MarqueeConfig.speedFromValue(item.speed).label(t.isEnglish)}', style: theme.textTheme.bodySmall ?.copyWith(color: colorScheme.onSurfaceVariant), ), const SizedBox(width: 16), if (item.fontSize != null) Padding( padding: const EdgeInsets.only(right: 16), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.format_size, size: 16, color: colorScheme.onSurfaceVariant), const SizedBox(width: 4), Text( '${t.marqueeFontSize}: ${item.fontSize}', style: theme.textTheme.bodySmall ?.copyWith(color: colorScheme.onSurfaceVariant), ), ], ), ), if (item.level != null) Padding( padding: const EdgeInsets.only(right: 16), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.layers, size: 16, color: colorScheme.onSurfaceVariant), const SizedBox(width: 4), Text( '${t.marqueeLevel}: ${MarqueeConfig.gradeFromValue(item.level).label(t.isEnglish)}', style: theme.textTheme.bodySmall ?.copyWith(color: colorScheme.onSurfaceVariant), ), ], ), ), ], ), const SizedBox(height: 8), Row( children: [ Icon(Icons.access_time, size: 16, color: colorScheme.onSurfaceVariant), const SizedBox(width: 4), Text( _formatTimeRange(item.startTime, item.endTime), style: theme.textTheme.bodySmall ?.copyWith(color: colorScheme.onSurfaceVariant), ), const Spacer(), if (item.fontColor != null || item.backgroundColor != null) Row( mainAxisSize: MainAxisSize.min, children: [ if (item.fontColor != null) _ColorDot( label: t.marqueeFontColor, colorHex: item.fontColor!, ), const SizedBox(width: 8), if (item.backgroundColor != null) _ColorDot( label: t.marqueeBackgroundColor, colorHex: item.backgroundColor!, ), ], ), ], ), const SizedBox(height: 12), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ if (item.isDisabled) FilledButton.tonal( onPressed: onEnable, child: Text(t.marqueeStatusEnabled), ) else OutlinedButton( onPressed: onDisable, child: Text(t.marqueeStatusDisabled), ), const SizedBox(width: 8), TextButton.icon( onPressed: onEdit, icon: const Icon(Icons.edit, size: 18), label: Text(t.edit), ), TextButton.icon( onPressed: onDelete, icon: const Icon(Icons.delete, size: 18), label: Text(t.delete), style: TextButton.styleFrom( foregroundColor: colorScheme.error, ), ), ], ), ], ), ), ); } String _formatTime(DateTime? time) { if (time == null) return '--'; return '${time.hour.toString().padLeft(2, '0')}:' '${time.minute.toString().padLeft(2, '0')}:' '${time.second.toString().padLeft(2, '0')}'; } String _formatTimeRange(DateTime? start, DateTime? end) { if (start == null && end == null) return '--'; final s = start == null ? '--' : _formatTime(start); final e = end == null ? '--' : _formatTime(end); return '$s ~ $e'; } Color? _parseColor(String? hex) { if (hex == null || hex.isEmpty) return null; final normalized = hex.trim(); try { if (normalized.length == 7 && normalized.startsWith('#')) { return Color(int.parse('FF${normalized.substring(1)}', radix: 16)); } if (normalized.length == 9 && normalized.startsWith('#')) { return Color(int.parse(normalized.substring(1), radix: 16)); } if (normalized.length == 6 && !normalized.startsWith('#')) { return Color(int.parse('FF$normalized', radix: 16)); } } catch (_) { return null; } return null; } } class _ColorPrefixIcon extends StatelessWidget { const _ColorPrefixIcon({required this.colorHex}); final String colorHex; @override Widget build(BuildContext context) { final parsed = _parseColor(colorHex); return Container( width: 24, height: 24, margin: const EdgeInsets.all(12), decoration: BoxDecoration( color: parsed ?? Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), ), ); } Color? _parseColor(String hex) { final normalized = hex.trim(); try { if (normalized.length == 7 && normalized.startsWith('#')) { return Color(int.parse('FF${normalized.substring(1)}', radix: 16)); } if (normalized.length == 9 && normalized.startsWith('#')) { return Color(int.parse(normalized.substring(1), radix: 16)); } if (normalized.length == 6 && !normalized.startsWith('#')) { return Color(int.parse('FF$normalized', radix: 16)); } } catch (_) { return null; } return null; } } class _DateTimeField extends StatelessWidget { const _DateTimeField({ required this.label, this.value, required this.onChanged, }); final String label; final DateTime? value; final ValueChanged onChanged; String _formatTime(DateTime? dt) { if (dt == null) return ''; return '${dt.hour.toString().padLeft(2, '0')}:' '${dt.minute.toString().padLeft(2, '0')}:' '${dt.second.toString().padLeft(2, '0')}'; } @override Widget build(BuildContext context) { final text = _formatTime(value); return InkWell( onTap: () async { final initial = value ?? DateTime(2000, 1, 1, 0, 0, 0); final picked = await showModalBottomSheet( useSafeArea: true, context: context, isScrollControlled: true, builder: (ctx) => _TimePickerSheet(initialTime: initial), ); if (picked == null) return; onChanged(picked); }, child: InputDecorator( decoration: InputDecoration( labelText: label, border: const OutlineInputBorder(), suffixIcon: const Icon(Icons.access_time), ), child: Text( text.isEmpty ? '--' : text, style: Theme.of(context).textTheme.bodyMedium, ), ), ); } } /// 时分秒时间选择器,对齐 Web 端滚动字幕开始/结束时间控件。 /// /// 三列滚轮(时 00-23 / 分 00-59 / 秒 00-59),底部取消/确定按钮。 class _TimePickerSheet extends StatefulWidget { const _TimePickerSheet({required this.initialTime}); final DateTime initialTime; @override State<_TimePickerSheet> createState() => _TimePickerSheetState(); } class _TimePickerSheetState extends State<_TimePickerSheet> { late FixedExtentScrollController _hourCtrl; late FixedExtentScrollController _minuteCtrl; late FixedExtentScrollController _secondCtrl; late int _hour; late int _minute; late int _second; @override void initState() { super.initState(); _hour = widget.initialTime.hour; _minute = widget.initialTime.minute; _second = widget.initialTime.second; _hourCtrl = FixedExtentScrollController(initialItem: _hour); _minuteCtrl = FixedExtentScrollController(initialItem: _minute); _secondCtrl = FixedExtentScrollController(initialItem: _second); } @override void dispose() { _hourCtrl.dispose(); _minuteCtrl.dispose(); _secondCtrl.dispose(); super.dispose(); } Widget _buildWheel({ required int count, required FixedExtentScrollController controller, required ValueChanged onChanged, required String unit, }) { return Expanded( child: Column( children: [ Text(unit, style: Theme.of(context).textTheme.labelSmall), Expanded( child: ListWheelScrollView.useDelegate( controller: controller, itemExtent: 44, diameterRatio: 1.2, physics: const FixedExtentScrollPhysics(), onSelectedItemChanged: onChanged, childDelegate: ListWheelChildBuilderDelegate( builder: (ctx, index) { if (index < 0 || index >= count) return null; return Container( alignment: Alignment.center, child: Text( index.toString().padLeft(2, '0'), style: Theme.of(context).textTheme.titleMedium, ), ); }, childCount: count, ), ), ), ], ), ); } @override Widget build(BuildContext context) { final t = AppLocalizations.of(context); return Padding( padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom, ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Text( t.pleaseSelect, style: Theme.of(context) .textTheme .titleMedium ?.copyWith(fontWeight: FontWeight.bold), ), ), SizedBox( height: 220, child: Row( children: [ _buildWheel( count: 24, controller: _hourCtrl, onChanged: (v) => setState(() => _hour = v), unit: t.marqueeHour, ), const Text(':', style: TextStyle(fontSize: 20)), _buildWheel( count: 60, controller: _minuteCtrl, onChanged: (v) => setState(() => _minute = v), unit: t.marqueeMinute, ), const Text(':', style: TextStyle(fontSize: 20)), _buildWheel( count: 60, controller: _secondCtrl, onChanged: (v) => setState(() => _second = v), unit: t.marqueeSecond, ), ], ), ), Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), child: Row( children: [ Expanded( child: OutlinedButton( onPressed: () => Navigator.pop(context), child: Text(t.cancel), ), ), const SizedBox(width: 12), Expanded( child: FilledButton( onPressed: () { Navigator.pop( context, DateTime(2000, 1, 1, _hour, _minute, _second), ); }, child: Text(t.confirm), ), ), ], ), ), SizedBox(height: MediaQuery.of(context).padding.bottom), ], ), ); } } class _ColorDot extends StatelessWidget { const _ColorDot({required this.label, required this.colorHex}); final String label; final String colorHex; @override Widget build(BuildContext context) { final theme = Theme.of(context); final parsed = _parseColor(colorHex); return Row( mainAxisSize: MainAxisSize.min, children: [ Text( label, style: theme.textTheme.bodySmall ?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), const SizedBox(width: 4), Container( width: 16, height: 16, decoration: BoxDecoration( color: parsed ?? theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), border: Border.all(color: theme.colorScheme.outlineVariant), ), ), ], ); } Color? _parseColor(String hex) { final normalized = hex.trim(); try { if (normalized.length == 7 && normalized.startsWith('#')) { return Color(int.parse('FF${normalized.substring(1)}', radix: 16)); } if (normalized.length == 9 && normalized.startsWith('#')) { return Color(int.parse(normalized.substring(1), radix: 16)); } if (normalized.length == 6 && !normalized.startsWith('#')) { return Color(int.parse('FF$normalized', radix: 16)); } } catch (_) { return null; } return null; } } class _StatusChip extends StatelessWidget { const _StatusChip({required this.status}); final int status; @override Widget build(BuildContext context) { final t = AppLocalizations.of(context); final isEnabled = status == 1; final colorScheme = Theme.of(context).colorScheme; return Chip( avatar: Icon( isEnabled ? Icons.check_circle : Icons.block, size: 18, color: isEnabled ? colorScheme.onPrimaryContainer : null, ), label: Text( isEnabled ? t.marqueeStatusEnabled : t.marqueeStatusDisabled, style: TextStyle( color: isEnabled ? colorScheme.onPrimaryContainer : null, fontSize: 12, ), ), backgroundColor: isEnabled ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest, side: BorderSide.none, padding: EdgeInsets.zero, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ); } } class _MarqueeForm extends StatefulWidget { const _MarqueeForm({ required this.stores, this.editing, }); final List stores; final MarqueeMessage? editing; @override State<_MarqueeForm> createState() => _MarqueeFormState(); } class _MarqueeFormState extends State<_MarqueeForm> { final _formKey = GlobalKey(); String? _storeId; late final _contentController = TextEditingController(text: widget.editing?.content ?? ''); late final _fontColorController = TextEditingController(text: widget.editing?.fontColor ?? '#FFFFFF'); late final _backgroundColorController = TextEditingController(text: widget.editing?.backgroundColor ?? '#3B82F6'); late final _fontSizeController = TextEditingController(text: (widget.editing?.fontSize ?? 32).toString()); late MarqueeSpeed _speed; late MarqueeGrade _grade; DateTime? _startTime; DateTime? _endTime; late int _status; @override void initState() { super.initState(); _storeId = widget.editing?.storeId ?? (widget.stores.isNotEmpty ? widget.stores.first.orgId : null); _speed = MarqueeConfig.speedFromValue(widget.editing?.speed); _grade = MarqueeConfig.gradeFromValue(widget.editing?.level); _startTime = widget.editing?.startTime ?? DateTime(2000, 1, 1, 0, 0, 0); _endTime = widget.editing?.endTime ?? DateTime(2000, 1, 1, 23, 59, 59); _status = widget.editing?.status ?? 1; } @override void dispose() { _contentController.dispose(); _fontColorController.dispose(); _backgroundColorController.dispose(); _fontSizeController.dispose(); super.dispose(); } Future _pickColor(TextEditingController controller) async { final result = await showModalBottomSheet( useSafeArea: true, context: context, isScrollControlled: true, builder: (ctx) => _ColorPickerSheet(initialColor: controller.text), ); if (result != null) { setState(() => controller.text = result); } } @override Widget build(BuildContext context) { final t = AppLocalizations.of(context); final isEdit = widget.editing != null; return SingleChildScrollView( padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom, ), child: Container( padding: const EdgeInsets.all(20), child: Form( key: _formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( isEdit ? t.editMarquee : t.addMarquee, style: Theme.of(context) .textTheme .titleLarge ?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 20), OrgPickerFormField( value: _storeId, labelText: t.targetStore, options: widget.stores .map((s) => OrgPickerOption( node: OrgNode( id: s.orgId, name: s.name, type: 3, parentId: s.parentId ?? '', address: s.address, contactPhone: s.contactPhone, activationCode: s.activationCode, ), )) .toList(), onChanged: (v) => setState(() => _storeId = v), validator: (v) => (v == null || v.isEmpty) ? t.pleaseSelect : null, searchHint: t.searchStoreHint, emptyHint: t.noStoreAvailable, autoOpen: false, ), const SizedBox(height: 16), TextFormField( controller: _contentController, decoration: InputDecoration( labelText: t.marqueeContent, border: const OutlineInputBorder(), alignLabelWithHint: true, ), maxLines: 4, validator: (v) => (v == null || v.trim().isEmpty) ? t.marqueeContentRequired : null, ), const SizedBox(height: 16), Row( children: [ Expanded( child: TextFormField( controller: _fontColorController, readOnly: true, onTap: () => _pickColor(_fontColorController), decoration: InputDecoration( labelText: t.marqueeFontColor, border: const OutlineInputBorder(), prefixIcon: _ColorPrefixIcon(colorHex: _fontColorController.text), ), ), ), const SizedBox(width: 12), Expanded( child: TextFormField( controller: _backgroundColorController, readOnly: true, onTap: () => _pickColor(_backgroundColorController), decoration: InputDecoration( labelText: t.marqueeBackgroundColor, border: const OutlineInputBorder(), prefixIcon: _ColorPrefixIcon(colorHex: _backgroundColorController.text), ), ), ), ], ), const SizedBox(height: 16), Row( children: [ Expanded( child: TextFormField( controller: _fontSizeController, decoration: InputDecoration( labelText: t.marqueeFontSize, border: const OutlineInputBorder(), suffixText: 'px', ), keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], validator: (v) { final size = int.tryParse(v ?? ''); if (size == null || size <= 0) { return t.marqueeFontSizeInvalid; } return null; }, ), ), const SizedBox(width: 12), Expanded( child: DropdownButtonFormField( value: _grade, decoration: InputDecoration( labelText: t.marqueeLevel, border: const OutlineInputBorder(), ), items: MarqueeGrade.values.map((g) { return DropdownMenuItem( value: g, child: Text(g.label(t.isEnglish)), ); }).toList(), onChanged: (v) { if (v != null) setState(() => _grade = v); }, ), ), ], ), const SizedBox(height: 16), DropdownButtonFormField( value: _speed, decoration: InputDecoration( labelText: t.marqueeSpeed, border: const OutlineInputBorder(), ), items: MarqueeSpeed.values.map((s) { return DropdownMenuItem( value: s, child: Text(s.label(t.isEnglish)), ); }).toList(), onChanged: (v) { if (v != null) setState(() => _speed = v); }, ), const SizedBox(height: 16), DropdownButtonFormField( value: _status, decoration: InputDecoration( labelText: t.marqueeStatus, border: const OutlineInputBorder(), ), items: [ DropdownMenuItem( value: 1, child: Text(t.marqueeStatusEnabled), ), DropdownMenuItem( value: 0, child: Text(t.marqueeStatusDisabled), ), ], onChanged: (v) { if (v == null) return; setState(() => _status = v); }, ), const SizedBox(height: 16), Row( children: [ Expanded( child: _DateTimeField( label: t.marqueeStartTime, value: _startTime, onChanged: (v) => setState(() => _startTime = v), ), ), const SizedBox(width: 12), Expanded( child: _DateTimeField( label: t.marqueeEndTime, value: _endTime, onChanged: (v) => setState(() => _endTime = v), ), ), ], ), const SizedBox(height: 24), SizedBox( width: double.infinity, child: FilledButton( onPressed: _submit, child: Text(t.save), ), ), ], ), ), ), ); } void _submit() { if (!_formKey.currentState!.validate()) return; final store = widget.stores.firstWhere( (s) => s.orgId == _storeId, orElse: () => const StoreInfo( orgId: '', name: '', address: '', contactPhone: '', activationCode: '', status: 0, expireTime: null, createTime: null, ), ); Navigator.pop(context, { 'id': widget.editing?.id, 'storeId': _storeId, 'storeName': store.name, 'content': _contentController.text.trim(), 'speed': _speed.value, 'fontColor': _fontColorController.text.trim(), 'backgroundColor': _backgroundColorController.text.trim(), 'fontSize': int.tryParse(_fontSizeController.text.trim()) ?? 32, 'level': _grade.value, if (_startTime != null) 'startTime': _startTime!.toIso8601String(), if (_endTime != null) 'endTime': _endTime!.toIso8601String(), 'status': _status, }); } } /// 颜色选择器弹窗,对齐 Web 端 `el-color-picker` 交互: /// - 饱和度/明度 2D 选择区 /// - 色相条 /// - HEX 输入框 /// - 当前颜色预览 /// - 取消/确定按钮 class _ColorPickerSheet extends StatefulWidget { const _ColorPickerSheet({this.initialColor}); final String? initialColor; @override State<_ColorPickerSheet> createState() => _ColorPickerSheetState(); } class _ColorPickerSheetState extends State<_ColorPickerSheet> { late HSVColor _hsv; final _hexCtrl = TextEditingController(); @override void initState() { super.initState(); final color = _parseColor(widget.initialColor) ?? const Color(0xFFFFFFFF); _hsv = HSVColor.fromColor(color); _hexCtrl.text = _colorToHex(_hsv.toColor()); } @override void dispose() { _hexCtrl.dispose(); super.dispose(); } void _updateFromHsv(HSVColor hsv) { setState(() { _hsv = hsv; _hexCtrl.text = _colorToHex(hsv.toColor()); }); } void _onHexSubmitted(String value) { final color = _parseColor(value); if (color == null) return; setState(() => _hsv = HSVColor.fromColor(color)); } @override Widget build(BuildContext context) { final t = AppLocalizations.of(context); final color = _hsv.toColor(); final bottom = MediaQuery.of(context).viewInsets.bottom; return Padding( padding: EdgeInsets.only(bottom: bottom), child: Column( mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Text( t.marqueeFontColor, style: Theme.of(context) .textTheme .titleMedium ?.copyWith(fontWeight: FontWeight.bold), ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: _SaturationValueBox( hsv: _hsv, onChanged: (sat, val) => _updateFromHsv(_hsv.withSaturation(sat).withValue(val)), ), ), const SizedBox(height: 12), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: _HueSlider( hue: _hsv.hue, onChanged: (h) => _updateFromHsv(_hsv.withHue(h)), ), ), const SizedBox(height: 12), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ Container( width: 40, height: 40, decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(8), border: Border.all( color: Theme.of(context).colorScheme.outlineVariant, ), ), ), const SizedBox(width: 12), Expanded( child: TextField( controller: _hexCtrl, decoration: const InputDecoration( labelText: 'HEX', border: OutlineInputBorder(), prefixText: '#', ), textCapitalization: TextCapitalization.characters, onSubmitted: _onHexSubmitted, ), ), ], ), ), Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), child: Row( children: [ Expanded( child: OutlinedButton( onPressed: () => Navigator.pop(context), child: Text(t.cancel), ), ), const SizedBox(width: 12), Expanded( child: FilledButton( onPressed: () => Navigator.pop(context, _colorToHex(color)), child: Text(t.confirm), ), ), ], ), ), SizedBox(height: MediaQuery.of(context).padding.bottom), ], ), ); } } class _SaturationValueBox extends StatelessWidget { const _SaturationValueBox({required this.hsv, required this.onChanged}); final HSVColor hsv; final void Function(double saturation, double value) onChanged; @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final size = Size(constraints.maxWidth, constraints.maxWidth); return GestureDetector( onPanUpdate: (d) => _handle(d.localPosition, size), onTapUp: (d) => _handle(d.localPosition, size), child: SizedBox( width: size.width, height: size.height, child: Stack( children: [ CustomPaint( size: size, painter: _SaturationValuePainter(hue: hsv.hue), ), Positioned( left: hsv.saturation * size.width - 8, top: (1 - hsv.value) * size.height - 8, child: const _PickerThumb(), ), ], ), ), ); }, ); } void _handle(Offset local, Size size) { final x = local.dx.clamp(0, size.width); final y = local.dy.clamp(0, size.height); onChanged(x / size.width, 1 - y / size.height); } } class _HueSlider extends StatelessWidget { const _HueSlider({required this.hue, required this.onChanged}); final double hue; final ValueChanged onChanged; @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final size = Size(constraints.maxWidth, 32); return GestureDetector( onPanUpdate: (d) => _handle(d.localPosition, size), onTapUp: (d) => _handle(d.localPosition, size), child: SizedBox( width: size.width, height: size.height, child: Stack( children: [ CustomPaint( size: size, painter: const _HuePainter(), ), Positioned( left: (hue / 360) * size.width - 6, top: 2, child: Container( width: 12, height: 28, decoration: BoxDecoration( color: Colors.white, border: Border.all(color: Colors.black38), borderRadius: BorderRadius.circular(4), ), ), ), ], ), ), ); }, ); } void _handle(Offset local, Size size) { onChanged((local.dx / size.width).clamp(0, 1) * 360); } } class _SaturationValuePainter extends CustomPainter { const _SaturationValuePainter({required this.hue}); final double hue; @override void paint(Canvas canvas, Size size) { final rect = Rect.fromLTWH(0, 0, size.width, size.height); final base = HSVColor.fromAHSV(1, hue, 1, 1).toColor(); final gradientH = LinearGradient( colors: [Colors.white, base], ).createShader(rect); final gradientV = const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Colors.transparent, Colors.black], ).createShader(rect); final paint = Paint()..style = PaintingStyle.fill; canvas.drawRect(rect, paint..shader = gradientH); canvas.drawRect(rect, paint..shader = gradientV); } @override bool shouldRepaint(covariant _SaturationValuePainter old) => old.hue != hue; } class _HuePainter extends CustomPainter { const _HuePainter(); @override void paint(Canvas canvas, Size size) { final rect = Rect.fromLTWH(0, 0, size.width, size.height); final colors = [ HSVColor.fromAHSV(1, 0, 1, 1).toColor(), HSVColor.fromAHSV(1, 60, 1, 1).toColor(), HSVColor.fromAHSV(1, 120, 1, 1).toColor(), HSVColor.fromAHSV(1, 180, 1, 1).toColor(), HSVColor.fromAHSV(1, 240, 1, 1).toColor(), HSVColor.fromAHSV(1, 300, 1, 1).toColor(), HSVColor.fromAHSV(1, 360, 1, 1).toColor(), ]; final gradient = LinearGradient(colors: colors).createShader(rect); canvas.drawRect(rect, Paint()..shader = gradient..style = PaintingStyle.fill); } @override bool shouldRepaint(covariant CustomPainter old) => false; } class _PickerThumb extends StatelessWidget { const _PickerThumb(); @override Widget build(BuildContext context) { return Container( width: 16, height: 16, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2), boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 2)], ), ); } } Color? _parseColor(String? hex) { if (hex == null || hex.isEmpty) return null; final normalized = hex.trim(); try { if (normalized.length == 7 && normalized.startsWith('#')) { return Color(int.parse('FF${normalized.substring(1)}', radix: 16)); } if (normalized.length == 9 && normalized.startsWith('#')) { return Color(int.parse(normalized.substring(1), radix: 16)); } if (normalized.length == 6 && !normalized.startsWith('#')) { return Color(int.parse('FF$normalized', radix: 16)); } } catch (_) { return null; } return null; } String _colorToHex(Color color) { final hex = color.value.toRadixString(16).padLeft(8, '0').toUpperCase(); return '#${hex.substring(2)}'; }