Files
easynode/native/lib/features/scripts/script_form_page.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,
),
),
),
),
),
);
}
}