mirror of
https://github.com/chaos-zhu/easynode.git
synced 2026-09-07 00:12:20 +08:00
1026 lines
32 KiB
Dart
1026 lines
32 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../core/ui/app_overflow_menu.dart';
|
|
import '../../core/ui/app_reorder_proxy.dart';
|
|
import '../../core/ui/refresh_feedback.dart';
|
|
import '../../core/api/api_result.dart';
|
|
import '../../features/order/order_layout.dart';
|
|
import '../../features/scripts/script_form_page.dart';
|
|
import '../../features/scripts/script_group_model.dart';
|
|
import '../../features/scripts/script_groups_page.dart';
|
|
import '../../features/scripts/script_model.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 'tab_header.dart';
|
|
|
|
import '../../core/ui/app_color_theme.dart';
|
|
|
|
/// Script-specific accent — distinct from the global `AppColorTheme.accent`.
|
|
const _kScriptAccent = Color(0xFF6F4B2A);
|
|
const _kScriptAccentSoft = Color(0xFFEEDCB5);
|
|
const _kScriptSuccessSoft = Color(0x225A8E3A);
|
|
|
|
/// Third bottom-nav tab — scripts library. Mirrors design node `Zhupt` and
|
|
/// feature parity with web `web/src/views/scripts/index.vue`: search + group
|
|
/// chips + card list with copy/edit/delete actions. State is sourced from
|
|
/// the shared [scriptListProvider] / [scriptGroupListProvider] so other
|
|
/// features (e.g. terminal quick-actions) can read the same snapshot.
|
|
class ScriptsTab extends ConsumerStatefulWidget {
|
|
const ScriptsTab({super.key});
|
|
|
|
@override
|
|
ConsumerState<ScriptsTab> createState() => _ScriptsTabState();
|
|
}
|
|
|
|
class _ScriptsTabState extends ConsumerState<ScriptsTab> {
|
|
final TextEditingController _searchCtrl = TextEditingController();
|
|
String? _selectedGroupId;
|
|
String _query = '';
|
|
bool _searchVisible = false;
|
|
bool _orderMode = false;
|
|
bool _orderSaving = false;
|
|
int _orderRevision = 0;
|
|
List<String> _orderDraft = const [];
|
|
|
|
@override
|
|
void dispose() {
|
|
_searchCtrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _refresh() async {
|
|
if (_orderMode) return;
|
|
await runRefreshWithFeedback(
|
|
context,
|
|
() => Future.wait([
|
|
ref.read(scriptListProvider.notifier).refresh(throwOnError: true),
|
|
ref.read(scriptGroupListProvider.notifier).refresh(throwOnError: true),
|
|
]),
|
|
);
|
|
}
|
|
|
|
Future<void> _openForm({ScriptModel? script}) async {
|
|
final l = AppLocalizations.of(context);
|
|
if (script != null && script.isBuiltin) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(l.tr('scripts.builtinReadonly'))));
|
|
return;
|
|
}
|
|
await Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (_) =>
|
|
ScriptFormPage(script: script, defaultGroup: _selectedGroupId),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _openGroups() async {
|
|
await Navigator.of(
|
|
context,
|
|
).push(MaterialPageRoute(builder: (_) => const ScriptGroupsPage()));
|
|
}
|
|
|
|
Future<void> _copyCommand(ScriptModel script) async {
|
|
final l = AppLocalizations.of(context);
|
|
await Clipboard.setData(ClipboardData(text: script.command));
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(l.tr('scripts.commandCopied'))));
|
|
}
|
|
|
|
Future<void> _confirmDelete(ScriptModel script) async {
|
|
final l = AppLocalizations.of(context);
|
|
final ok = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(l.tr('scripts.deleteScriptTitle')),
|
|
content: Text(l.trf('scripts.deleteScriptBody', [script.name])),
|
|
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;
|
|
try {
|
|
final message = await ref
|
|
.read(scriptRepositoryProvider)
|
|
.deleteScript(script.id);
|
|
await 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()])),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
void _toggleSearch() {
|
|
if (_orderMode) return;
|
|
setState(() {
|
|
_searchVisible = !_searchVisible;
|
|
if (!_searchVisible) {
|
|
_searchCtrl.clear();
|
|
_query = '';
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final scriptsAsync = ref.watch(scriptListProvider);
|
|
final groupsAsync = ref.watch(scriptGroupListProvider);
|
|
final c = context.colors;
|
|
return Scaffold(
|
|
backgroundColor: c.canvas,
|
|
body: RefreshIndicator(
|
|
color: c.primary,
|
|
backgroundColor: c.card,
|
|
displacement: 30,
|
|
edgeOffset: 6,
|
|
strokeWidth: 2,
|
|
onRefresh: _refresh,
|
|
child: _buildBody(scriptsAsync, groupsAsync),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildBody(
|
|
AsyncValue<List<ScriptModel>> scriptsAsync,
|
|
AsyncValue<List<ScriptGroupModel>> groupsAsync,
|
|
) {
|
|
final l = AppLocalizations.of(context);
|
|
return scriptsAsync.when(
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (error, _) {
|
|
return ListView(
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|
padding: const EdgeInsets.all(24),
|
|
children: [
|
|
const SizedBox(height: 56),
|
|
Center(
|
|
child: Text(
|
|
l.trf('scripts.loadFailed', [error.toString()]),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Center(
|
|
child: TextButton(
|
|
onPressed: _refresh,
|
|
child: Text(l.tr('common.retry')),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
data: (scripts) {
|
|
final groups = groupsAsync.valueOrNull ?? const <ScriptGroupModel>[];
|
|
final searched = _orderMode ? scripts : _searched(scripts);
|
|
final effectiveGroupId = _effectiveGroupId(groups);
|
|
final filtered = _orderMode
|
|
? _orderedDraftScripts(scripts)
|
|
: _filterByGroup(searched, effectiveGroupId);
|
|
return Column(
|
|
children: [
|
|
TabHeader(
|
|
title: l.tr('tabs.scripts'),
|
|
actions: [
|
|
if (_orderMode) ...[
|
|
TextButton(
|
|
onPressed: _orderSaving ? null : _cancelOrder,
|
|
child: Text(l.tr('common.cancel')),
|
|
),
|
|
FilledButton(
|
|
onPressed: _orderSaving ? null : _saveOrder,
|
|
child: Text(l.tr('common.save')),
|
|
),
|
|
] else ...[
|
|
_HeaderIconButton(
|
|
tooltip: l.tr('scripts.addScript'),
|
|
icon: Icons.add,
|
|
onPressed: () => _openForm(),
|
|
),
|
|
const SizedBox(width: 4),
|
|
AppOverflowMenu<String>(
|
|
key: const ValueKey('script-more-menu'),
|
|
tooltip: l.tr('common.moreActions'),
|
|
items: [
|
|
AppOverflowMenuItem(
|
|
value: 'search',
|
|
icon: _searchVisible ? Icons.close : Icons.search,
|
|
label: _searchVisible
|
|
? l.tr('common.closeSearch')
|
|
: l.tr('common.search'),
|
|
),
|
|
AppOverflowMenuItem(
|
|
value: 'groups',
|
|
icon: Icons.account_tree_outlined,
|
|
label: l.tr('scripts.groupsManage'),
|
|
),
|
|
AppOverflowMenuItem(
|
|
value: 'order',
|
|
icon: Icons.swap_vert,
|
|
label: l.tr('common.adjustOrder'),
|
|
enabled:
|
|
effectiveGroupId != null &&
|
|
effectiveGroupId != 'builtin',
|
|
),
|
|
],
|
|
onSelected: (action) {
|
|
if (action == 'search') {
|
|
_toggleSearch();
|
|
} else if (action == 'groups') {
|
|
_openGroups();
|
|
} else if (action == 'order') {
|
|
_startOrder();
|
|
}
|
|
},
|
|
),
|
|
],
|
|
],
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (!_orderMode)
|
|
AnimatedSize(
|
|
duration: const Duration(milliseconds: 220),
|
|
curve: Curves.easeInOut,
|
|
alignment: Alignment.topCenter,
|
|
child: AnimatedSwitcher(
|
|
duration: const Duration(milliseconds: 220),
|
|
transitionBuilder: (child, anim) =>
|
|
FadeTransition(opacity: anim, child: child),
|
|
child: _searchVisible
|
|
? Padding(
|
|
key: const ValueKey('search'),
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
child: SizedBox(
|
|
height: 44,
|
|
child: TextField(
|
|
controller: _searchCtrl,
|
|
autofocus: true,
|
|
cursorColor: context.colors.primary,
|
|
style: TextStyle(
|
|
color: context.colors.text,
|
|
fontSize: 14,
|
|
),
|
|
decoration:
|
|
_searchFieldDecoration(
|
|
context,
|
|
l,
|
|
).copyWith(
|
|
hintText: l.tr('scripts.searchHint'),
|
|
),
|
|
onChanged: (v) => setState(
|
|
() => _query = v.trim().toLowerCase(),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
: const SizedBox(
|
|
key: ValueKey('search-empty'),
|
|
width: double.infinity,
|
|
),
|
|
),
|
|
),
|
|
_GroupChips(
|
|
groups: groups,
|
|
scripts: searched,
|
|
selectedGroupId: effectiveGroupId,
|
|
onSelected: (id) {
|
|
if (!_orderMode) setState(() => _selectedGroupId = id);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: _orderMode
|
|
? ReorderableListView.builder(
|
|
buildDefaultDragHandles: false,
|
|
proxyDecorator: buildAppReorderProxy,
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 110),
|
|
itemCount: filtered.length,
|
|
onReorderItem: _reorder,
|
|
itemBuilder: (context, index) {
|
|
final script = filtered[index];
|
|
return _ScriptOrderCard(
|
|
key: ValueKey('order-${script.id}'),
|
|
script: script,
|
|
orderIndex: index,
|
|
);
|
|
},
|
|
)
|
|
: ListView(
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 110),
|
|
children: [
|
|
if (scripts.isEmpty)
|
|
_MessageState(message: l.tr('scripts.emptyHint'))
|
|
else if (filtered.isEmpty)
|
|
_MessageState(message: l.tr('scripts.emptyFiltered'))
|
|
else
|
|
for (var i = 0; i < filtered.length; i++)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: _ScriptCard(
|
|
script: filtered[i],
|
|
groupName: _groupName(filtered[i], groups),
|
|
onCopy: () => _copyCommand(filtered[i]),
|
|
onEdit: filtered[i].isBuiltin
|
|
? null
|
|
: () => _openForm(script: filtered[i]),
|
|
onDelete: filtered[i].isBuiltin
|
|
? null
|
|
: () => _confirmDelete(filtered[i]),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
List<ScriptModel> _orderedDraftScripts(List<ScriptModel> scripts) {
|
|
final byId = {for (final script in scripts) script.id: script};
|
|
return _orderDraft.map((id) => byId[id]).whereType<ScriptModel>().toList();
|
|
}
|
|
|
|
Future<void> _startOrder() async {
|
|
final groupId = _selectedGroupId;
|
|
if (groupId == null || groupId == 'builtin') return;
|
|
final catalog = await ref.read(scriptRepositoryProvider).fetchCatalog();
|
|
final section = catalog.order.sections.where(
|
|
(item) => item.groupId == groupId,
|
|
);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_searchVisible = false;
|
|
_searchCtrl.clear();
|
|
_query = '';
|
|
_orderRevision = catalog.order.revision;
|
|
_orderDraft = section.isEmpty ? const [] : [...section.first.itemIds];
|
|
_orderMode = true;
|
|
});
|
|
}
|
|
|
|
void _cancelOrder() => setState(() {
|
|
_orderMode = false;
|
|
_orderDraft = const [];
|
|
});
|
|
|
|
void _reorder(int oldIndex, int newIndex) {
|
|
setState(() {
|
|
final draft = [..._orderDraft];
|
|
final id = draft.removeAt(oldIndex);
|
|
draft.insert(newIndex, id);
|
|
_orderDraft = draft;
|
|
});
|
|
}
|
|
|
|
Future<void> _saveOrder() async {
|
|
final groupId = _selectedGroupId;
|
|
if (groupId == null || groupId == 'builtin') return;
|
|
setState(() => _orderSaving = true);
|
|
try {
|
|
await ref.read(scriptRepositoryProvider).updateOrder(_orderRevision, [
|
|
OrderChange(
|
|
scope: 'groupItems',
|
|
groupId: groupId,
|
|
orderedIds: _orderDraft,
|
|
),
|
|
]);
|
|
await Future.wait([
|
|
ref.read(scriptListProvider.notifier).refresh(),
|
|
ref.read(scriptGroupListProvider.notifier).refresh(),
|
|
]);
|
|
if (mounted) _cancelOrder();
|
|
} catch (error) {
|
|
if (!mounted) return;
|
|
_cancelOrder();
|
|
if (error is ApiFailure && error.statusCode == 409) {
|
|
await Future.wait([
|
|
ref.read(scriptListProvider.notifier).refresh(),
|
|
ref.read(scriptGroupListProvider.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())));
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _orderSaving = false);
|
|
}
|
|
}
|
|
|
|
List<ScriptModel> _searched(List<ScriptModel> scripts) {
|
|
if (_query.isEmpty) return scripts;
|
|
return scripts
|
|
.where((s) {
|
|
final haystack = '${s.name} ${s.description} ${s.command}'
|
|
.toLowerCase();
|
|
return haystack.contains(_query);
|
|
})
|
|
.toList(growable: false);
|
|
}
|
|
|
|
String? _effectiveGroupId(List<ScriptGroupModel> groups) {
|
|
if (_selectedGroupId == null) return null;
|
|
if (groups.any((g) => g.id == _selectedGroupId)) return _selectedGroupId;
|
|
return null;
|
|
}
|
|
|
|
List<ScriptModel> _filterByGroup(List<ScriptModel> scripts, String? groupId) {
|
|
if (groupId == null) return scripts;
|
|
return scripts.where((s) => s.group == groupId).toList(growable: false);
|
|
}
|
|
|
|
String _groupName(ScriptModel script, List<ScriptGroupModel> groups) {
|
|
for (final g in groups) {
|
|
if (g.id == script.group) return g.displayName;
|
|
}
|
|
return script.group;
|
|
}
|
|
}
|
|
|
|
class _GroupChips extends StatelessWidget {
|
|
const _GroupChips({
|
|
required this.groups,
|
|
required this.scripts,
|
|
required this.selectedGroupId,
|
|
required this.onSelected,
|
|
});
|
|
|
|
final List<ScriptGroupModel> groups;
|
|
final List<ScriptModel> scripts;
|
|
final String? selectedGroupId;
|
|
final ValueChanged<String?> onSelected;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l = AppLocalizations.of(context);
|
|
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;
|
|
}
|
|
final visible = groups
|
|
.where((g) => (counts[g.id] ?? 0) > 0)
|
|
.toList(growable: false);
|
|
if (visible.length < 2) return const SizedBox.shrink();
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 10),
|
|
child: SizedBox(
|
|
height: 36,
|
|
child: ListView.separated(
|
|
scrollDirection: Axis.horizontal,
|
|
physics: const BouncingScrollPhysics(),
|
|
itemCount: visible.length + 1,
|
|
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
|
itemBuilder: (context, i) {
|
|
if (i == 0) {
|
|
return _Chip(
|
|
label: l.tr('common.all'),
|
|
count: scripts.length,
|
|
selected: selectedGroupId == null,
|
|
onTap: () => onSelected(null),
|
|
);
|
|
}
|
|
final g = visible[i - 1];
|
|
return _Chip(
|
|
label: g.displayName,
|
|
count: counts[g.id] ?? 0,
|
|
selected: selectedGroupId == g.id,
|
|
onTap: () => onSelected(g.id),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Chip extends StatelessWidget {
|
|
const _Chip({
|
|
required this.label,
|
|
required this.count,
|
|
required this.selected,
|
|
required this.onTap,
|
|
});
|
|
|
|
final String label;
|
|
final int count;
|
|
final bool selected;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.colors;
|
|
final bg = selected ? c.primary : Colors.transparent;
|
|
final fg = selected ? c.fontOnPrimary : c.text;
|
|
return Material(
|
|
color: bg,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(18),
|
|
side: selected ? BorderSide.none : BorderSide(color: c.strongBorder),
|
|
),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(18),
|
|
onTap: onTap,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: fg,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
|
decoration: BoxDecoration(
|
|
color: selected
|
|
? c.fontOnPrimary.withValues(alpha: 0.20)
|
|
: c.chip,
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Text(
|
|
count.toString(),
|
|
style: TextStyle(
|
|
color: selected ? c.fontOnPrimary : c.softMuted,
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ScriptCard extends StatelessWidget {
|
|
const _ScriptCard({
|
|
required this.script,
|
|
required this.groupName,
|
|
required this.onCopy,
|
|
required this.onEdit,
|
|
required this.onDelete,
|
|
});
|
|
|
|
final ScriptModel script;
|
|
final String groupName;
|
|
final VoidCallback onCopy;
|
|
final VoidCallback? onEdit;
|
|
final VoidCallback? onDelete;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.colors;
|
|
return Container(
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: c.card,
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(color: c.border),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
script.name.isEmpty ? '--' : script.name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: c.text,
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
if (script.useBase64) const _Base64Chip() else const _PlainChip(),
|
|
],
|
|
),
|
|
if (script.description.isNotEmpty) ...[
|
|
const SizedBox(height: 10),
|
|
Text(
|
|
script.description,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(color: c.muted, fontSize: 12, height: 1.4),
|
|
),
|
|
],
|
|
const SizedBox(height: 10),
|
|
_CommandPreview(command: script.command),
|
|
const SizedBox(height: 10),
|
|
Row(
|
|
children: [
|
|
Icon(Icons.folder_outlined, size: 13, color: c.softMuted),
|
|
const SizedBox(width: 5),
|
|
Expanded(
|
|
child: Text(
|
|
groupName,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(color: c.softMuted, fontSize: 12),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
_ActionIcon(
|
|
tooltip: AppLocalizations.of(context).tr('scripts.action.copy'),
|
|
icon: Icons.copy,
|
|
color: c.muted,
|
|
onTap: onCopy,
|
|
),
|
|
if (onEdit != null)
|
|
_ActionIcon(
|
|
tooltip: AppLocalizations.of(
|
|
context,
|
|
).tr('scripts.action.edit'),
|
|
icon: Icons.edit_outlined,
|
|
color: c.muted,
|
|
onTap: onEdit,
|
|
),
|
|
if (onDelete != null)
|
|
_ActionIcon(
|
|
tooltip: AppLocalizations.of(
|
|
context,
|
|
).tr('scripts.action.delete'),
|
|
icon: Icons.delete_outline,
|
|
color: c.danger,
|
|
onTap: onDelete,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ScriptOrderCard extends StatelessWidget {
|
|
const _ScriptOrderCard({
|
|
super.key,
|
|
required this.script,
|
|
required this.orderIndex,
|
|
});
|
|
|
|
final ScriptModel script;
|
|
final int orderIndex;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.colors;
|
|
final firstLine = script.command.split('\n').first.trim();
|
|
return Container(
|
|
key: Key('script-${script.id}'),
|
|
margin: const EdgeInsets.symmetric(vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: c.card,
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(color: c.border),
|
|
),
|
|
child: Stack(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(12, 9, 58, 9),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 38,
|
|
height: 38,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: c.banner,
|
|
borderRadius: BorderRadius.circular(11),
|
|
),
|
|
child: Icon(
|
|
Icons.terminal_rounded,
|
|
color: c.primary,
|
|
size: 20,
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
script.name.isEmpty ? '--' : script.name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: c.text,
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: 3),
|
|
Text(
|
|
firstLine.isEmpty ? '--' : firstLine,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: c.softMuted,
|
|
fontFamily: 'monospace',
|
|
fontSize: 11,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Positioned(
|
|
top: 0,
|
|
right: 8,
|
|
bottom: 0,
|
|
child: Center(
|
|
child: ReorderableDragStartListener(
|
|
index: orderIndex,
|
|
child: Container(
|
|
width: 40,
|
|
height: 42,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: c.canvas,
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Icon(Icons.drag_handle, color: c.muted, size: 22),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CommandPreview extends StatelessWidget {
|
|
const _CommandPreview({required this.command});
|
|
final String command;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.colors;
|
|
final firstLine = command.split('\n').first.trim();
|
|
final hasMore = command.contains('\n');
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
|
decoration: BoxDecoration(
|
|
color: c.chip,
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(color: c.border),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Text(
|
|
'\$',
|
|
style: TextStyle(
|
|
color: c.softMuted,
|
|
fontFamily: 'monospace',
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
hasMore ? '$firstLine …' : firstLine,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: c.text,
|
|
fontFamily: 'monospace',
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Base64Chip extends StatelessWidget {
|
|
const _Base64Chip();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
|
decoration: BoxDecoration(
|
|
color: _kScriptAccentSoft,
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: const Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.code, size: 11, color: _kScriptAccent),
|
|
SizedBox(width: 4),
|
|
Text(
|
|
'Base64',
|
|
style: TextStyle(
|
|
color: _kScriptAccent,
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PlainChip extends StatelessWidget {
|
|
const _PlainChip();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
|
decoration: BoxDecoration(
|
|
color: _kScriptSuccessSoft,
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.send_outlined, size: 11, color: context.colors.success),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'直接发送',
|
|
style: TextStyle(
|
|
color: context.colors.success,
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ActionIcon extends StatelessWidget {
|
|
const _ActionIcon({
|
|
required this.tooltip,
|
|
required this.icon,
|
|
required this.color,
|
|
required this.onTap,
|
|
});
|
|
|
|
final String tooltip;
|
|
final IconData icon;
|
|
final Color color;
|
|
final VoidCallback? onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Tooltip(
|
|
message: tooltip,
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(8),
|
|
onTap: onTap,
|
|
child: SizedBox(
|
|
width: 32,
|
|
height: 32,
|
|
child: Center(child: Icon(icon, size: 18, color: color)),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _HeaderIconButton extends StatelessWidget {
|
|
const _HeaderIconButton({
|
|
required this.tooltip,
|
|
required this.icon,
|
|
required this.onPressed,
|
|
});
|
|
|
|
final String tooltip;
|
|
final IconData icon;
|
|
final VoidCallback? onPressed;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Tooltip(
|
|
message: tooltip,
|
|
child: InkResponse(
|
|
radius: 22,
|
|
onTap: onPressed,
|
|
child: SizedBox(
|
|
width: 36,
|
|
height: 36,
|
|
child: Icon(
|
|
icon,
|
|
color: onPressed == null
|
|
? context.colors.softMuted
|
|
: context.colors.muted,
|
|
size: 22,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MessageState extends StatelessWidget {
|
|
const _MessageState({required this.message});
|
|
final String message;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 56),
|
|
child: Center(
|
|
child: Text(
|
|
message,
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(color: context.colors.muted),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
InputDecoration _searchFieldDecoration(
|
|
BuildContext context,
|
|
AppLocalizations l,
|
|
) {
|
|
final c = context.colors;
|
|
return InputDecoration(
|
|
isDense: true,
|
|
filled: true,
|
|
fillColor: c.card,
|
|
prefixIcon: const Icon(Icons.search, size: 18),
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
|
hintStyle: TextStyle(color: c.softMuted, fontSize: 13),
|
|
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),
|
|
),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
borderSide: BorderSide(color: c.border),
|
|
),
|
|
);
|
|
}
|