Files
CloudHome-Ops/lib/screens/admin/marquee/marquee_management_page.dart

1490 lines
46 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<MarqueeManagementPage> createState() =>
_MarqueeManagementPageState();
}
class _MarqueeManagementPageState extends ConsumerState<MarqueeManagementPage> {
final MarqueeService _service = MarqueeService();
final _searchController = TextEditingController();
List<MarqueeMessage> _items = [];
List<StoreInfo> _stores = [];
bool _loading = false;
String? _error;
String _keyword = '';
String? _selectedStoreId;
AppLocalizations get t => AppLocalizations.of(context);
@override
void initState() {
super.initState();
_init();
}
/// 初始化:先加载门店,受限账号默认选中第一家门店(避免「全部门店」视图
/// 泄露跨组织字幕),再加载字幕列表。
Future<void> _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<void> _loadStores() async {
try {
final stores = await _service.getStores();
if (!mounted) return;
setState(() => _stores = stores);
} catch (_) {
// 门店列表加载失败不影响主列表展示。
}
}
Future<void> _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<void> _refresh() async => _loadData();
void _onSearchChanged(String value) {
_keyword = value.trim();
_loadData();
}
Future<void> _showForm({MarqueeMessage? editing}) async {
final result = await showModalBottomSheet<Map<String, dynamic>>(
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<void> _deleteMarquee(MarqueeMessage item) async {
final confirmed = await showDialog<bool>(
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<void> _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<void> _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<String?>(
value: _selectedStoreId,
decoration: InputDecoration(
labelText: t.filterByStore,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
),
items: [
if (!SessionOrgScope.instance.enabled)
DropdownMenuItem<String?>(
value: null,
child: Text(t.allStores),
),
..._stores.map((s) => DropdownMenuItem<String?>(
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<DateTime?> 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<DateTime?>(
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<int> 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<StoreInfo> stores;
final MarqueeMessage? editing;
@override
State<_MarqueeForm> createState() => _MarqueeFormState();
}
class _MarqueeFormState extends State<_MarqueeForm> {
final _formKey = GlobalKey<FormState>();
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<void> _pickColor(TextEditingController controller) async {
final result = await showModalBottomSheet<String?>(
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<MarqueeGrade>(
value: _grade,
decoration: InputDecoration(
labelText: t.marqueeLevel,
border: const OutlineInputBorder(),
),
items: MarqueeGrade.values.map((g) {
return DropdownMenuItem<MarqueeGrade>(
value: g,
child: Text(g.label(t.isEnglish)),
);
}).toList(),
onChanged: (v) {
if (v != null) setState(() => _grade = v);
},
),
),
],
),
const SizedBox(height: 16),
DropdownButtonFormField<MarqueeSpeed>(
value: _speed,
decoration: InputDecoration(
labelText: t.marqueeSpeed,
border: const OutlineInputBorder(),
),
items: MarqueeSpeed.values.map((s) {
return DropdownMenuItem<MarqueeSpeed>(
value: s,
child: Text(s.label(t.isEnglish)),
);
}).toList(),
onChanged: (v) {
if (v != null) setState(() => _speed = v);
},
),
const SizedBox(height: 16),
DropdownButtonFormField<int>(
value: _status,
decoration: InputDecoration(
labelText: t.marqueeStatus,
border: const OutlineInputBorder(),
),
items: [
DropdownMenuItem<int>(
value: 1,
child: Text(t.marqueeStatusEnabled),
),
DropdownMenuItem<int>(
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<double> 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 = <Color>[
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)}';
}