Initial commit
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
|
||||
import '../../../providers/danmaku_settings_provider.dart';
|
||||
import '../../../shared/constants/breakpoints.dart';
|
||||
import '../../../shared/widgets/adaptive_modal.dart';
|
||||
|
||||
void showBlockedKeywordsDialog(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
List<String> keywords,
|
||||
) {
|
||||
showAdaptiveModal<void>(
|
||||
context: context,
|
||||
maxWidth: 420,
|
||||
compactMaxHeightFactor: 0.9,
|
||||
builder: (ctx) => _BlockedKeywordsDialog(
|
||||
keywords: keywords,
|
||||
onAdd: (kw) =>
|
||||
ref.read(danmakuSettingsProvider.notifier).addBlockedKeyword(kw),
|
||||
onRemove: (kw) =>
|
||||
ref.read(danmakuSettingsProvider.notifier).removeBlockedKeyword(kw),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _BlockedKeywordsDialog extends StatefulWidget {
|
||||
final List<String> keywords;
|
||||
final ValueChanged<String> onAdd;
|
||||
final ValueChanged<String> onRemove;
|
||||
|
||||
const _BlockedKeywordsDialog({
|
||||
required this.keywords,
|
||||
required this.onAdd,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_BlockedKeywordsDialog> createState() => _BlockedKeywordsDialogState();
|
||||
}
|
||||
|
||||
class _BlockedKeywordsDialogState extends State<_BlockedKeywordsDialog> {
|
||||
late final List<String> _keywords;
|
||||
final _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_keywords = List.of(widget.keywords);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _add() {
|
||||
final text = _controller.text.trim();
|
||||
if (text.isEmpty || _keywords.contains(text)) return;
|
||||
setState(() => _keywords.add(text));
|
||||
widget.onAdd(text);
|
||||
_controller.clear();
|
||||
}
|
||||
|
||||
void _remove(String kw) {
|
||||
setState(() => _keywords.remove(kw));
|
||||
widget.onRemove(kw);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isCompact = Breakpoints.isCompact(MediaQuery.sizeOf(context).width);
|
||||
final chipsMaxHeight =
|
||||
MediaQuery.sizeOf(context).height * (isCompact ? 0.38 : 0.34);
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.fromLTRB(24, isCompact ? 16 : 24, 24, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('屏蔽关键词', style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'匹配到这些关键词的弹幕将被隐藏',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '输入关键词后回车添加',
|
||||
isDense: true,
|
||||
),
|
||||
textInputAction: TextInputAction.done,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
onSubmitted: (_) => _add(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filledTonal(
|
||||
tooltip: '添加',
|
||||
onPressed: _add,
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_keywords.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Text(
|
||||
'暂无屏蔽关键词',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
)
|
||||
else
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: chipsMaxHeight),
|
||||
child: SingleChildScrollView(
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _keywords
|
||||
.map(
|
||||
(kw) => Chip(
|
||||
label: Text(kw),
|
||||
onDeleted: () => _remove(kw),
|
||||
deleteIcon: const Icon(Icons.close, size: 14),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (isCompact)
|
||||
FButton(
|
||||
onPress: () => Navigator.pop(context),
|
||||
child: const Text('完成'),
|
||||
)
|
||||
else
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: FButton(
|
||||
variant: FButtonVariant.ghost,
|
||||
onPress: () => Navigator.pop(context),
|
||||
child: const Text('完成'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,788 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/contracts/danmaku.dart';
|
||||
import '../../../providers/danmaku_settings_provider.dart';
|
||||
import '../../../shared/constants/breakpoints.dart';
|
||||
import '../../../shared/theme/app_theme_scope.dart';
|
||||
import '../../../shared/theme/app_theme.dart';
|
||||
|
||||
String danmakuSourcesSummary(List<DanmakuSource> sources) {
|
||||
if (sources.isEmpty) return '未配置';
|
||||
final enabled = sources.where((s) => s.enabled).length;
|
||||
if (enabled == 0) return '已配置 ${sources.length} 个,全部停用';
|
||||
final first = sources.firstWhere(
|
||||
(s) => s.enabled,
|
||||
orElse: () => sources.first,
|
||||
);
|
||||
return '$enabled/${sources.length} 个启用,优先:${first.displayName}';
|
||||
}
|
||||
|
||||
void showDanmakuSourcesDialog(
|
||||
BuildContext context,
|
||||
List<DanmakuSource> sources,
|
||||
) {
|
||||
final isCompact = MediaQuery.sizeOf(context).width < Breakpoints.compact;
|
||||
if (isCompact) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => _DanmakuSourcesSheet(initialSources: sources),
|
||||
);
|
||||
} else {
|
||||
showFSheet<void>(
|
||||
context: context,
|
||||
side: FLayout.rtl,
|
||||
mainAxisMaxRatio: 0.42,
|
||||
draggable: false,
|
||||
builder: (_) => AppThemeScope(
|
||||
child: _DanmakuSourcesDesktopSheet(initialSources: sources),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mixin _DanmakuSourcesLogic<T extends ConsumerStatefulWidget>
|
||||
on ConsumerState<T> {
|
||||
late List<DanmakuSource> sources;
|
||||
|
||||
void initSources(List<DanmakuSource> initial) {
|
||||
sources = List.of(initial);
|
||||
}
|
||||
|
||||
Future<void> persist(List<DanmakuSource> next) async {
|
||||
final normalized = await ref
|
||||
.read(danmakuSettingsProvider.notifier)
|
||||
.setSources(next);
|
||||
if (!mounted) return;
|
||||
setState(() => sources = normalized);
|
||||
}
|
||||
|
||||
Future<void> showEditDialog(DanmakuSource? source) async {
|
||||
final nameCtrl = TextEditingController(text: source?.name ?? '');
|
||||
final urlCtrl = TextEditingController(text: source?.url ?? '');
|
||||
try {
|
||||
final saved = await showFDialog<DanmakuSource>(
|
||||
context: context,
|
||||
builder: (ctx, _, animation) => AppThemeScope(
|
||||
child: FDialog.adaptive(
|
||||
animation: animation,
|
||||
title: Text(source == null ? '添加弹幕源' : '编辑弹幕源'),
|
||||
body: SizedBox(
|
||||
width: 420,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '名称',
|
||||
hintText: '例如:主源、备用源',
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: urlCtrl,
|
||||
autofocus: source == null,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '服务地址',
|
||||
hintText: 'http://localhost:8080',
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
FButton(
|
||||
onPress: () {
|
||||
final url = urlCtrl.text.trim();
|
||||
if (url.isEmpty) return;
|
||||
final id = source?.id.isNotEmpty == true
|
||||
? source!.id
|
||||
: 'src_${url.hashCode.toUnsigned(32).toRadixString(16)}';
|
||||
Navigator.pop(
|
||||
ctx,
|
||||
DanmakuSource(
|
||||
id: id,
|
||||
name: nameCtrl.text.trim(),
|
||||
url: url,
|
||||
enabled: source?.enabled ?? true,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('保存'),
|
||||
),
|
||||
FButton(
|
||||
onPress: () => Navigator.pop(ctx),
|
||||
variant: FButtonVariant.outline,
|
||||
child: const Text('取消'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (saved == null) return;
|
||||
|
||||
final existing = sources.indexWhere((s) => s.id == saved.id);
|
||||
final next = [...sources];
|
||||
if (existing >= 0) {
|
||||
next[existing] = saved;
|
||||
} else {
|
||||
next.add(saved);
|
||||
}
|
||||
await persist(next);
|
||||
} finally {
|
||||
nameCtrl.dispose();
|
||||
urlCtrl.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> removeSource(DanmakuSource source) async {
|
||||
await persist(sources.where((s) => s.id != source.id).toList());
|
||||
}
|
||||
|
||||
Future<void> toggleSource(DanmakuSource source, bool enabled) async {
|
||||
await persist(
|
||||
sources
|
||||
.map((s) => s.id == source.id ? s.copyWith(enabled: enabled) : s)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> reorderSource(int oldIndex, int newIndex) async {
|
||||
final next = [...sources];
|
||||
if (newIndex > oldIndex) newIndex -= 1;
|
||||
final item = next.removeAt(oldIndex);
|
||||
next.insert(newIndex, item);
|
||||
await persist(next);
|
||||
}
|
||||
}
|
||||
|
||||
class _DanmakuSourcesSheet extends ConsumerStatefulWidget {
|
||||
final List<DanmakuSource> initialSources;
|
||||
|
||||
const _DanmakuSourcesSheet({required this.initialSources});
|
||||
|
||||
@override
|
||||
ConsumerState<_DanmakuSourcesSheet> createState() =>
|
||||
_DanmakuSourcesSheetState();
|
||||
}
|
||||
|
||||
class _DanmakuSourcesSheetState extends ConsumerState<_DanmakuSourcesSheet>
|
||||
with _DanmakuSourcesLogic<_DanmakuSourcesSheet> {
|
||||
bool _editing = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
initSources(widget.initialSources);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> persist(List<DanmakuSource> next) async {
|
||||
await super.persist(next);
|
||||
if (mounted && sources.isEmpty && _editing) {
|
||||
setState(() => _editing = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final tokens = context.appTokens;
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.sizeOf(context).height * 0.7,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 8, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'弹幕源',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (sources.isNotEmpty)
|
||||
TextButton(
|
||||
onPressed: () => setState(() => _editing = !_editing),
|
||||
child: Text(_editing ? '完成' : '编辑'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'按顺序自动匹配;启用的源会聚合显示候选,选中后从对应源加载弹幕。',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (sources.isNotEmpty)
|
||||
Flexible(
|
||||
child: _editing
|
||||
? ReorderableListView.builder(
|
||||
shrinkWrap: true,
|
||||
buildDefaultDragHandles: false,
|
||||
itemCount: sources.length,
|
||||
onReorder: (oldIdx, newIdx) =>
|
||||
unawaited(reorderSource(oldIdx, newIdx)),
|
||||
itemBuilder: (context, index) {
|
||||
final source = sources[index];
|
||||
return _DanmakuEditRow(
|
||||
key: ValueKey(source.id),
|
||||
source: source,
|
||||
index: index,
|
||||
onDelete: () => unawaited(removeSource(source)),
|
||||
onEdit: () => unawaited(showEditDialog(source)),
|
||||
);
|
||||
},
|
||||
)
|
||||
: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: sources.length,
|
||||
itemBuilder: (context, index) {
|
||||
final source = sources[index];
|
||||
final hasName = source.name.trim().isNotEmpty;
|
||||
return InkWell(
|
||||
onTap: () => unawaited(showEditDialog(source)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 10,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
hasName
|
||||
? source.name.trim()
|
||||
: source.url,
|
||||
style: theme.textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (hasName)
|
||||
Text(
|
||||
source.url,
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: tokens.mutedForeground,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
FSwitch(
|
||||
value: source.enabled,
|
||||
onChange: (v) =>
|
||||
unawaited(toggleSource(source, v)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
8,
|
||||
4,
|
||||
8,
|
||||
MediaQuery.paddingOf(context).bottom + 16,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => unawaited(showEditDialog(null)),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('添加弹幕源'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DanmakuEditRow extends StatefulWidget {
|
||||
final DanmakuSource source;
|
||||
final int index;
|
||||
final VoidCallback onDelete;
|
||||
final VoidCallback onEdit;
|
||||
|
||||
const _DanmakuEditRow({
|
||||
required super.key,
|
||||
required this.source,
|
||||
required this.index,
|
||||
required this.onDelete,
|
||||
required this.onEdit,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_DanmakuEditRow> createState() => _DanmakuEditRowState();
|
||||
}
|
||||
|
||||
class _DanmakuEditRowState extends State<_DanmakuEditRow>
|
||||
with SingleTickerProviderStateMixin {
|
||||
static const _deleteWidth = 72.0;
|
||||
|
||||
late final AnimationController _controller;
|
||||
late final Animation<Offset> _slideAnim;
|
||||
bool _expanded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
);
|
||||
_slideAnim = Tween<Offset>(
|
||||
begin: Offset.zero,
|
||||
end: const Offset(-0.2, 0),
|
||||
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _toggle() {
|
||||
setState(() => _expanded = !_expanded);
|
||||
if (_expanded) {
|
||||
_controller.forward();
|
||||
} else {
|
||||
_controller.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final tokens = context.appTokens;
|
||||
final source = widget.source;
|
||||
final hasName = source.name.trim().isNotEmpty;
|
||||
|
||||
return ClipRect(
|
||||
child: SizedBox(
|
||||
height: hasName ? 64 : 52,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: _deleteWidth,
|
||||
child: Material(
|
||||
color: Colors.red,
|
||||
child: InkWell(
|
||||
onTap: widget.onDelete,
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'删除',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SlideTransition(
|
||||
position: _slideAnim,
|
||||
child: Container(
|
||||
color: theme.colorScheme.surface,
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: _toggle,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: AnimatedRotation(
|
||||
turns: _expanded ? 0.0 : -0.25,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: const Icon(
|
||||
Icons.remove_circle,
|
||||
color: Colors.red,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: widget.onEdit,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
hasName ? source.name.trim() : source.url,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (hasName)
|
||||
Text(
|
||||
source.url,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: tokens.mutedForeground,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
ReorderableDragStartListener(
|
||||
index: widget.index,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Icon(
|
||||
Icons.drag_handle,
|
||||
size: 18,
|
||||
color: tokens.mutedForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DanmakuSourcesDesktopSheet extends ConsumerStatefulWidget {
|
||||
final List<DanmakuSource> initialSources;
|
||||
|
||||
const _DanmakuSourcesDesktopSheet({required this.initialSources});
|
||||
|
||||
@override
|
||||
ConsumerState<_DanmakuSourcesDesktopSheet> createState() =>
|
||||
_DanmakuSourcesDesktopSheetState();
|
||||
}
|
||||
|
||||
class _DanmakuSourcesDesktopSheetState
|
||||
extends ConsumerState<_DanmakuSourcesDesktopSheet>
|
||||
with _DanmakuSourcesLogic<_DanmakuSourcesDesktopSheet> {
|
||||
bool _editing = false;
|
||||
DanmakuSource? _editingSource;
|
||||
final _nameCtrl = TextEditingController();
|
||||
final _urlCtrl = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
initSources(widget.initialSources);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameCtrl.dispose();
|
||||
_urlCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startEdit(DanmakuSource? source) {
|
||||
_nameCtrl.text = source?.name ?? '';
|
||||
_urlCtrl.text = source?.url ?? '';
|
||||
setState(() {
|
||||
_editing = true;
|
||||
_editingSource = source;
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelEdit() {
|
||||
setState(() {
|
||||
_editing = false;
|
||||
_editingSource = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _saveEdit() async {
|
||||
final url = _urlCtrl.text.trim();
|
||||
if (url.isEmpty) return;
|
||||
final source = _editingSource;
|
||||
final id = source?.id.isNotEmpty == true
|
||||
? source!.id
|
||||
: 'src_${url.hashCode.toUnsigned(32).toRadixString(16)}';
|
||||
final saved = DanmakuSource(
|
||||
id: id,
|
||||
name: _nameCtrl.text.trim(),
|
||||
url: url,
|
||||
enabled: source?.enabled ?? true,
|
||||
);
|
||||
final existing = sources.indexWhere((s) => s.id == saved.id);
|
||||
final next = [...sources];
|
||||
if (existing >= 0) {
|
||||
next[existing] = saved;
|
||||
} else {
|
||||
next.add(saved);
|
||||
}
|
||||
await persist(next);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_editing = false;
|
||||
_editingSource = null;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Material(
|
||||
color: theme.colorScheme.surface,
|
||||
child: SafeArea(
|
||||
left: false,
|
||||
right: false,
|
||||
child: Column(
|
||||
children: [
|
||||
_buildHeader(theme),
|
||||
const Divider(height: 1),
|
||||
Expanded(child: _editing ? _buildEditView() : _buildListView(theme)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(ThemeData theme) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 8, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
if (_editing)
|
||||
IconButton(
|
||||
tooltip: '返回',
|
||||
icon: const Icon(Icons.arrow_back, size: 20),
|
||||
onPressed: _cancelEdit,
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_editing
|
||||
? (_editingSource == null ? '添加弹幕源' : '编辑弹幕源')
|
||||
: '弹幕源',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '关闭',
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildListView(ThemeData theme) {
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 12),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'按顺序自动匹配;启用的源会聚合显示候选,选中后从对应源加载弹幕。',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: sources.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'尚未配置弹幕源',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ReorderableListView.builder(
|
||||
buildDefaultDragHandles: false,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
itemCount: sources.length,
|
||||
onReorder: (oldIndex, newIndex) =>
|
||||
unawaited(reorderSource(oldIndex, newIndex)),
|
||||
itemBuilder: (context, index) {
|
||||
final source = sources[index];
|
||||
return ListTile(
|
||||
key: ValueKey(source.id),
|
||||
leading: ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: const Icon(Icons.drag_handle, size: 18),
|
||||
),
|
||||
title: Text(
|
||||
source.displayName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: source.name.trim().isEmpty
|
||||
? null
|
||||
: Text(
|
||||
source.url,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FSwitch(
|
||||
value: source.enabled,
|
||||
onChange: (v) => unawaited(toggleSource(source, v)),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '编辑',
|
||||
icon: const Icon(Icons.edit_outlined, size: 18),
|
||||
onPressed: () => _startEdit(source),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '移除',
|
||||
icon: const Icon(Icons.delete_outline, size: 18),
|
||||
onPressed: () => unawaited(removeSource(source)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FButton(
|
||||
onPress: () => _startEdit(null),
|
||||
prefix: const Icon(Icons.add, size: 16),
|
||||
child: const Text('添加'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEditView() {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _nameCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '名称',
|
||||
hintText: '例如:主源、备用源',
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _urlCtrl,
|
||||
autofocus: _editingSource == null,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '服务地址',
|
||||
hintText: 'http://localhost:8080',
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FButton(
|
||||
onPress: _cancelEdit,
|
||||
variant: FButtonVariant.outline,
|
||||
child: const Text('取消'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FButton(
|
||||
onPress: () => unawaited(_saveEdit()),
|
||||
child: const Text('保存'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../providers/player_engine_override_provider.dart';
|
||||
import '../../../shared/widgets/setting_select.dart';
|
||||
|
||||
|
||||
class PlayerEngineRow extends StatelessWidget {
|
||||
final PlayerEngineOverride value;
|
||||
final ValueChanged<PlayerEngineOverride> onChanged;
|
||||
|
||||
|
||||
final bool? isAndroid;
|
||||
|
||||
const PlayerEngineRow({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
this.isAndroid,
|
||||
});
|
||||
|
||||
|
||||
static List<PlayerEngineOverride> visibleOptions({required bool isAndroid}) {
|
||||
return PlayerEngineOverride.values
|
||||
.where((v) => v != PlayerEngineOverride.media3 || isAndroid)
|
||||
.toList();
|
||||
}
|
||||
|
||||
|
||||
static PlayerEngineOverride coerceValue(
|
||||
PlayerEngineOverride value, {
|
||||
required bool isAndroid,
|
||||
}) {
|
||||
if (value == PlayerEngineOverride.media3 && !isAndroid) {
|
||||
return PlayerEngineOverride.auto;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final onAndroid = isAndroid ?? Platform.isAndroid;
|
||||
|
||||
final options = visibleOptions(isAndroid: onAndroid);
|
||||
final coercedValue = coerceValue(value, isAndroid: onAndroid);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'播放引擎',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingSelect<PlayerEngineOverride>(
|
||||
value: coercedValue,
|
||||
options: options,
|
||||
labelOf: (v) => v.label,
|
||||
onChanged: (v) {
|
||||
if (v != null) onChanged(v);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../shared/theme/app_theme.dart';
|
||||
import '../../../shared/utils/user_error_formatter.dart';
|
||||
import '../../../shared/widgets/app_inline_alert.dart';
|
||||
|
||||
|
||||
Widget settingsLoadError(Object error) {
|
||||
return AppInlineAlert(message: '加载失败:${formatUserError(error)}');
|
||||
}
|
||||
|
||||
class SettingsTabScroll extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
const SettingsTabScroll({super.key, required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(0, 12, 0, 32),
|
||||
children: [
|
||||
Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 720),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: children,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SettingsCard extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
|
||||
const SettingsCard({super.key, required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final dividerColor = isDark
|
||||
? Colors.white.withValues(alpha: 0.08)
|
||||
: Colors.black.withValues(alpha: 0.06);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? const Color(0xFF2C2C2E) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: dividerColor, width: 0.5),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 0; i < children.length; i++) ...[
|
||||
children[i],
|
||||
if (i < children.length - 1)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 0.5,
|
||||
indent: 16,
|
||||
endIndent: 16,
|
||||
color: dividerColor,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SettingRow extends StatelessWidget {
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final Widget? trailing;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const SettingRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.trailing,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final tokens = context.appTokens;
|
||||
|
||||
Widget content = Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: tokens.mutedForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null) trailing!,
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (onTap != null) {
|
||||
content = InkWell(onTap: onTap, child: content);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/contracts/player_gestures.dart';
|
||||
import '../../../shared/widgets/setting_select.dart';
|
||||
import '../../player/gestures/gesture_action.dart' as g;
|
||||
import '../utils/settings_labels.dart';
|
||||
|
||||
class SeekDurationRow extends StatelessWidget {
|
||||
final String title;
|
||||
final int seconds;
|
||||
final ValueChanged<int> onChanged;
|
||||
|
||||
static const _options = [5, 10, 15, 20, 30, 45, 60, 90, 120];
|
||||
|
||||
const SeekDurationRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.seconds,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingSelect<int>(
|
||||
value: seconds,
|
||||
options: _options,
|
||||
labelOf: (value) => '$value 秒',
|
||||
onChanged: (v) {
|
||||
if (v != null) onChanged(v);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GestureSpeedRow extends StatelessWidget {
|
||||
final String title;
|
||||
final double value;
|
||||
final ValueChanged<double> onChanged;
|
||||
|
||||
static const _options = [
|
||||
0.5,
|
||||
0.75,
|
||||
1.0,
|
||||
1.25,
|
||||
1.5,
|
||||
1.75,
|
||||
2.0,
|
||||
2.5,
|
||||
3.0,
|
||||
4.0,
|
||||
8.0,
|
||||
];
|
||||
|
||||
const GestureSpeedRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingSelect<double>(
|
||||
value: value,
|
||||
options: _options,
|
||||
labelOf: formatMenuSpeed,
|
||||
onChanged: (v) {
|
||||
if (v != null) onChanged(v);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GestureActionRow extends StatelessWidget {
|
||||
final String title;
|
||||
final g.GestureKind kind;
|
||||
final GestureAction value;
|
||||
final ValueChanged<GestureAction> onChanged;
|
||||
|
||||
const GestureActionRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.kind,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final options = g.allowedActionsFor(kind);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingSelect<GestureAction>(
|
||||
value: value,
|
||||
options: options,
|
||||
labelOf: g.gestureActionLabel,
|
||||
onChanged: (v) {
|
||||
if (v != null) onChanged(v);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
|
||||
import '../../../shared/theme/app_theme.dart';
|
||||
|
||||
class StepperRow extends StatelessWidget {
|
||||
final String title;
|
||||
final String display;
|
||||
final VoidCallback? onDecrement;
|
||||
final VoidCallback? onIncrement;
|
||||
|
||||
const StepperRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.display,
|
||||
this.onDecrement,
|
||||
this.onIncrement,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final tokens = context.appTokens;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
_stepBtn(Icons.remove, onDecrement, theme),
|
||||
SizedBox(
|
||||
width: 52,
|
||||
child: Text(
|
||||
display,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: tokens.mutedForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
_stepBtn(Icons.add, onIncrement, theme),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stepBtn(IconData icon, VoidCallback? onPressed, ThemeData theme) {
|
||||
return SizedBox.square(
|
||||
dimension: 28,
|
||||
child: FButton.icon(
|
||||
variant: FButtonVariant.ghost,
|
||||
size: FButtonSizeVariant.xs,
|
||||
onPress: onPressed,
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 14,
|
||||
color: onPressed != null
|
||||
? theme.colorScheme.onSurface
|
||||
: theme.colorScheme.onSurface.withValues(alpha: 0.25),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SettingsTextFieldRow extends StatelessWidget {
|
||||
final String title;
|
||||
final String hintText;
|
||||
final TextEditingController controller;
|
||||
final FocusNode focusNode;
|
||||
final ValueChanged<String> onChanged;
|
||||
final bool obscureText;
|
||||
|
||||
const SettingsTextFieldRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.hintText,
|
||||
required this.controller,
|
||||
required this.focusNode,
|
||||
required this.onChanged,
|
||||
this.obscureText = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 360),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
obscureText: obscureText,
|
||||
onChanged: onChanged,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
isDense: true,
|
||||
suffixIcon: controller.text.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
tooltip: '清空',
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
onPressed: () {
|
||||
controller.clear();
|
||||
onChanged('');
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
|
||||
import '../../../providers/version_priority_provider.dart';
|
||||
import '../../../shared/theme/app_theme.dart';
|
||||
import '../../../shared/theme/app_theme_scope.dart';
|
||||
|
||||
class VersionPrioritySection extends StatelessWidget {
|
||||
final List<VersionPriority> activePriorities;
|
||||
final ValueChanged<VersionPriority> onToggle;
|
||||
final ValueChanged<List<VersionPriority>> onReorder;
|
||||
|
||||
const VersionPrioritySection({
|
||||
super.key,
|
||||
required this.activePriorities,
|
||||
required this.onToggle,
|
||||
required this.onReorder,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final tokens = context.appTokens;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'选择后按顺序排列详情页的视频版本',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: tokens.mutedForeground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: VersionPriority.values.map((p) {
|
||||
final isActive = activePriorities.contains(p);
|
||||
return FilterChip(
|
||||
label: Text(p.label),
|
||||
selected: isActive,
|
||||
onSelected: (_) => onToggle(p),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
if (activePriorities.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
activePriorities
|
||||
.map((p) => p.label.replaceAll('优先', ''))
|
||||
.join(' > '),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: tokens.mutedForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (activePriorities.length > 1) ...[
|
||||
const SizedBox(height: 8),
|
||||
GestureDetector(
|
||||
onTap: () => _showReorderDialog(context),
|
||||
child: Text(
|
||||
'调整顺序',
|
||||
style: TextStyle(fontSize: 12, color: theme.colorScheme.primary),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _showReorderDialog(BuildContext context) {
|
||||
showFDialog(
|
||||
context: context,
|
||||
builder: (_, _, animation) => AppThemeScope(
|
||||
child: FDialog.raw(
|
||||
animation: animation,
|
||||
builder: (context, _) => PriorityOrderDialog(
|
||||
priorities: activePriorities,
|
||||
onChanged: onReorder,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PriorityOrderDialog extends StatefulWidget {
|
||||
final List<VersionPriority> priorities;
|
||||
final ValueChanged<List<VersionPriority>> onChanged;
|
||||
|
||||
const PriorityOrderDialog({
|
||||
super.key,
|
||||
required this.priorities,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PriorityOrderDialog> createState() => PriorityOrderDialogState();
|
||||
}
|
||||
|
||||
class PriorityOrderDialogState extends State<PriorityOrderDialog> {
|
||||
late final List<VersionPriority> _items;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_items = List.of(widget.priorities);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FDialog(
|
||||
title: const Text('调整优先级顺序'),
|
||||
body: SizedBox(
|
||||
width: 320,
|
||||
height: (_items.length * 56.0).clamp(0, 400),
|
||||
child: ReorderableListView.builder(
|
||||
shrinkWrap: true,
|
||||
buildDefaultDragHandles: false,
|
||||
itemCount: _items.length,
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
setState(() {
|
||||
if (newIndex > oldIndex) newIndex -= 1;
|
||||
final item = _items.removeAt(oldIndex);
|
||||
_items.insert(newIndex, item);
|
||||
});
|
||||
widget.onChanged(List.of(_items));
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
return ListTile(
|
||||
key: ValueKey(_items[index].name),
|
||||
leading: Text(
|
||||
'${index + 1}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
_items[index].label,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
trailing: ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: const Icon(Icons.drag_handle, size: 18),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
FButton(onPress: () => Navigator.pop(context), child: const Text('完成')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user