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