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

1490 lines
46 KiB
Dart
Raw Permalink Normal View History

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)}';
}