Files
easynode/native/lib/features/scripts/script_groups_page.dart

772 lines
23 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/ui/top_notice.dart';
import '../../core/api/api_result.dart';
import '../order/order_layout.dart';
import '../../l10n/app_localizations.dart';
import '../../state/api_providers.dart';
import '../../state/plus_info_notifier.dart';
import '../../state/script_group_list_notifier.dart';
import '../../state/script_list_notifier.dart';
import 'script_group_model.dart';
import 'script_model.dart';
import 'script_repository.dart';
import '../../core/ui/app_color_theme.dart';
/// Group manager — full-screen page pushed from the scripts list AppBar.
/// Mirrors design node `Poem6` and feature parity with web `script-group.vue`:
/// list groups with script-count, add/edit via bottom sheet, delete with
/// confirmation. The `default` group cannot be deleted; the `builtin` group
/// is read-only (server-seeded; backed by `local-script`).
class ScriptGroupsPage extends ConsumerStatefulWidget {
const ScriptGroupsPage({super.key});
@override
ConsumerState<ScriptGroupsPage> createState() => _ScriptGroupsPageState();
}
class _ScriptGroupsPageState extends ConsumerState<ScriptGroupsPage> {
bool _busy = false;
bool _orderMode = false;
int _orderRevision = 0;
List<ScriptGroupModel> _orderDraft = const [];
bool _ensurePlusOrWarn() {
if (ref.read(isPlusActiveProvider)) return true;
final l = AppLocalizations.of(context);
showTopNotice(context, l.tr('plus.serverManagedTip'));
return false;
}
Future<void> _openForm({ScriptGroupModel? group}) async {
if (!_ensurePlusOrWarn()) return;
final c = context.colors;
final l = AppLocalizations.of(context);
final form = ScriptGroupFormData(id: group?.id, name: group?.name ?? '');
final saved = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
backgroundColor: c.card,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(18)),
),
builder: (sheetContext) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(sheetContext).viewInsets.bottom,
),
child: _GroupForm(form: form),
);
},
);
if (saved == true) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
form.isEdit ? l.tr('scripts.editGroup') : l.tr('scripts.addGroup'),
),
),
);
}
}
Future<void> _confirmDelete(ScriptGroupModel group) async {
if (!_ensurePlusOrWarn()) return;
final l = AppLocalizations.of(context);
final ok = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(l.tr('scripts.deleteGroupTitle')),
content: Text(l.trf('scripts.deleteGroupBody', [group.displayName])),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(l.tr('common.cancel')),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: context.colors.danger,
foregroundColor: Colors.white,
),
onPressed: () => Navigator.of(context).pop(true),
child: Text(l.tr('common.delete')),
),
],
),
);
if (ok != true) return;
setState(() => _busy = true);
try {
final message = await ref
.read(scriptRepositoryProvider)
.deleteGroup(group.id);
await Future.wait([
ref.read(scriptGroupListProvider.notifier).refresh(),
ref.read(scriptListProvider.notifier).refresh(),
]);
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
} catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l.trf('scripts.deleteFailed', [error.toString()])),
),
);
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final c = context.colors;
final l = AppLocalizations.of(context);
final groupsAsync = ref.watch(scriptGroupListProvider);
final scripts =
ref.watch(scriptListProvider).valueOrNull ?? const <ScriptModel>[];
final isPlus = ref.watch(isPlusActiveProvider);
return Scaffold(
backgroundColor: c.canvas,
appBar: AppBar(
backgroundColor: c.canvas,
surfaceTintColor: Colors.transparent,
elevation: 0,
title: Text(
l.tr('scripts.groupsTitle'),
style: TextStyle(color: c.text, fontWeight: FontWeight.w800),
),
iconTheme: IconThemeData(color: c.text),
actions: [
if (_orderMode) ...[
TextButton(
onPressed: _cancelOrder,
child: Text(l.tr('common.cancel')),
),
FilledButton(
onPressed: _saveOrder,
child: Text(l.tr('common.save')),
),
] else ...[
IconButton(
tooltip: l.tr('common.adjustOrder'),
onPressed: _startOrder,
icon: const Icon(Icons.swap_vert),
),
Padding(
padding: const EdgeInsets.only(right: 12),
child: _AddChip(
label: l.tr('scripts.addGroup'),
onTap: _busy ? null : () => _openForm(),
),
),
],
],
),
body: groupsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(error.toString(), textAlign: TextAlign.center),
),
);
},
data: (groups) {
final counts = <String, int>{for (final g in groups) g.id: 0};
for (final s in scripts) {
counts[s.group] = (counts[s.group] ?? 0) + 1;
}
if (_orderMode) {
return ReorderableListView.builder(
buildDefaultDragHandles: false,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
itemCount: _orderDraft.length,
onReorderItem: _reorder,
itemBuilder: (context, index) {
final group = _orderDraft[index];
return Row(
key: ValueKey('order-${group.id}'),
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _GroupCard(
group: group,
scriptCount: counts[group.id] ?? 0,
onEdit: null,
onDelete: null,
),
),
),
ReorderableDragStartListener(
index: index,
child: const Padding(
padding: EdgeInsets.all(12),
child: Icon(Icons.drag_handle),
),
),
],
);
},
);
}
return ListView(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 24),
children: [
if (!isPlus) ...[
_PlusBanner(message: l.tr('plus.serverManagedTip')),
const SizedBox(height: 10),
],
_HintCard(body: l.tr('scripts.groupsHintBody')),
const SizedBox(height: 12),
for (var i = 0; i < groups.length; i++)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _GroupCard(
group: groups[i],
scriptCount: counts[groups[i].id] ?? 0,
onEdit: groups[i].isBuiltin
? null
: () => _openForm(group: groups[i]),
onDelete:
(groups[i].isBuiltin || groups[i].isDefault || _busy)
? null
: () => _confirmDelete(groups[i]),
),
),
],
);
},
),
);
}
Future<void> _startOrder() async {
final catalog = await ref.read(scriptRepositoryProvider).fetchCatalog();
final byId = {for (final group in catalog.groups) group.id: group};
if (!mounted) return;
setState(() {
_orderRevision = catalog.order.revision;
_orderDraft = catalog.order.sections
.map((section) => byId[section.groupId])
.whereType<ScriptGroupModel>()
.toList();
_orderMode = true;
});
}
void _cancelOrder() => setState(() {
_orderMode = false;
_orderDraft = const [];
});
void _reorder(int oldIndex, int newIndex) {
setState(() {
final draft = [..._orderDraft];
final group = draft.removeAt(oldIndex);
draft.insert(newIndex, group);
_orderDraft = draft;
});
}
Future<void> _saveOrder() async {
try {
await ref.read(scriptRepositoryProvider).updateOrder(_orderRevision, [
OrderChange(
scope: 'groups',
orderedIds: _orderDraft.map((group) => group.id).toList(),
),
]);
await Future.wait([
ref.read(scriptGroupListProvider.notifier).refresh(),
ref.read(scriptListProvider.notifier).refresh(),
]);
if (mounted) _cancelOrder();
} catch (error) {
if (!mounted) return;
_cancelOrder();
if (error is ApiFailure && error.statusCode == 409) {
await Future.wait([
ref.read(scriptGroupListProvider.notifier).refresh(),
ref.read(scriptListProvider.notifier).refresh(),
]);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).tr('order.conflict')),
),
);
} else {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.toString())));
}
}
}
}
class _HintCard extends StatelessWidget {
const _HintCard({required this.body});
final String body;
@override
Widget build(BuildContext context) {
final c = context.colors;
return Container(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
decoration: BoxDecoration(
color: c.banner,
borderRadius: BorderRadius.circular(10),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: Icon(Icons.info_outline, size: 16, color: c.primary),
),
const SizedBox(width: 8),
Expanded(
child: Text(
body,
style: TextStyle(color: c.muted, fontSize: 12, height: 1.4),
),
),
],
),
);
}
}
class _PlusBanner extends StatelessWidget {
const _PlusBanner({required this.message});
final String message;
@override
Widget build(BuildContext context) {
final c = context.colors;
return Container(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
decoration: BoxDecoration(
color: c.banner,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: c.strongBorder),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: Icon(
Icons.workspace_premium_outlined,
size: 16,
color: c.primary,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
message,
style: TextStyle(
color: c.primary,
fontSize: 12,
fontWeight: FontWeight.w700,
height: 1.4,
),
),
),
],
),
);
}
}
class _GroupCard extends StatelessWidget {
const _GroupCard({
required this.group,
required this.scriptCount,
required this.onEdit,
required this.onDelete,
});
final ScriptGroupModel group;
final int scriptCount;
final VoidCallback? onEdit;
final VoidCallback? onDelete;
@override
Widget build(BuildContext context) {
final c = context.colors;
final l = AppLocalizations.of(context);
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: c.card,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: c.border),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
group.displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: c.text,
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
),
if (group.isBuiltin) ...[
const SizedBox(width: 6),
_RoleChip(label: l.tr('scripts.builtinChip')),
] else if (group.isDefault) ...[
const SizedBox(width: 6),
_RoleChip(label: l.tr('scripts.defaultChip')),
],
],
),
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.description_outlined,
size: 13,
color: c.softMuted,
),
const SizedBox(width: 4),
Text(
l.trf('scripts.scriptCount', [scriptCount]),
style: TextStyle(color: c.softMuted, fontSize: 12),
),
],
),
],
),
),
if (group.isBuiltin)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'--',
style: TextStyle(
color: c.softMuted,
fontWeight: FontWeight.w600,
),
),
)
else
Row(
children: [
_ActionIcon(
icon: Icons.edit_outlined,
color: c.muted,
onTap: onEdit,
),
_ActionIcon(
icon: Icons.delete_outline,
color: onDelete == null ? c.softMuted : c.danger,
onTap: onDelete,
),
],
),
],
),
);
}
}
class _RoleChip extends StatelessWidget {
const _RoleChip({required this.label});
final String label;
@override
Widget build(BuildContext context) {
final c = context.colors;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: c.chip,
borderRadius: BorderRadius.circular(6),
),
child: Text(
label,
style: TextStyle(
color: c.muted,
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
);
}
}
class _ActionIcon extends StatelessWidget {
const _ActionIcon({
required this.icon,
required this.color,
required this.onTap,
});
final IconData icon;
final Color color;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return InkResponse(
onTap: onTap,
radius: 18,
child: SizedBox(
width: 36,
height: 36,
child: Icon(icon, size: 18, color: color),
),
);
}
}
class _AddChip extends StatelessWidget {
const _AddChip({required this.label, required this.onTap});
final String label;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final c = context.colors;
return Material(
color: c.banner,
borderRadius: BorderRadius.circular(18),
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add, size: 16, color: c.primary),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
color: c.primary,
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
],
),
),
),
);
}
}
class _GroupForm extends ConsumerStatefulWidget {
const _GroupForm({required this.form});
final ScriptGroupFormData form;
@override
ConsumerState<_GroupForm> createState() => _GroupFormState();
}
class _GroupFormState extends ConsumerState<_GroupForm> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nameCtrl;
bool _saving = false;
@override
void initState() {
super.initState();
_nameCtrl = TextEditingController(text: widget.form.name);
}
@override
void dispose() {
_nameCtrl.dispose();
super.dispose();
}
Future<void> _save() async {
if (_saving) return;
if (!(_formKey.currentState?.validate() ?? false)) return;
final l = AppLocalizations.of(context);
setState(() => _saving = true);
widget.form.name = _nameCtrl.text.trim();
final repo = ref.read(scriptRepositoryProvider);
try {
final message = widget.form.isEdit
? await repo.updateGroup(widget.form)
: await repo.createGroup(widget.form);
await ref.read(scriptGroupListProvider.notifier).refresh();
if (!mounted) return;
Navigator.of(context).pop(true);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
} catch (error) {
if (!mounted) return;
setState(() => _saving = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l.trf('scripts.saveFailed', [error.toString()])),
),
);
}
}
@override
Widget build(BuildContext context) {
final c = context.colors;
final l = AppLocalizations.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: c.border,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 14),
Text(
widget.form.isEdit
? l.tr('scripts.editGroup')
: l.tr('scripts.addGroup'),
style: TextStyle(
color: c.text,
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 14),
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Text(
'分组名称',
style: TextStyle(
color: c.softMuted,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
TextFormField(
controller: _nameCtrl,
cursorColor: c.primary,
style: TextStyle(color: c.text, fontSize: 14),
validator: (v) => (v ?? '').trim().isEmpty
? l.tr('scripts.validation.groupName')
: null,
decoration: _decoration(),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _saving
? null
: () => Navigator.of(context).pop(false),
style: OutlinedButton.styleFrom(
side: BorderSide(color: c.strongBorder),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
padding: const EdgeInsets.symmetric(vertical: 14),
),
child: Text(
l.tr('common.cancel'),
style: TextStyle(
color: c.muted,
fontWeight: FontWeight.w700,
),
),
),
),
const SizedBox(width: 10),
Expanded(
child: FilledButton(
onPressed: _saving ? null : _save,
style: FilledButton.styleFrom(
backgroundColor: c.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
padding: const EdgeInsets.symmetric(vertical: 14),
),
child: _saving
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: c.fontOnPrimary,
),
)
: Text(
l.tr('common.save'),
style: TextStyle(
color: c.fontOnPrimary,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
],
),
),
);
}
InputDecoration _decoration() {
final c = context.colors;
return InputDecoration(
isDense: true,
filled: true,
fillColor: c.chip,
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: c.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: c.primary, width: 1.2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: c.danger),
),
);
}
}