mirror of
https://github.com/chaos-zhu/easynode.git
synced 2026-09-07 00:12:20 +08:00
656 lines
19 KiB
Dart
656 lines
19 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../core/ui/app_color_theme.dart';
|
|
import '../../l10n/app_localizations.dart';
|
|
import '../../state/api_providers.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';
|
|
|
|
/// Script editor — full-screen page pushed from the scripts list. Mirrors
|
|
/// design node `fbTKb` and feature parity with web `script-edit.vue`:
|
|
/// group picker, name/description, command body, and Base64 toggle.
|
|
class ScriptFormPage extends ConsumerStatefulWidget {
|
|
const ScriptFormPage({
|
|
super.key,
|
|
this.script,
|
|
this.defaultGroup,
|
|
this.defaultCommand,
|
|
});
|
|
|
|
/// Provided when editing; null when adding.
|
|
final ScriptModel? script;
|
|
|
|
/// Group preselected when creating; ignored when [script] is non-null.
|
|
final String? defaultGroup;
|
|
|
|
/// Command preselected when creating (e.g. captured from terminal).
|
|
final String? defaultCommand;
|
|
|
|
@override
|
|
ConsumerState<ScriptFormPage> createState() => _ScriptFormPageState();
|
|
}
|
|
|
|
class _ScriptFormPageState extends ConsumerState<ScriptFormPage> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
final _nameCtrl = TextEditingController();
|
|
final _descriptionCtrl = TextEditingController();
|
|
final _commandCtrl = TextEditingController();
|
|
|
|
late ScriptFormData _form;
|
|
bool _saving = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
if (widget.script != null) {
|
|
final s = widget.script!;
|
|
_form = ScriptFormData(
|
|
id: s.id,
|
|
name: s.name,
|
|
description: s.description,
|
|
command: s.command,
|
|
group: s.group,
|
|
useBase64: s.useBase64,
|
|
);
|
|
} else {
|
|
// Never pick `builtin` as the default since the picker forbids it.
|
|
final group =
|
|
(widget.defaultGroup == null ||
|
|
widget.defaultGroup!.isEmpty ||
|
|
widget.defaultGroup == 'builtin')
|
|
? 'default'
|
|
: widget.defaultGroup!;
|
|
_form = ScriptFormData(
|
|
name: '',
|
|
description: '',
|
|
command: widget.defaultCommand ?? '',
|
|
group: group,
|
|
useBase64: false,
|
|
);
|
|
}
|
|
_syncControllers();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_nameCtrl.dispose();
|
|
_descriptionCtrl.dispose();
|
|
_commandCtrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _syncControllers() {
|
|
_nameCtrl.text = _form.name;
|
|
_descriptionCtrl.text = _form.description;
|
|
_commandCtrl.text = _form.command;
|
|
}
|
|
|
|
Future<void> _save() async {
|
|
if (_saving) return;
|
|
final l = AppLocalizations.of(context);
|
|
if (!(_formKey.currentState?.validate() ?? false)) return;
|
|
setState(() => _saving = true);
|
|
final repo = ref.read(scriptRepositoryProvider);
|
|
try {
|
|
final form = ScriptFormData(
|
|
id: _form.id,
|
|
name: _nameCtrl.text.trim(),
|
|
description: _descriptionCtrl.text.trim(),
|
|
command: _commandCtrl.text,
|
|
group: _form.group,
|
|
useBase64: _form.useBase64,
|
|
);
|
|
final message = form.isEdit
|
|
? await repo.updateScript(form)
|
|
: await repo.createScript(form);
|
|
await ref.read(scriptListProvider.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);
|
|
final groups =
|
|
ref.watch(scriptGroupListProvider).valueOrNull ??
|
|
const <ScriptGroupModel>[];
|
|
final editing = _form.isEdit;
|
|
return Scaffold(
|
|
backgroundColor: c.canvas,
|
|
appBar: AppBar(
|
|
backgroundColor: c.canvas,
|
|
surfaceTintColor: Colors.transparent,
|
|
elevation: 0,
|
|
title: Text(
|
|
editing ? l.tr('scripts.editScript') : l.tr('scripts.addScript'),
|
|
style: TextStyle(color: c.text, fontWeight: FontWeight.w700),
|
|
),
|
|
iconTheme: IconThemeData(color: c.text),
|
|
),
|
|
bottomNavigationBar: _BottomSaveBar(
|
|
saving: _saving,
|
|
label: editing ? l.tr('common.save') : l.tr('scripts.addScript'),
|
|
onPressed: _saving ? null : _save,
|
|
),
|
|
body: Form(
|
|
key: _formKey,
|
|
child: ListView(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
|
children: [
|
|
_SectionCard(
|
|
title: l.tr('scripts.section.basic'),
|
|
children: [
|
|
_LabeledField(
|
|
label: l.tr('scripts.field.group'),
|
|
child: _GroupSelector(
|
|
groups: groups,
|
|
selected: _form.group,
|
|
onChanged: (id) => setState(() => _form.group = id),
|
|
),
|
|
),
|
|
_LabeledField(
|
|
label: l.tr('scripts.field.name'),
|
|
child: _SoftTextField(
|
|
controller: _nameCtrl,
|
|
validator: (v) {
|
|
if ((v ?? '').trim().isEmpty) {
|
|
return l.tr('scripts.validation.name');
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
),
|
|
_LabeledField(
|
|
label: l.tr('scripts.field.description'),
|
|
child: _SoftTextField(controller: _descriptionCtrl),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 14),
|
|
_SectionCard(
|
|
title: l.tr('scripts.section.command'),
|
|
trailing: const _ShellTag(),
|
|
children: [
|
|
_CommandEditor(
|
|
controller: _commandCtrl,
|
|
validator: (v) {
|
|
if ((v ?? '').isEmpty) {
|
|
return l.tr('scripts.validation.command');
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
_LabeledField(
|
|
label: l.tr('scripts.field.useBase64'),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: _EncodingOption(
|
|
title: l.tr('scripts.useBase64.direct'),
|
|
hint: l.tr('scripts.useBase64.directHint'),
|
|
selected: !_form.useBase64,
|
|
onTap: () => setState(() => _form.useBase64 = false),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: _EncodingOption(
|
|
title: l.tr('scripts.useBase64.base64'),
|
|
hint: l.tr('scripts.useBase64.base64Hint'),
|
|
selected: _form.useBase64,
|
|
onTap: () => setState(() => _form.useBase64 = true),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SectionCard extends StatelessWidget {
|
|
const _SectionCard({
|
|
required this.title,
|
|
required this.children,
|
|
this.trailing,
|
|
});
|
|
|
|
final String title;
|
|
final List<Widget> children;
|
|
final Widget? trailing;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.colors;
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: c.card,
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(color: c.border),
|
|
),
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 14),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
width: 3,
|
|
height: 14,
|
|
decoration: BoxDecoration(
|
|
color: c.primary,
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
title,
|
|
style: TextStyle(
|
|
color: c.muted,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: 0.4,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
?trailing,
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
...children,
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _LabeledField extends StatelessWidget {
|
|
const _LabeledField({required this.label, required this.child});
|
|
|
|
final String label;
|
|
final Widget child;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.colors;
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 6),
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: c.softMuted,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
child,
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SoftTextField extends StatelessWidget {
|
|
const _SoftTextField({required this.controller, this.validator});
|
|
|
|
final TextEditingController controller;
|
|
final FormFieldValidator<String>? validator;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.colors;
|
|
return TextFormField(
|
|
controller: controller,
|
|
validator: validator,
|
|
cursorColor: c.primary,
|
|
style: TextStyle(color: c.text, fontSize: 14),
|
|
decoration: InputDecoration(
|
|
isDense: true,
|
|
filled: true,
|
|
fillColor: c.chip,
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 14,
|
|
vertical: 14,
|
|
),
|
|
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),
|
|
),
|
|
focusedErrorBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
borderSide: BorderSide(color: c.danger, width: 1.4),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _GroupSelector extends StatelessWidget {
|
|
const _GroupSelector({
|
|
required this.groups,
|
|
required this.selected,
|
|
required this.onChanged,
|
|
});
|
|
|
|
final List<ScriptGroupModel> groups;
|
|
final String selected;
|
|
final ValueChanged<String> onChanged;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.colors;
|
|
final l = AppLocalizations.of(context);
|
|
final selectable = groups
|
|
.where((g) => !g.isBuiltin)
|
|
.toList(growable: false);
|
|
final current = groups.firstWhere(
|
|
(g) => g.id == selected,
|
|
orElse: () => ScriptGroupModel(id: selected, name: selected),
|
|
);
|
|
return InkWell(
|
|
borderRadius: BorderRadius.circular(10),
|
|
onTap: selectable.isEmpty
|
|
? null
|
|
: () async {
|
|
final picked = await showModalBottomSheet<String>(
|
|
context: context,
|
|
backgroundColor: c.card,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(18)),
|
|
),
|
|
builder: (sheetContext) => SafeArea(
|
|
top: false,
|
|
child: ListView.separated(
|
|
shrinkWrap: true,
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
itemBuilder: (_, i) {
|
|
final g = selectable[i];
|
|
final picked = g.id == current.id;
|
|
return ListTile(
|
|
title: Text(
|
|
g.displayName,
|
|
style: TextStyle(
|
|
color: c.text,
|
|
fontWeight: picked
|
|
? FontWeight.w700
|
|
: FontWeight.w500,
|
|
),
|
|
),
|
|
trailing: picked
|
|
? Icon(Icons.check, color: c.primary)
|
|
: null,
|
|
onTap: () => Navigator.of(sheetContext).pop(g.id),
|
|
);
|
|
},
|
|
separatorBuilder: (_, _) =>
|
|
Divider(height: 1, color: c.border),
|
|
itemCount: selectable.length,
|
|
),
|
|
),
|
|
);
|
|
if (picked != null) onChanged(picked);
|
|
},
|
|
child: Container(
|
|
height: 48,
|
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
|
decoration: BoxDecoration(
|
|
color: c.chip,
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(color: c.border),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
current.displayName.isEmpty
|
|
? l.tr('scripts.validation.group')
|
|
: current.displayName,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: current.displayName.isEmpty ? c.softMuted : c.text,
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
),
|
|
Icon(Icons.keyboard_arrow_down, size: 18, color: c.softMuted),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ShellTag extends StatelessWidget {
|
|
const _ShellTag();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.colors;
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.terminal, size: 13, color: c.softMuted),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'shell',
|
|
style: TextStyle(
|
|
color: c.softMuted,
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CommandEditor extends StatelessWidget {
|
|
const _CommandEditor({required this.controller, required this.validator});
|
|
|
|
final TextEditingController controller;
|
|
final FormFieldValidator<String> validator;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.colors;
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 14),
|
|
child: TextFormField(
|
|
controller: controller,
|
|
validator: validator,
|
|
maxLines: 8,
|
|
minLines: 6,
|
|
cursorColor: c.primary,
|
|
style: TextStyle(
|
|
color: c.text,
|
|
fontSize: 13,
|
|
fontFamily: 'monospace',
|
|
height: 1.5,
|
|
),
|
|
decoration: 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),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _EncodingOption extends StatelessWidget {
|
|
const _EncodingOption({
|
|
required this.title,
|
|
required this.hint,
|
|
required this.selected,
|
|
required this.onTap,
|
|
});
|
|
|
|
final String title;
|
|
final String hint;
|
|
final bool selected;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.colors;
|
|
return Material(
|
|
color: selected ? c.banner : c.chip,
|
|
borderRadius: BorderRadius.circular(10),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(10),
|
|
onTap: onTap,
|
|
child: Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(
|
|
color: selected ? c.primary : c.border,
|
|
width: selected ? 1.4 : 1,
|
|
),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
title,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: selected ? c.primary : c.text,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
Icon(
|
|
Icons.info_outline,
|
|
size: 14,
|
|
color: selected ? c.primary : c.softMuted,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
hint,
|
|
style: TextStyle(
|
|
color: selected ? c.muted : c.softMuted,
|
|
fontSize: 11,
|
|
height: 1.4,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _BottomSaveBar extends StatelessWidget {
|
|
const _BottomSaveBar({
|
|
required this.saving,
|
|
required this.label,
|
|
required this.onPressed,
|
|
});
|
|
|
|
final bool saving;
|
|
final String label;
|
|
final VoidCallback? onPressed;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SafeArea(
|
|
top: false,
|
|
child: Container(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 20),
|
|
decoration: BoxDecoration(
|
|
color: context.colors.card,
|
|
border: Border(top: BorderSide(color: context.colors.border)),
|
|
),
|
|
child: SizedBox(
|
|
height: 52,
|
|
child: FilledButton(
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: context.colors.primary,
|
|
foregroundColor: context.colors.fontOnPrimary,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
),
|
|
onPressed: onPressed,
|
|
child: saving
|
|
? const SizedBox(
|
|
width: 18,
|
|
height: 18,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: Text(
|
|
label,
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|