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 createState() => _ScriptGroupsPageState(); } class _ScriptGroupsPageState extends ConsumerState { bool _busy = false; bool _orderMode = false; int _orderRevision = 0; List _orderDraft = const []; bool _ensurePlusOrWarn() { if (ref.read(isPlusActiveProvider)) return true; final l = AppLocalizations.of(context); showTopNotice(context, l.tr('plus.serverManagedTip')); return false; } Future _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( 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 _confirmDelete(ScriptGroupModel group) async { if (!_ensurePlusOrWarn()) return; final l = AppLocalizations.of(context); final ok = await showDialog( 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 []; 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 = {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 _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() .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 _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(); 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 _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), ), ); } }