This commit is contained in:
2026-02-27 21:12:56 +08:00
commit a878084cbb
233 changed files with 22988 additions and 0 deletions

View File

@@ -0,0 +1,127 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../../backend/api/commands.dart' as commands;
import '../../../../backend/transfer/model.dart';
part 'transfers_controller.g.dart';
@riverpod
class TransfersController extends _$TransfersController {
@override
Future<List<Transfer>> build() async {
return commands.getTransfers();
}
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(commands.getTransfers);
}
void updateProgress(String id, double currentBytes) {
final current = state.asData?.value;
if (current == null || current.isEmpty) {
return;
}
final normalized = currentBytes < 0 ? 0.0 : currentBytes;
var changed = false;
final next = current
.map((item) {
if (item.id != id) {
return item;
}
if ((item.progress - normalized).abs() < 0.001) {
return item;
}
changed = true;
return Transfer(
id: item.id,
createTime: item.createTime,
sender: item.sender,
senderIp: item.senderIp,
fileName: item.fileName,
fileSize: item.fileSize,
savePath: item.savePath,
status: item.status,
type: item.type,
contentType: item.contentType,
text: item.text,
errorMsg: item.errorMsg,
token: item.token,
progress: normalized,
lastReadTime: item.lastReadTime,
speed: item.speed,
);
})
.toList(growable: false);
if (changed) {
state = AsyncData(next);
}
}
void upsertTransfer(Transfer transfer) {
final current = state.asData?.value ?? const <Transfer>[];
final index = current.indexWhere((item) => item.id == transfer.id);
if (index < 0) {
state = AsyncData([...current, transfer]);
return;
}
final old = current[index];
if (old == transfer) {
return;
}
final next = [...current];
next[index] = transfer;
state = AsyncData(next);
}
void removeTransfer(String id) {
final current = state.asData?.value;
if (current == null || current.isEmpty) {
return;
}
final next = current.where((item) => item.id != id).toList(growable: false);
if (next.length != current.length) {
state = AsyncData(next);
}
}
void clearTransfersLocal() {
state = const AsyncData(<Transfer>[]);
}
Future<void> cancel(String id) async {
await commands.cancelTransfer(id: id);
}
Future<void> accept(String id, String path) async {
await commands.resolvePendingRequest(id: id, accept: true, path: path);
}
Future<void> reject(String id) async {
await commands.resolvePendingRequest(id: id, accept: false, path: '');
}
Future<void> delete(String id) async {
await commands.deleteTransfer(id: id);
}
Future<void> clearCompleted() async {
final current = state.asData?.value ?? const <Transfer>[];
final hasCompleted = current.any(
(item) => item.status is TransferStatus_Completed,
);
if (!hasCompleted) {
return;
}
await commands.clearTransfers();
}
}

View File

@@ -0,0 +1,55 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'transfers_controller.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(TransfersController)
final transfersControllerProvider = TransfersControllerProvider._();
final class TransfersControllerProvider
extends $AsyncNotifierProvider<TransfersController, List<Transfer>> {
TransfersControllerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'transfersControllerProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$transfersControllerHash();
@$internal
@override
TransfersController create() => TransfersController();
}
String _$transfersControllerHash() =>
r'4bb376e37746360ad323d2428d4fcfc4b1e37aa7';
abstract class _$TransfersController extends $AsyncNotifier<List<Transfer>> {
FutureOr<List<Transfer>> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<List<Transfer>>, List<Transfer>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<List<Transfer>>, List<Transfer>>,
AsyncValue<List<Transfer>>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}

View File

@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../backend/transfer/model.dart';
import '../../../shared/widgets/empty_state.dart';
import '../../settings/controller/settings_controller.dart';
import '../controller/transfers_controller.dart';
import '../widgets/transfer_item.dart';
class TransferPage extends ConsumerWidget {
const TransferPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final transfersAsync = ref.watch(transfersControllerProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Transfer Queue'),
actions: [
IconButton(
tooltip: '清理已完成',
onPressed: () =>
ref.read(transfersControllerProvider.notifier).clearCompleted(),
icon: const Icon(Icons.cleaning_services_rounded),
),
IconButton(
tooltip: '刷新',
onPressed: () =>
ref.read(transfersControllerProvider.notifier).refresh(),
icon: const Icon(Icons.refresh_rounded),
),
],
),
body: transfersAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => AppEmptyState(
icon: Icons.warning_amber_rounded,
title: '加载失败',
message: error.toString(),
),
data: (transfers) {
if (transfers.isEmpty) {
return const AppEmptyState(
icon: Icons.inbox_rounded,
title: '暂无传输记录',
message: '发起或接收一次文件后,会在这里看到记录。',
);
}
return ListView.separated(
padding: const EdgeInsets.all(16),
itemBuilder: (context, index) {
final item = transfers[index];
final isSender = item.type == TransferType.send;
final isReceiver = item.type == TransferType.receive;
final canCancel =
(isSender &&
(item.status is TransferStatus_Pending ||
item.status is TransferStatus_Active)) ||
(isReceiver && item.status is TransferStatus_Active);
return TransferItem(
transfer: item,
onCancel: canCancel
? () => ref
.read(transfersControllerProvider.notifier)
.cancel(item.id)
: null,
onAccept: isReceiver && item.status is TransferStatus_Pending
? () async {
final settings = await ref.read(
settingsControllerProvider.future,
);
await ref
.read(transfersControllerProvider.notifier)
.accept(item.id, settings.savePath);
}
: null,
onReject: isReceiver && item.status is TransferStatus_Pending
? () => ref
.read(transfersControllerProvider.notifier)
.reject(item.id)
: null,
onDelete: () => ref
.read(transfersControllerProvider.notifier)
.delete(item.id),
);
},
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemCount: transfers.length,
);
},
),
);
}
}

View File

@@ -0,0 +1,282 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../backend/transfer/model.dart';
class TransferItem extends StatelessWidget {
const TransferItem({
super.key,
required this.transfer,
this.onAccept,
this.onReject,
this.onCancel,
this.onDelete,
});
final Transfer transfer;
final VoidCallback? onAccept;
final VoidCallback? onReject;
final VoidCallback? onCancel;
final VoidCallback? onDelete;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
children: [
Row(
children: [
CircleAvatar(
backgroundColor: theme.colorScheme.secondaryContainer,
child: Icon(
_iconForContentType(transfer.contentType),
color: theme.colorScheme.onSecondaryContainer,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
transfer.fileName.isNotEmpty
? transfer.fileName
: '文本消息',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium,
),
Text(
'${transfer.sender.name} · ${transfer.type.name}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
_StatusChip(status: transfer.status),
],
),
const SizedBox(height: 12),
LinearProgressIndicator(
value: _progressOrNull(transfer),
color: _progressColor(theme, transfer.status),
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: Text(
'进度 ${_progressPercent(transfer).toStringAsFixed(0)}% 大小 ${_formatSize(transfer.fileSize)} 速度 ${_formatSpeed(transfer.speed)}',
style: theme.textTheme.bodySmall,
),
),
Wrap(spacing: 8, children: _buildActions(context)),
],
),
],
),
),
);
}
List<Widget> _buildActions(BuildContext context) {
final actions = <Widget>[];
if (transfer.status is TransferStatus_Pending) {
if (onReject != null) {
actions.add(
OutlinedButton.icon(
onPressed: onReject,
icon: const Icon(Icons.close_rounded),
label: const Text('拒绝'),
),
);
}
if (onAccept != null) {
actions.add(
FilledButton.icon(
onPressed: onAccept,
icon: const Icon(Icons.check_rounded),
label: const Text('接收'),
),
);
}
return actions;
}
if ((transfer.status is TransferStatus_Active ||
transfer.status is TransferStatus_Accepted) &&
onCancel != null) {
actions.add(
OutlinedButton.icon(
onPressed: onCancel,
icon: const Icon(Icons.stop_circle_outlined),
label: const Text('取消'),
),
);
}
final canPreviewText =
transfer.type == TransferType.receive &&
transfer.contentType == ContentType.text &&
transfer.status is TransferStatus_Completed;
if (canPreviewText) {
actions.add(
IconButton(
tooltip: '复制文本',
onPressed: () async {
final messenger = ScaffoldMessenger.of(context);
await Clipboard.setData(ClipboardData(text: transfer.text));
messenger.showSnackBar(const SnackBar(content: Text('文本已复制')));
},
icon: const Icon(Icons.copy_rounded),
),
);
actions.add(
IconButton(
tooltip: '查看文本',
onPressed: () {
showDialog<void>(
context: context,
builder: (_) => AlertDialog(
title: const Text('接收文本内容'),
content: SizedBox(
width: 560,
child: SingleChildScrollView(
child: SelectableText(transfer.text),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('关闭'),
),
],
),
);
},
icon: const Icon(Icons.visibility_rounded),
),
);
}
if ((transfer.status is TransferStatus_Completed ||
transfer.status is TransferStatus_Error ||
transfer.status is TransferStatus_Canceled) &&
onDelete != null) {
actions.add(
IconButton(
tooltip: '删除记录',
onPressed: onDelete,
icon: const Icon(Icons.delete_outline_rounded),
),
);
}
return actions;
}
IconData _iconForContentType(ContentType contentType) {
return switch (contentType) {
ContentType.file => Icons.insert_drive_file_rounded,
ContentType.folder => Icons.folder_zip_rounded,
ContentType.text => Icons.text_snippet_rounded,
};
}
double? _progressOrNull(Transfer transfer) {
if (transfer.status is TransferStatus_Pending) {
return null;
}
return _progressFraction(transfer);
}
Color _progressColor(ThemeData theme, TransferStatus status) {
return switch (status) {
TransferStatus_Completed() => theme.colorScheme.primary,
TransferStatus_Active() ||
TransferStatus_Accepted() => theme.colorScheme.tertiary,
TransferStatus_Rejected() ||
TransferStatus_Error() => theme.colorScheme.error,
TransferStatus_Canceled() => theme.colorScheme.outline,
TransferStatus_Pending() => theme.colorScheme.secondary,
};
}
String _formatSpeed(double? bytesPerSec) {
if (bytesPerSec == null || bytesPerSec <= 0) {
return '--';
}
return '${_formatSize(bytesPerSec)}/s';
}
String _formatSize(double bytes) {
if (bytes <= 0) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
var size = bytes;
var i = 0;
while (size >= 1024 && i < units.length - 1) {
size /= 1024;
i++;
}
final fixed = size >= 100 ? 0 : (size >= 10 ? 1 : 2);
return '${size.toStringAsFixed(fixed)} ${units[i]}';
}
double _progressedBytes(Transfer transfer) {
final current = transfer.progress;
if (current <= 0) {
return 0;
}
if (transfer.fileSize <= 0) {
return current;
}
return current.clamp(0, transfer.fileSize).toDouble();
}
double _progressFraction(Transfer transfer) {
if (transfer.fileSize <= 0) {
return 0;
}
return (_progressedBytes(transfer) / transfer.fileSize)
.clamp(0, 1)
.toDouble();
}
double _progressPercent(Transfer transfer) {
return _progressFraction(transfer) * 100;
}
}
class _StatusChip extends StatelessWidget {
const _StatusChip({required this.status});
final TransferStatus status;
@override
Widget build(BuildContext context) {
final label = switch (status) {
TransferStatus_Pending() => 'Pending',
TransferStatus_Accepted() => 'Accepted',
TransferStatus_Rejected() => 'Rejected',
TransferStatus_Completed() => 'Completed',
TransferStatus_Error() => 'Error',
TransferStatus_Canceled() => 'Canceled',
TransferStatus_Active() => 'Active',
};
return Chip(label: Text(label));
}
}