Files
gopeed/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart
T

587 lines
19 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform, kIsWeb;
import 'package:flutter/material.dart' show Colors, Icons;
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
import '../../../../shared/theme/app_design_tokens.dart';
import '../../../../shared/theme/app_palette.dart';
import '../../../../shared/widgets/app_copy_icon_button.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../../../../shared/widgets/app_tooltip.dart';
import '../../../../shared/widgets/detail/app_detail_surface.dart';
import '../../../../core/utils/text_wrap.dart';
import '../../../../l10n/l10n.dart';
import '../../domain/task_record.dart';
import '../task_record_localizations.dart';
import '../../application/task_runtime_status_provider.dart';
import 'task_file_browser_dialog.dart';
import 'task_file_tree.dart';
import 'task_statistics/task_statistics_tab.dart';
class TaskDrawer extends ConsumerStatefulWidget {
const TaskDrawer({
super.key,
required this.task,
required this.onClose,
required this.onOpenStorage,
required this.onUpdateUrl,
});
final TaskRecord? task;
final VoidCallback onClose;
final VoidCallback onOpenStorage;
final Future<void> Function(String url) onUpdateUrl;
@override
ConsumerState<TaskDrawer> createState() => _TaskDrawerState();
}
class _TaskDrawerState extends ConsumerState<TaskDrawer> {
@override
Widget build(BuildContext context) {
final isOpen = widget.task != null;
final task = widget.task;
return AppDetailDrawer(
open: isOpen,
title: context.l10n.taskDetails,
onClose: widget.onClose,
drawerKey: const ValueKey('task-details-drawer'),
child: task == null
? const SizedBox.shrink()
: TaskDetailsView(
task: task,
mobile: false,
onOpenStorage: widget.onOpenStorage,
onUpdateUrl: widget.onUpdateUrl,
),
);
}
}
class TaskDetailsView extends ConsumerStatefulWidget {
const TaskDetailsView({
super.key,
required this.task,
required this.mobile,
required this.onOpenStorage,
required this.onUpdateUrl,
});
final TaskRecord task;
final bool mobile;
final VoidCallback onOpenStorage;
final Future<void> Function(String url) onUpdateUrl;
@override
ConsumerState<TaskDetailsView> createState() => _TaskDetailsViewState();
}
class _TaskDetailsViewState extends ConsumerState<TaskDetailsView> {
final _urlController = TextEditingController();
int _tabIndex = 0;
bool _editingUrl = false;
bool _updatingUrl = false;
@override
void initState() {
super.initState();
_urlController.text = widget.task.url;
}
@override
void didUpdateWidget(covariant TaskDetailsView oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.task.id != oldWidget.task.id) {
_tabIndex = 0;
_editingUrl = false;
_updatingUrl = false;
_urlController.text = widget.task.url;
} else if (!_editingUrl && widget.task.url != oldWidget.task.url) {
_urlController.text = widget.task.url;
}
if (!widget.task.canUpdateUrl && _editingUrl) {
_editingUrl = false;
_updatingUrl = false;
_urlController.text = widget.task.url;
}
}
@override
void dispose() {
_urlController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
final contentPadding = widget.mobile
? AppDesignTokens.taskDetailsMobilePadding
: AppDesignTokens.taskDetailsDesktopPadding;
final runtimeStatus = _tabIndex == 1 ? ref.watch(taskRuntimeStatusProvider(widget.task.id)).value : null;
return Column(
children: [
Container(
height: 48,
padding: EdgeInsets.symmetric(horizontal: contentPadding),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: palette.border)),
),
child: Row(
children: [
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_DrawerTabButton(
label: context.l10n.taskDetailsInfoTab,
active: _tabIndex == 0,
onTap: () => setState(() => _tabIndex = 0),
),
const SizedBox(width: 24),
_DrawerTabButton(
label: context.l10n.taskDetailsFilesTab,
active: _tabIndex == 1,
onTap: () => setState(() => _tabIndex = 1),
),
const SizedBox(width: 24),
_DrawerTabButton(
label: context.l10n.taskDetailsStatsTab,
active: _tabIndex == 2,
onTap: () => setState(() => _tabIndex = 2),
),
],
),
),
),
],
),
),
Expanded(
child: switch (_tabIndex) {
0 => SingleChildScrollView(
key: PageStorageKey<String>('task-info-${widget.task.id}'),
padding: EdgeInsets.all(contentPadding),
child: _TaskInfoTab(
task: widget.task,
canOpenStorage: _canOpenStoragePath,
editingUrl: _editingUrl,
updatingUrl: _updatingUrl,
urlController: _urlController,
onOpenStorage: widget.onOpenStorage,
onEditUrl: widget.task.canUpdateUrl
? () => setState(() {
_urlController.text = widget.task.url;
_editingUrl = true;
})
: null,
onCancelEditUrl: () => setState(() {
_urlController.text = widget.task.url;
_editingUrl = false;
}),
onSaveUrl: () => unawaited(_saveUrl()),
),
),
1 => Padding(
padding: EdgeInsets.all(contentPadding),
child: TaskFileTree(
task: widget.task,
runtimeStatus: runtimeStatus,
progressAction: _TaskDetailIconButton(
key: const ValueKey('task-files-browse'),
label: context.l10n.browseFiles,
icon: const Icon(Icons.snippet_folder_outlined, size: 18),
onPressed: _browseFiles,
),
),
),
_ => TaskStatisticsTab(task: widget.task, mobile: widget.mobile),
},
),
],
);
}
bool get _canOpenStoragePath {
if (kIsWeb) return false;
return switch (defaultTargetPlatform) {
TargetPlatform.windows || TargetPlatform.macOS || TargetPlatform.linux => true,
_ => false,
};
}
void _browseFiles() {
unawaited(browseTaskFiles(context, widget.task, mobile: widget.mobile));
}
Future<void> _saveUrl() async {
final value = _urlController.text.trim();
if (value.isEmpty || _updatingUrl || !widget.task.canUpdateUrl) return;
setState(() => _updatingUrl = true);
try {
await widget.onUpdateUrl(value);
if (!mounted) return;
setState(() {
_editingUrl = false;
_updatingUrl = false;
});
} catch (_) {
if (mounted) setState(() => _updatingUrl = false);
}
}
}
class _DrawerTabButton extends StatelessWidget {
const _DrawerTabButton({required this.label, required this.active, required this.onTap});
final String label;
final bool active;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: active ? palette.textPrimary : Colors.transparent, width: 2)),
),
child: Text(
label,
style: TextStyle(
color: active ? palette.textPrimary : palette.textSecondary,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
);
}
}
class _TaskInfoTab extends StatelessWidget {
const _TaskInfoTab({
required this.task,
required this.canOpenStorage,
required this.editingUrl,
required this.updatingUrl,
required this.urlController,
required this.onOpenStorage,
required this.onEditUrl,
required this.onCancelEditUrl,
required this.onSaveUrl,
});
final TaskRecord task;
final bool canOpenStorage;
final bool editingUrl;
final bool updatingUrl;
final TextEditingController urlController;
final VoidCallback onOpenStorage;
final VoidCallback? onEditUrl;
final VoidCallback onCancelEditUrl;
final VoidCallback onSaveUrl;
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_InfoBlock(label: context.l10n.name, value: task.name),
_InfoBlock(
label: context.l10n.status,
value: task.localizedStatus(context.l10n),
error: task.status == TaskStatus.failed,
),
_InfoBlock(
label: context.l10n.size,
value: task.status == TaskStatus.completed
? task.total ?? task.downloaded
: task.total != null
? '${task.downloaded} / ${task.total}'
: task.downloaded,
),
if (task.speed != null) _InfoBlock(label: context.l10n.speed, value: task.speed!),
if (task.uploading) _InfoBlock(label: context.l10n.uploadSpeed, value: task.uploadSpeed!),
if (task.status == TaskStatus.completed)
_InfoBlock(label: context.l10n.downloadDuration, value: task.localizedDownloadDuration(context.l10n) ?? '—')
else if (task.status == TaskStatus.paused)
_InfoBlock(label: context.l10n.remaining, value: '—')
else if (task.localizedRemaining(context.l10n) case final remaining?)
_InfoBlock(label: context.l10n.remaining, value: remaining),
if (task.createdAt case final createdAt?)
_InfoBlock(label: context.l10n.createdAt, value: _formatTaskDateTime(createdAt)),
Container(
key: const ValueKey('task-details-info-divider'),
height: 1,
margin: const EdgeInsets.symmetric(vertical: 4),
color: palette.border,
),
if (editingUrl)
_EditableUrlBlock(
controller: urlController,
updating: updatingUrl,
onCancel: onCancelEditUrl,
onSave: onSaveUrl,
)
else
_CopyableInfoBlock(
label: context.l10n.downloadLink,
value: task.url,
valueKey: const ValueKey('task-details-url-value'),
action: AppCopyIconButton(key: const ValueKey('task-details-copy-url'), text: task.url),
secondaryActionLabel: onEditUrl == null ? null : context.l10n.edit,
secondaryActionIcon: Icons.edit_outlined,
onSecondaryTap: onEditUrl,
),
_CopyableInfoBlock(
label: context.l10n.storagePath,
value: task.storagePath,
valueKey: const ValueKey('task-details-storage-value'),
action: canOpenStorage
? _TaskDetailIconButton(
key: const ValueKey('task-details-open-storage'),
label: context.l10n.open,
icon: const Icon(Icons.folder_open_outlined, size: 16),
onPressed: onOpenStorage,
)
: AppCopyIconButton(key: const ValueKey('task-details-copy-storage'), text: task.storagePath),
),
],
);
}
}
String _formatTaskDateTime(DateTime value) {
final local = value.toLocal();
String two(int number) => number.toString().padLeft(2, '0');
return '${local.year}-${two(local.month)}-${two(local.day)} '
'${two(local.hour)}:${two(local.minute)}:${two(local.second)}';
}
class _InfoBlock extends StatelessWidget {
const _InfoBlock({required this.label, required this.value, this.error = false});
final String label;
final String value;
final bool error;
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_InfoLabel(label),
const SizedBox(width: 12),
Expanded(
child: Text(
value,
style: TextStyle(color: error ? palette.error : palette.textPrimary, fontSize: 12, height: 1.35),
),
),
],
),
);
}
}
class _CopyableInfoBlock extends StatelessWidget {
const _CopyableInfoBlock({
required this.label,
required this.value,
required this.valueKey,
required this.action,
this.secondaryActionLabel,
this.secondaryActionIcon,
this.onSecondaryTap,
});
final String label;
final String value;
final Key valueKey;
final Widget action;
final String? secondaryActionLabel;
final IconData? secondaryActionIcon;
final VoidCallback? onSecondaryTap;
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_InfoLabel(label),
const SizedBox(width: 12),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
softWrapAnywhere(value),
key: valueKey,
semanticsLabel: value,
softWrap: true,
style: TextStyle(color: palette.textSecondary, fontSize: 11, height: 1.35),
),
),
const SizedBox(width: 8),
if (secondaryActionLabel != null && secondaryActionIcon != null && onSecondaryTap != null) ...[
_TaskDetailIconButton(
key: const ValueKey('task-details-edit-url'),
label: secondaryActionLabel!,
icon: Icon(secondaryActionIcon!, size: 16),
onPressed: onSecondaryTap,
),
const SizedBox(width: 4),
],
action,
],
),
),
],
),
);
}
}
class _InfoLabel extends StatelessWidget {
const _InfoLabel(this.label);
final String label;
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
return SizedBox(
width: 88,
child: Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
label,
style: TextStyle(
color: palette.textMuted,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
height: 1.3,
),
),
),
);
}
}
class _EditableUrlBlock extends StatelessWidget {
const _EditableUrlBlock({
required this.controller,
required this.updating,
required this.onCancel,
required this.onSave,
});
final TextEditingController controller;
final bool updating;
final VoidCallback onCancel;
final VoidCallback onSave;
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_InfoLabel(context.l10n.downloadLink),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: 76,
child: AppTextField(
controller: controller,
minLines: null,
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
style: TextStyle(color: palette.textPrimary, fontSize: 11, height: 1.3),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: palette.inputBg,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: palette.border),
),
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
_TaskDetailIconButton(
key: const ValueKey('task-details-cancel-url'),
label: context.l10n.cancel,
icon: const Icon(Icons.close, size: 17),
onPressed: updating ? null : onCancel,
),
const SizedBox(width: 8),
_TaskDetailIconButton(
key: const ValueKey('task-details-save-url'),
label: context.l10n.updateAndResume,
icon: updating
? const SizedBox.square(dimension: 14, child: shad.CircularProgressIndicator())
: const Icon(Icons.check, size: 17),
onPressed: updating ? null : onSave,
primary: true,
),
],
),
],
),
),
],
),
);
}
}
class _TaskDetailIconButton extends StatelessWidget {
const _TaskDetailIconButton({
super.key,
required this.label,
required this.icon,
required this.onPressed,
this.primary = false,
});
final String label;
final Widget icon;
final VoidCallback? onPressed;
final bool primary;
@override
Widget build(BuildContext context) {
final button = primary
? shad.IconButton.primary(size: shad.ButtonSize.xSmall, onPressed: onPressed, icon: icon)
: shad.IconButton.ghost(size: shad.ButtonSize.xSmall, onPressed: onPressed, icon: icon);
return AppTooltip(
message: label,
child: SizedBox.square(dimension: 28, child: button),
);
}
}