1490 lines
46 KiB
Dart
1490 lines
46 KiB
Dart
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)}';
|
||
}
|