789 lines
25 KiB
Dart
789 lines
25 KiB
Dart
|
|
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('保存'),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|