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

61
lib/app/app.dart Normal file
View File

@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../backend/event.dart';
import 'features/peers/controller/peers_controller.dart';
import 'features/transfer/controller/transfers_controller.dart';
import 'navigation/home_shell.dart';
import 'sync/backend_event_sync.dart';
import 'theme/app_theme.dart';
import 'theme/theme_mode_controller.dart';
class MeshDropApp extends ConsumerWidget {
const MeshDropApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final themeMode = ref.watch(themeModeControllerProvider);
ref.listen<AsyncValue<AppEvent>>(backendEventSyncProvider, (
previous,
next,
) {
next.whenData((event) {
switch (event) {
case AppEvent_PeerConnectOrUpdated(:final peer):
ref.read(peersControllerProvider.notifier).upsertPeer(peer);
case AppEvent_PeerDisconnected(:final id):
ref.read(peersControllerProvider.notifier).removePeer(id);
case AppEvent_TransferAdded(:final transfer):
ref
.read(transfersControllerProvider.notifier)
.upsertTransfer(transfer);
case AppEvent_TransferStatusChanged(:final transfer):
ref
.read(transfersControllerProvider.notifier)
.upsertTransfer(transfer);
case AppEvent_TransferRemoved(:final id):
ref.read(transfersControllerProvider.notifier).removeTransfer(id);
case AppEvent_TransferClear():
ref
.read(transfersControllerProvider.notifier)
.clearTransfersLocal();
case AppEvent_TransferProgressChanged(:final id, :final progress):
final currentBytes = progress < 0 ? 0.0 : progress;
ref
.read(transfersControllerProvider.notifier)
.updateProgress(id, currentBytes);
}
});
});
return MaterialApp(
title: 'Mesh Drop',
debugShowCheckedModeBanner: false,
themeMode: themeMode,
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
home: const HomeShell(),
);
}
}

View File

@@ -0,0 +1,246 @@
import 'dart:io';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import '../../../backend/api/commands.dart' as commands;
import '../../../backend/discovery/model.dart';
class SendFilesModal extends StatefulWidget {
const SendFilesModal({
super.key,
required this.peer,
required this.targetIp,
this.initialPaths = const [],
});
final Peer peer;
final String targetIp;
final List<String> initialPaths;
@override
State<SendFilesModal> createState() => _SendFilesModalState();
}
class _SendFilesModalState extends State<SendFilesModal> {
late final List<String> _paths = [...widget.initialPaths];
bool _dragging = false;
Future<void> _submit() async {
if (_paths.isEmpty) {
return;
}
final pending = List<String>.from(_paths);
if (mounted) {
Navigator.of(context).pop();
}
for (final path in pending) {
final entityType = FileSystemEntity.typeSync(path);
if (entityType == FileSystemEntityType.directory) {
commands.sendFolder(
target: widget.peer,
targetIp: widget.targetIp,
folderPath: path,
);
} else {
commands.sendFile(
target: widget.peer,
targetIp: widget.targetIp,
filePath: path,
);
}
}
}
Future<void> _pickFiles() async {
final result = await FilePicker.platform.pickFiles(allowMultiple: true);
if (result == null || !mounted) {
return;
}
final selected = result.paths.whereType<String>().where(
(p) => p.isNotEmpty,
);
setState(() {
for (final path in selected) {
if (!_paths.contains(path)) {
_paths.add(path);
}
}
});
}
Future<void> _pickFolder() async {
final folder = await FilePicker.platform.getDirectoryPath();
if (folder == null || folder.isEmpty || !mounted) {
return;
}
setState(() {
if (!_paths.contains(folder)) {
_paths.add(folder);
}
});
}
bool get _enableDragDrop =>
Platform.isLinux || Platform.isWindows || Platform.isMacOS;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AlertDialog(
title: Text('发送文件给 ${widget.peer.name}'),
content: SizedBox(
width: 640,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_enableDragDrop)
DropTarget(
onDragEntered: (_) => setState(() => _dragging = true),
onDragExited: (_) => setState(() => _dragging = false),
onDragDone: (details) {
setState(() {
_dragging = false;
_paths.addAll(
details.files
.map((f) => f.path)
.where((p) => p.isNotEmpty)
.where((p) => !_paths.contains(p)),
);
});
},
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _dragging
? theme.colorScheme.primary
: theme.colorScheme.outlineVariant,
width: _dragging ? 2 : 1,
),
color: theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.35,
),
),
child: Column(
children: [
Icon(
_dragging
? Icons.file_download_done_rounded
: Icons.file_upload_rounded,
size: 36,
),
const SizedBox(height: 8),
Text(_dragging ? '松手即可添加文件' : '将文件或文件夹拖到这里'),
],
),
),
)
else
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: theme.colorScheme.outlineVariant),
color: theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.35,
),
),
child: const Column(
children: [
Icon(Icons.file_upload_rounded, size: 36),
SizedBox(height: 8),
],
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _pickFiles,
icon: const Icon(Icons.attach_file_rounded),
label: const Text('选择文件'),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: _pickFolder,
icon: const Icon(Icons.folder_open_rounded),
label: const Text('选择文件夹'),
),
),
],
),
const SizedBox(height: 12),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 260),
child: _paths.isEmpty
? const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Text('暂无待发送文件'),
),
)
: ListView.separated(
shrinkWrap: true,
itemCount: _paths.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final path = _paths[index];
final isDirectory =
FileSystemEntity.typeSync(path) ==
FileSystemEntityType.directory;
return ListTile(
dense: true,
leading: Icon(
isDirectory
? Icons.folder_copy_rounded
: Icons.insert_drive_file_rounded,
),
title: Text(
path.split(Platform.pathSeparator).last,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
path,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: IconButton(
icon: const Icon(Icons.delete_outline_rounded),
onPressed: () =>
setState(() => _paths.removeAt(index)),
),
);
},
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('取消'),
),
FilledButton.icon(
onPressed: _paths.isEmpty ? null : _submit,
icon: const Icon(Icons.send_rounded),
label: Text('发送 (${_paths.length})'),
),
],
);
}
}

View File

@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import '../../../backend/api/commands.dart' as commands;
import '../../../backend/discovery/model.dart';
class SendTextModal extends StatefulWidget {
const SendTextModal({super.key, required this.peer, required this.targetIp});
final Peer peer;
final String targetIp;
@override
State<SendTextModal> createState() => _SendTextModalState();
}
class _SendTextModalState extends State<SendTextModal> {
final _controller = TextEditingController();
Future<void> _submit() async {
final text = _controller.text.trim();
if (text.isEmpty) {
return;
}
if (mounted) {
Navigator.of(context).pop();
}
commands.sendText(
target: widget.peer,
targetIp: widget.targetIp,
text: text,
);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('发送文本给 ${widget.peer.name}'),
content: SizedBox(
width: 540,
child: TextField(
controller: _controller,
maxLines: 8,
minLines: 4,
decoration: const InputDecoration(
hintText: '输入你想发送的文本内容',
border: OutlineInputBorder(),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('取消'),
),
FilledButton.icon(
onPressed: _submit,
icon: const Icon(Icons.send_rounded),
label: const Text('发送'),
),
],
);
}
}

View File

@@ -0,0 +1,50 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../../backend/api/commands.dart' as commands;
import '../../../../backend/discovery/model.dart';
part 'peers_controller.g.dart';
@riverpod
class PeersController extends _$PeersController {
@override
Future<List<Peer>> build() async {
return commands.getPeers();
}
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(commands.getPeers);
}
void upsertPeer(Peer peer) {
final current = state.asData?.value ?? const <Peer>[];
final index = current.indexWhere((item) => item.id == peer.id);
if (index < 0) {
state = AsyncData([...current, peer]);
return;
}
final old = current[index];
if (old == peer) {
return;
}
final next = [...current];
next[index] = peer;
state = AsyncData(next);
}
void removePeer(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);
}
}
}

View File

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

View File

@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../controller/peers_controller.dart';
import '../widgets/peer_card.dart';
import '../../../shared/widgets/empty_state.dart';
class PeersPage extends ConsumerWidget {
const PeersPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final peersAsync = ref.watch(peersControllerProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Peers Discovery'),
actions: [
IconButton(
tooltip: '刷新',
onPressed: () =>
ref.read(peersControllerProvider.notifier).refresh(),
icon: const Icon(Icons.refresh_rounded),
),
],
),
body: peersAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => AppEmptyState(
icon: Icons.cloud_off_rounded,
title: '发现失败',
message: error.toString(),
action: FilledButton.icon(
onPressed: () =>
ref.read(peersControllerProvider.notifier).refresh(),
icon: const Icon(Icons.refresh_rounded),
label: const Text('重试'),
),
),
data: (peers) {
if (peers.isEmpty) {
return AppEmptyState(
icon: Icons.wifi_tethering,
title: '扫描中',
message: '请确认局域网连接和防火墙配置。',
);
}
final width = MediaQuery.sizeOf(context).width;
final columns = width >= 1320
? 4
: width >= 1100
? 3
: width >= 720
? 2
: 1;
return GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1.36,
),
itemCount: peers.length,
itemBuilder: (context, index) => PeerCard(peer: peers[index]),
);
},
),
);
}
}

View File

@@ -0,0 +1,266 @@
import 'dart:io';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:flutter/material.dart';
import '../../../../backend/api/commands.dart' as commands;
import '../../../../backend/discovery/model.dart';
import '../../modals/send_files_modal.dart';
import '../../modals/send_text_modal.dart';
class PeerCard extends StatefulWidget {
const PeerCard({super.key, required this.peer});
final Peer peer;
@override
State<PeerCard> createState() => _PeerCardState();
}
class _PeerCardState extends State<PeerCard> {
bool _dragging = false;
String? get _targetIp {
if (widget.peer.routes.isEmpty) {
return null;
}
return widget.peer.routes.values.first.ip;
}
Future<void> _openTextModal() async {
final targetIp = _targetIp;
if (targetIp == null || !mounted) {
return;
}
await showDialog<void>(
context: context,
builder: (_) => SendTextModal(peer: widget.peer, targetIp: targetIp),
);
}
Future<void> _openFilesModal(List<String> files) async {
final targetIp = _targetIp;
if (targetIp == null || !mounted) {
return;
}
await showDialog<void>(
context: context,
builder: (_) => SendFilesModal(
peer: widget.peer,
targetIp: targetIp,
initialPaths: files,
),
);
}
bool get _enableDragDrop =>
Platform.isLinux || Platform.isWindows || Platform.isMacOS;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final targetIp = _targetIp;
final card = Card(
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
side: BorderSide(
color: _dragging
? theme.colorScheme.primary
: theme.colorScheme.outlineVariant,
width: _dragging ? 2 : 1,
),
),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
backgroundColor: theme.colorScheme.primaryContainer,
child: Icon(
_iconForOs(widget.peer.os),
color: theme.colorScheme.onPrimaryContainer,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.peer.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium,
),
Text(
targetIp ?? '无路由',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
Chip(
avatar: const Icon(Icons.shield_rounded, size: 16),
label: Text(widget.peer.enableTls ? 'TLS on' : 'TLS off'),
),
if (widget.peer.trustMismatch)
const Chip(
avatar: Icon(Icons.warning_amber_rounded, size: 16),
label: Text('Trust Mismatch'),
),
FutureBuilder<bool>(
future: commands.isTrusted(peerId: widget.peer.id),
builder: (context, snapshot) {
final trusted = snapshot.data ?? false;
return Chip(
avatar: Icon(
trusted
? Icons.verified_user_rounded
: Icons.gpp_maybe_rounded,
size: 16,
),
label: Text(trusted ? 'Trusted' : 'Untrusted'),
);
},
),
Chip(
avatar: const Icon(Icons.hub_rounded, size: 16),
label: Text('${widget.peer.routes.length} routes'),
),
],
),
const Spacer(),
if (_enableDragDrop && _dragging)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.colorScheme.primaryContainer.withValues(
alpha: 0.55,
),
),
child: const Center(child: Text('释放后发送文件')),
),
Row(
children: [
Expanded(
child: FilledButton.tonalIcon(
onPressed: targetIp == null ? null : _openTextModal,
icon: const Icon(Icons.chat_bubble_rounded),
label: const Text('文本'),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton.icon(
onPressed: targetIp == null
? null
: () => _openFilesModal(const []),
icon: const Icon(Icons.upload_file_rounded),
label: const Text('文件/文件夹'),
),
),
],
),
const SizedBox(height: 8),
FutureBuilder<bool>(
future: commands.isTrusted(peerId: widget.peer.id),
builder: (context, snapshot) {
final trusted = snapshot.data ?? false;
return SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () async {
if (trusted) {
await commands.untrustPeer(peerId: widget.peer.id);
} else {
await commands.trustPeer(peerId: widget.peer.id);
}
if (mounted) {
setState(() {});
}
},
icon: Icon(
trusted
? Icons.verified_user_rounded
: Icons.gpp_maybe_rounded,
),
label: Text(trusted ? '取消信任' : '设为信任设备'),
),
);
},
),
if (targetIp == null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'该设备暂无可用传输路由',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
),
],
),
),
);
if (!_enableDragDrop) {
return card;
}
return DropTarget(
onDragEntered: (_) => setState(() => _dragging = true),
onDragExited: (_) => setState(() => _dragging = false),
onDragDone: (details) {
final files = details.files
.map((f) => f.path)
.where((e) => e.isNotEmpty)
.toList();
setState(() => _dragging = false);
if (files.isNotEmpty) {
_openFilesModal(files);
}
},
child: card,
);
}
IconData _iconForOs(String os) {
final normalized = os.toLowerCase();
if (normalized.contains('windows')) {
return Icons.laptop_windows_rounded;
}
if (normalized.contains('mac') || normalized.contains('darwin')) {
return Icons.laptop_mac_rounded;
}
if (normalized.contains('linux')) {
return Icons.computer_rounded;
}
if (normalized.contains('android')) {
return Icons.phone_android_rounded;
}
if (normalized.contains('ios') || normalized.contains('iphone')) {
return Icons.phone_iphone_rounded;
}
return Platform.isAndroid
? Icons.devices_rounded
: Icons.device_hub_rounded;
}
}

View File

@@ -0,0 +1,99 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../../backend/api/commands.dart' as commands;
part 'settings_controller.g.dart';
class SettingsState {
const SettingsState({
required this.hostname,
required this.savePath,
required this.autoAccept,
required this.saveHistory,
required this.enableTls,
});
final String hostname;
final String savePath;
final bool autoAccept;
final bool saveHistory;
final bool enableTls;
SettingsState copyWith({
String? hostname,
String? savePath,
bool? autoAccept,
bool? saveHistory,
bool? enableTls,
}) {
return SettingsState(
hostname: hostname ?? this.hostname,
savePath: savePath ?? this.savePath,
autoAccept: autoAccept ?? this.autoAccept,
saveHistory: saveHistory ?? this.saveHistory,
enableTls: enableTls ?? this.enableTls,
);
}
}
@riverpod
class SettingsController extends _$SettingsController {
@override
Future<SettingsState> build() async {
final values = await Future.wait([
commands.getHostname(),
commands.getSavePath(),
commands.getAutoAccept(),
commands.getSaveHistory(),
commands.getEnableTls(),
]);
return SettingsState(
hostname: values[0] as String,
savePath: values[1] as String,
autoAccept: values[2] as bool,
saveHistory: values[3] as bool,
enableTls: values[4] as bool,
);
}
Future<void> updateHostname(String value) async {
await commands.setHostname(hostname: value);
final current = state.value;
if (current != null) {
state = AsyncData(current.copyWith(hostname: value));
}
}
Future<void> updateSavePath(String value) async {
await commands.setSavePath(savePath: value);
final current = state.value;
if (current != null) {
state = AsyncData(current.copyWith(savePath: value));
}
}
Future<void> updateAutoAccept(bool value) async {
await commands.setAutoAccept(autoAccept: value);
final current = state.value;
if (current != null) {
state = AsyncData(current.copyWith(autoAccept: value));
}
}
Future<void> updateSaveHistory(bool value) async {
await commands.setSaveHistory(saveHistory: value);
final current = state.value;
if (current != null) {
state = AsyncData(current.copyWith(saveHistory: value));
}
}
Future<void> updateEnableTls(bool value) async {
await commands.setEnableTls(enableTls: value);
final current = state.value;
if (current != null) {
state = AsyncData(current.copyWith(enableTls: value));
}
}
}

View File

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

View File

@@ -0,0 +1,161 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../theme/theme_mode_controller.dart';
import '../controller/settings_controller.dart';
class SettingsPage extends ConsumerWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settingsAsync = ref.watch(settingsControllerProvider);
final themeMode = ref.watch(themeModeControllerProvider);
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: settingsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(child: Text(error.toString())),
data: (settings) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Column(
children: [
ListTile(
leading: const Icon(Icons.brightness_6_rounded),
title: const Text('主题模式'),
subtitle: Text(themeMode.name),
trailing: SegmentedButton<ThemeMode>(
segments: const [
ButtonSegment(
value: ThemeMode.light,
icon: Icon(Icons.light_mode_rounded),
label: Text('Light'),
),
ButtonSegment(
value: ThemeMode.dark,
icon: Icon(Icons.dark_mode_rounded),
label: Text('Dark'),
),
ButtonSegment(
value: ThemeMode.system,
icon: Icon(Icons.settings_suggest_rounded),
label: Text('System'),
),
],
selected: {themeMode},
onSelectionChanged: (selection) => ref
.read(themeModeControllerProvider.notifier)
.setThemeMode(selection.first),
),
),
],
),
),
const SizedBox(height: 12),
Card(
child: Column(
children: [
ListTile(
leading: const Icon(Icons.badge_rounded),
title: const Text('Hostname'),
subtitle: Text(settings.hostname),
trailing: IconButton(
icon: const Icon(Icons.edit_rounded),
onPressed: () => _editText(
context: context,
title: 'Hostname',
initial: settings.hostname,
onSubmit: (value) => ref
.read(settingsControllerProvider.notifier)
.updateHostname(value),
),
),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.folder_rounded),
title: const Text('默认保存路径'),
subtitle: Text(settings.savePath),
trailing: IconButton(
icon: const Icon(Icons.edit_rounded),
onPressed: () => _editText(
context: context,
title: '保存路径',
initial: settings.savePath,
onSubmit: (value) => ref
.read(settingsControllerProvider.notifier)
.updateSavePath(value),
),
),
),
const Divider(height: 1),
SwitchListTile.adaptive(
secondary: const Icon(Icons.auto_mode_rounded),
title: const Text('自动接收'),
value: settings.autoAccept,
onChanged: (v) => ref
.read(settingsControllerProvider.notifier)
.updateAutoAccept(v),
),
SwitchListTile.adaptive(
secondary: const Icon(Icons.history_rounded),
title: const Text('保存历史记录'),
value: settings.saveHistory,
onChanged: (v) => ref
.read(settingsControllerProvider.notifier)
.updateSaveHistory(v),
),
SwitchListTile.adaptive(
secondary: const Icon(Icons.shield_rounded),
title: const Text('启用 TLS'),
value: settings.enableTls,
onChanged: (v) => ref
.read(settingsControllerProvider.notifier)
.updateEnableTls(v),
),
],
),
),
],
);
},
),
);
}
Future<void> _editText({
required BuildContext context,
required String title,
required String initial,
required Future<void> Function(String value) onSubmit,
}) async {
final controller = TextEditingController(text: initial);
final confirmed = await showDialog<bool>(
context: context,
builder: (_) {
return AlertDialog(
title: Text(title),
content: TextField(controller: controller),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('保存'),
),
],
);
},
);
if (confirmed == true) {
await onSubmit(controller.text.trim());
}
}
}

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));
}
}

View File

@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import '../features/peers/pages/peers_page.dart';
import '../features/settings/pages/settings_page.dart';
import '../features/transfer/pages/transfer_page.dart';
enum AppTab { peers, transfer, settings }
class HomeShell extends StatefulWidget {
const HomeShell({super.key});
@override
State<HomeShell> createState() => _HomeShellState();
}
class _HomeShellState extends State<HomeShell> {
AppTab _current = AppTab.peers;
void _onSelect(int index) {
setState(() {
_current = AppTab.values[index];
});
}
@override
Widget build(BuildContext context) {
final isWide = MediaQuery.sizeOf(context).width >= 920;
final pages = const [PeersPage(), TransferPage(), SettingsPage()];
if (isWide) {
return Scaffold(
body: Row(
children: [
NavigationRail(
selectedIndex: _current.index,
onDestinationSelected: _onSelect,
labelType: NavigationRailLabelType.all,
minWidth: 84,
destinations: const [
NavigationRailDestination(
icon: Icon(Icons.radar_rounded),
label: Text('Peers'),
),
NavigationRailDestination(
icon: Icon(Icons.sync_alt_rounded),
label: Text('Transfer'),
),
NavigationRailDestination(
icon: Icon(Icons.tune_rounded),
label: Text('Settings'),
),
],
),
const VerticalDivider(width: 1),
Expanded(child: pages[_current.index]),
],
),
);
}
return Scaffold(
body: pages[_current.index],
bottomNavigationBar: NavigationBar(
selectedIndex: _current.index,
onDestinationSelected: _onSelect,
destinations: const [
NavigationDestination(
icon: Icon(Icons.radar_rounded),
label: 'Peers',
),
NavigationDestination(
icon: Icon(Icons.sync_alt_rounded),
label: 'Transfer',
),
NavigationDestination(
icon: Icon(Icons.tune_rounded),
label: 'Settings',
),
],
),
);
}
}

View File

@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
class AppEmptyState extends StatelessWidget {
const AppEmptyState({
super.key,
required this.icon,
required this.title,
required this.message,
this.action,
});
final IconData icon;
final String title;
final String message;
final Widget? action;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 52, color: theme.colorScheme.primary),
const SizedBox(height: 14),
Text(title, style: theme.textTheme.titleLarge),
const SizedBox(height: 8),
Text(
message,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
if (action != null) ...[const SizedBox(height: 16), action!],
],
),
),
);
}
}

View File

@@ -0,0 +1,11 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../backend/api/commands.dart' as commands;
import '../../backend/event.dart';
part 'backend_event_sync.g.dart';
@Riverpod(keepAlive: true)
Stream<AppEvent> backendEventSync(Ref ref) {
return commands.createEventStream();
}

View File

@@ -0,0 +1,44 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'backend_event_sync.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(backendEventSync)
final backendEventSyncProvider = BackendEventSyncProvider._();
final class BackendEventSyncProvider
extends
$FunctionalProvider<AsyncValue<AppEvent>, AppEvent, Stream<AppEvent>>
with $FutureModifier<AppEvent>, $StreamProvider<AppEvent> {
BackendEventSyncProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'backendEventSyncProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$backendEventSyncHash();
@$internal
@override
$StreamProviderElement<AppEvent> $createElement($ProviderPointer pointer) =>
$StreamProviderElement(pointer);
@override
Stream<AppEvent> create(Ref ref) {
return backendEventSync(ref);
}
}
String _$backendEventSyncHash() => r'98ada20a035b92e209fca4c366faf7f41f412160';

View File

@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
final class AppTheme {
const AppTheme._();
static ThemeData get lightTheme {
const seed = Color(0xFF4F46E5);
final colorScheme = ColorScheme.fromSeed(
seedColor: seed,
brightness: Brightness.light,
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
scaffoldBackgroundColor: const Color(0xFFF7F8FC),
cardTheme: CardThemeData(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
chipTheme: ChipThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)),
),
);
}
static ThemeData get darkTheme {
const seed = Color(0xFF8B8BFF);
final colorScheme = ColorScheme.fromSeed(
seedColor: seed,
brightness: Brightness.dark,
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
scaffoldBackgroundColor: const Color(0xFF0B1020),
cardTheme: CardThemeData(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
chipTheme: ChipThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)),
),
);
}
}

View File

@@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'theme_mode_controller.g.dart';
@riverpod
class ThemeModeController extends _$ThemeModeController {
@override
ThemeMode build() => ThemeMode.system;
void setThemeMode(ThemeMode mode) {
state = mode;
}
void toggle() {
state = switch (state) {
ThemeMode.dark => ThemeMode.light,
ThemeMode.light || ThemeMode.system => ThemeMode.dark,
};
}
}

View File

@@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'theme_mode_controller.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(ThemeModeController)
final themeModeControllerProvider = ThemeModeControllerProvider._();
final class ThemeModeControllerProvider
extends $NotifierProvider<ThemeModeController, ThemeMode> {
ThemeModeControllerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'themeModeControllerProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$themeModeControllerHash();
@$internal
@override
ThemeModeController create() => ThemeModeController();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(ThemeMode value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<ThemeMode>(value),
);
}
}
String _$themeModeControllerHash() =>
r'96d7617273bf6319cb57844c94190c8514dc0b36';
abstract class _$ThemeModeController extends $Notifier<ThemeMode> {
ThemeMode build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<ThemeMode, ThemeMode>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<ThemeMode, ThemeMode>,
ThemeMode,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}