xworkmate-app/lib/features/settings/settings_archived_tasks_panel.dart
2026-06-05 16:59:41 +08:00

604 lines
20 KiB
Dart

import 'package:flutter/material.dart';
import '../../i18n/app_language.dart';
import '../../runtime/runtime_models.dart';
import '../../theme/app_palette.dart';
class SettingsArchivedTasksPanel extends StatefulWidget {
const SettingsArchivedTasksPanel({
super.key,
required this.sessions,
required this.onRestore,
required this.onDelete,
required this.onStop,
});
final List<GatewaySessionSummary> sessions;
final Future<void> Function(String sessionKey) onRestore;
final Future<void> Function(String sessionKey) onDelete;
final Future<void> Function(String sessionKey)? onStop;
@override
State<SettingsArchivedTasksPanel> createState() =>
_SettingsArchivedTasksPanelState();
}
class _SettingsArchivedTasksPanelState
extends State<SettingsArchivedTasksPanel> {
final Set<String> _selectedSessionKeys = <String>{};
List<GatewaySessionSummary> get _selectedSessions => widget.sessions
.where((session) => _selectedSessionKeys.contains(session.key))
.toList(growable: false);
bool get _hasSelection => _selectedSessionKeys.isNotEmpty;
bool get _allSelected =>
widget.sessions.isNotEmpty &&
_selectedSessionKeys.length == widget.sessions.length;
@override
void didUpdateWidget(covariant SettingsArchivedTasksPanel oldWidget) {
super.didUpdateWidget(oldWidget);
final availableKeys = widget.sessions.map((session) => session.key).toSet();
_selectedSessionKeys.removeWhere((key) => !availableKeys.contains(key));
}
void _toggleSessionSelection(String sessionKey, bool selected) {
setState(() {
if (selected) {
_selectedSessionKeys.add(sessionKey);
} else {
_selectedSessionKeys.remove(sessionKey);
}
});
}
void _toggleAllSelection() {
setState(() {
if (_allSelected) {
_selectedSessionKeys.clear();
} else {
_selectedSessionKeys
..clear()
..addAll(widget.sessions.map((session) => session.key));
}
});
}
Future<void> _restoreSelectedSessions() async {
final selected = _selectedSessions;
if (selected.isEmpty) {
return;
}
for (final session in selected) {
await widget.onRestore(session.key);
}
if (mounted) {
setState(_selectedSessionKeys.clear);
}
}
Future<void> _deleteSelectedSessions(BuildContext context) async {
final selected = _selectedSessions;
if (selected.isEmpty) {
return;
}
final confirmed = await _confirmBulkDelete(context, selected.length);
if (!confirmed) {
return;
}
for (final session in selected) {
await widget.onDelete(session.key);
}
if (mounted) {
setState(_selectedSessionKeys.clear);
}
}
@override
Widget build(BuildContext context) {
final palette = context.palette;
final theme = Theme.of(context);
return Column(
key: const ValueKey('settings-archived-tasks-panel'),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(Icons.inventory_2_outlined, color: palette.textSecondary),
const SizedBox(width: 10),
Expanded(
child: Text(
appText('归档任务管理', 'Archived task management'),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
Text(
appText(
'${widget.sessions.length}',
'${widget.sessions.length} items',
),
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textMuted,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 12),
if (widget.sessions.isEmpty)
_ArchivedTasksEmptyState()
else
Column(
children: [
_ArchivedTasksBulkToolbar(
allSelected: _allSelected,
selectedCount: _selectedSessionKeys.length,
totalCount: widget.sessions.length,
onToggleAll: _toggleAllSelection,
onRestoreSelected: _hasSelection
? () => _restoreSelectedSessions()
: null,
onDeleteSelected: _hasSelection
? () => _deleteSelectedSessions(context)
: null,
),
const SizedBox(height: 8),
for (final session in widget.sessions) ...[
_ArchivedTaskTile(
session: session,
selected: _selectedSessionKeys.contains(session.key),
onSelectionChanged: (selected) =>
_toggleSessionSelection(session.key, selected),
onRestore: () => widget.onRestore(session.key),
onStop: widget.onStop == null
? null
: () => widget.onStop!(session.key),
onDelete: () async {
final confirmed = await _confirmDelete(context, session);
if (confirmed) {
await widget.onDelete(session.key);
}
},
),
if (session != widget.sessions.last)
Divider(height: 1, color: palette.strokeSoft),
],
],
),
],
);
}
Future<bool> _confirmDelete(
BuildContext context,
GatewaySessionSummary session,
) async {
final palette = context.palette;
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(appText('彻底删除归档记录', 'Delete archived record')),
content: Text(
appText(
'将从 XWorkmate 中删除「${session.label}」的任务记录、消息状态和本地线程工作目录。此操作不可撤销。',
'This removes "${session.label}" from XWorkmate task records, message state, and the local thread workspace. This cannot be undone.',
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(appText('取消', 'Cancel')),
),
FilledButton.icon(
key: const ValueKey('settings-archived-task-confirm-delete'),
onPressed: () => Navigator.of(context).pop(true),
style: FilledButton.styleFrom(
backgroundColor: palette.danger,
foregroundColor: Colors.white,
),
icon: const Icon(Icons.delete_outline_rounded),
label: Text(appText('彻底删除', 'Delete permanently')),
),
],
),
);
if (result != true || !context.mounted) {
return false;
}
return _confirmDeleteWithYes(context, session);
}
Future<bool> _confirmDeleteWithYes(
BuildContext context,
GatewaySessionSummary session,
) async {
final palette = context.palette;
final result = await showDialog<bool>(
context: context,
builder: (context) => _DeleteYesConfirmationDialog(
message: appText(
'此操作会删除「${session.label}」的归档记录和任务目录。请输入 Yes 继续。',
'This deletes "${session.label}" archived records and task directory. Type Yes to continue.',
),
palette: palette,
),
);
return result ?? false;
}
Future<bool> _confirmBulkDelete(
BuildContext context,
int selectedCount,
) async {
final palette = context.palette;
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(appText('批量彻底删除归档记录', 'Delete archived records')),
content: Text(
appText(
'将从 XWorkmate 中删除已选择的 $selectedCount 条归档任务记录、消息状态和本地线程工作目录。此操作不可撤销。',
'This removes $selectedCount selected archived tasks from XWorkmate task records, message state, and local thread workspaces. This cannot be undone.',
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(appText('取消', 'Cancel')),
),
FilledButton.icon(
key: const ValueKey('settings-archived-task-confirm-bulk-delete'),
onPressed: () => Navigator.of(context).pop(true),
style: FilledButton.styleFrom(
backgroundColor: palette.danger,
foregroundColor: Colors.white,
),
icon: const Icon(Icons.delete_sweep_outlined),
label: Text(appText('批量删除', 'Delete selected')),
),
],
),
);
if (result != true || !context.mounted) {
return false;
}
final yesResult = await showDialog<bool>(
context: context,
builder: (context) => _DeleteYesConfirmationDialog(
message: appText(
'此操作会删除已选择的 $selectedCount 条归档记录和任务目录。请输入 Yes 继续。',
'This deletes $selectedCount selected archived records and task directories. Type Yes to continue.',
),
palette: palette,
),
);
return yesResult ?? false;
}
}
class _ArchivedTasksBulkToolbar extends StatelessWidget {
const _ArchivedTasksBulkToolbar({
required this.allSelected,
required this.selectedCount,
required this.totalCount,
required this.onToggleAll,
required this.onRestoreSelected,
required this.onDeleteSelected,
});
final bool allSelected;
final int selectedCount;
final int totalCount;
final VoidCallback onToggleAll;
final Future<void> Function()? onRestoreSelected;
final Future<void> Function()? onDeleteSelected;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: palette.surfaceSecondary,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: palette.strokeSoft),
),
child: Row(
children: [
Checkbox(
key: const ValueKey('settings-archived-tasks-select-all'),
value: allSelected,
onChanged: (_) => onToggleAll(),
),
TextButton(
key: const ValueKey('settings-archived-tasks-select-all-button'),
onPressed: onToggleAll,
child: Text(appText('全选', 'Select all')),
),
const SizedBox(width: 8),
Expanded(
child: Text(
appText(
selectedCount == 0
? '$totalCount 条归档任务'
: '已选择 $selectedCount / $totalCount',
selectedCount == 0
? '$totalCount archived tasks'
: '$selectedCount / $totalCount selected',
),
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textMuted,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 8),
FilledButton.tonalIcon(
key: const ValueKey('settings-archived-tasks-bulk-restore'),
onPressed: onRestoreSelected,
icon: const Icon(Icons.unarchive_outlined),
label: Text(appText('批量解除归档', 'Restore selected')),
),
const SizedBox(width: 8),
IconButton(
key: const ValueKey('settings-archived-tasks-bulk-delete'),
tooltip: appText('批量彻底删除归档记录', 'Delete selected records'),
onPressed: onDeleteSelected == null
? null
: () async => onDeleteSelected!(),
icon: const Icon(Icons.delete_sweep_outlined),
),
],
),
);
}
}
class _DeleteYesConfirmationDialog extends StatefulWidget {
const _DeleteYesConfirmationDialog({
required this.message,
required this.palette,
});
final String message;
final AppPalette palette;
@override
State<_DeleteYesConfirmationDialog> createState() =>
_DeleteYesConfirmationDialogState();
}
class _DeleteYesConfirmationDialogState
extends State<_DeleteYesConfirmationDialog> {
final TextEditingController _confirmationController = TextEditingController();
bool get _confirmed => _confirmationController.text.trim() == 'Yes';
@override
void dispose() {
_confirmationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(appText('确认彻底删除', 'Confirm permanent delete')),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(widget.message),
const SizedBox(height: 14),
TextField(
key: const ValueKey('settings-archived-task-delete-yes-input'),
controller: _confirmationController,
autofocus: true,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
labelText: appText('输入 Yes', 'Type Yes'),
border: const OutlineInputBorder(),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(appText('取消', 'Cancel')),
),
FilledButton.icon(
key: const ValueKey('settings-archived-task-confirm-delete-yes'),
onPressed: _confirmed ? () => Navigator.of(context).pop(true) : null,
style: FilledButton.styleFrom(
backgroundColor: widget.palette.danger,
foregroundColor: Colors.white,
disabledBackgroundColor: widget.palette.strokeSoft,
disabledForegroundColor: widget.palette.textMuted,
),
icon: const Icon(Icons.delete_forever_outlined),
label: Text(appText('彻底删除', 'Delete permanently')),
),
],
);
}
}
class _ArchivedTasksEmptyState extends StatelessWidget {
@override
Widget build(BuildContext context) {
final palette = context.palette;
final theme = Theme.of(context);
return Container(
key: const ValueKey('settings-archived-tasks-empty'),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
decoration: BoxDecoration(
color: palette.surfaceSecondary,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: palette.strokeSoft),
),
child: Column(
children: [
Icon(Icons.archive_outlined, size: 28, color: palette.textMuted),
const SizedBox(height: 8),
Text(
appText('暂无归档任务', 'No archived tasks'),
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
);
}
}
class _ArchivedTaskTile extends StatelessWidget {
const _ArchivedTaskTile({
required this.session,
required this.selected,
required this.onSelectionChanged,
required this.onRestore,
required this.onStop,
required this.onDelete,
});
final GatewaySessionSummary session;
final bool selected;
final ValueChanged<bool> onSelectionChanged;
final Future<void> Function() onRestore;
final Future<void> Function()? onStop;
final Future<void> Function() onDelete;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final theme = Theme.of(context);
final preview = session.lastMessagePreview?.trim() ?? '';
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Checkbox(
key: ValueKey<String>(
'settings-archived-task-select-${session.key}',
),
value: selected,
onChanged: (value) => onSelectionChanged(value ?? false),
),
const SizedBox(width: 8),
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: palette.surfaceSecondary,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: palette.strokeSoft),
),
child: Icon(
Icons.archive_outlined,
color: palette.textSecondary,
size: 18,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
session.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
if (preview.isNotEmpty) ...[
const SizedBox(height: 3),
Text(
preview,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textMuted,
),
),
],
const SizedBox(height: 4),
Text(
_archivedTaskUpdatedAtLabel(session.updatedAtMs),
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textMuted,
),
),
],
),
),
const SizedBox(width: 12),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.end,
children: [
FilledButton.tonalIcon(
key: ValueKey<String>(
'settings-archived-task-restore-${session.key}',
),
onPressed: () async {
await onRestore();
},
icon: const Icon(Icons.unarchive_outlined),
label: Text(appText('解除归档', 'Restore')),
),
if (onStop != null)
FilledButton.tonalIcon(
key: ValueKey<String>(
'settings-archived-task-stop-${session.key}',
),
onPressed: () async {
await onStop!();
},
icon: const Icon(Icons.stop_rounded),
label: Text(appText('停止', 'Stop')),
),
IconButton(
key: ValueKey<String>(
'settings-archived-task-delete-${session.key}',
),
tooltip: appText('彻底删除归档记录', 'Delete archived record'),
onPressed: () async {
await onDelete();
},
icon: const Icon(Icons.delete_outline_rounded),
),
],
),
],
),
);
}
}
String _archivedTaskUpdatedAtLabel(double? updatedAtMs) {
if (updatedAtMs == null) {
return appText('无更新时间', 'No update time');
}
final timestamp = DateTime.fromMillisecondsSinceEpoch(updatedAtMs.round());
final date =
'${timestamp.year.toString().padLeft(4, '0')}-'
'${timestamp.month.toString().padLeft(2, '0')}-'
'${timestamp.day.toString().padLeft(2, '0')}';
final time =
'${timestamp.hour.toString().padLeft(2, '0')}:'
'${timestamp.minute.toString().padLeft(2, '0')}';
return appText('归档前更新于 $date $time', 'Updated before archive $date $time');
}