mirror of
https://github.com/chaos-zhu/easynode.git
synced 2026-10-07 16:37:58 +08:00
2949 lines
89 KiB
Dart
2949 lines
89 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:file_picker/file_picker.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:share_plus/share_plus.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import '../../core/api/api_result.dart';
|
|
import '../../core/ui/app_color_theme.dart';
|
|
import '../../core/ui/refresh_feedback.dart';
|
|
import '../../core/ui/top_notice.dart';
|
|
import '../../features/servers/server_model.dart';
|
|
import '../../l10n/app_localizations.dart';
|
|
import '../../state/api_providers.dart';
|
|
import '../../state/auth_notifier.dart';
|
|
import '../../state/host_list_notifier.dart';
|
|
import '../../state/plus_info_notifier.dart';
|
|
import '../../state/terminal_providers.dart';
|
|
import 'editor/text_editor_page.dart';
|
|
import 'media/image_preview_page.dart';
|
|
import 'media/media_extensions.dart';
|
|
import 'sftp_session_manager.dart';
|
|
import 'tab_header.dart';
|
|
|
|
class SftpTab extends StatelessWidget {
|
|
const SftpTab({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: context.colors.canvas,
|
|
body: const SftpPanel(showHeader: true),
|
|
);
|
|
}
|
|
}
|
|
|
|
class SftpPanel extends ConsumerStatefulWidget {
|
|
const SftpPanel({
|
|
super.key,
|
|
this.showHeader = false,
|
|
this.initialHostId,
|
|
this.allowDisconnect = true,
|
|
this.lockToHost = false,
|
|
this.onExecCommand,
|
|
this.onCommandPanelClose,
|
|
this.sessionManager,
|
|
});
|
|
|
|
final bool showHeader;
|
|
final String? initialHostId;
|
|
final bool allowDisconnect;
|
|
final bool lockToHost;
|
|
final void Function(String command)? onExecCommand;
|
|
final VoidCallback? onCommandPanelClose;
|
|
final SftpSessionManager? sessionManager;
|
|
|
|
@override
|
|
ConsumerState<SftpPanel> createState() => _SftpPanelState();
|
|
}
|
|
|
|
class _SftpPanelState extends ConsumerState<SftpPanel> {
|
|
bool _connecting = false;
|
|
String? _autoConnectAttemptedHostId;
|
|
|
|
SftpSessionManager get _manager =>
|
|
widget.sessionManager ?? ref.read(sftpSessionManagerProvider);
|
|
|
|
Future<void> _refresh() => runRefreshWithFeedback(
|
|
context,
|
|
() => ref.read(hostListProvider.notifier).refresh(throwOnError: true),
|
|
);
|
|
|
|
Future<void> _openServerPicker() async {
|
|
final manager = _manager;
|
|
final selected = await showModalBottomSheet<ServerModel>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
barrierColor: Colors.black.withValues(alpha: 0.34),
|
|
builder: (_) => _SftpServerPickerSheet(
|
|
connectedServerIds: manager.sessions
|
|
.where(
|
|
(session) => session.status == SftpConnectionStatus.connected,
|
|
)
|
|
.map((session) => session.server.id)
|
|
.toSet(),
|
|
activeServerId: manager.activeHostId,
|
|
onRefresh: _refresh,
|
|
),
|
|
);
|
|
if (selected == null || !mounted) return;
|
|
await _connectOrActivate(selected);
|
|
}
|
|
|
|
Future<void> _connectOrActivate(ServerModel server) async {
|
|
final l = AppLocalizations.of(context);
|
|
final manager = _manager;
|
|
if (manager.isConnected(server.id)) {
|
|
manager.activate(server.id);
|
|
return;
|
|
}
|
|
if (server.isWindows) {
|
|
_showSnack(l.tr('servers.windowsUnsupported'));
|
|
return;
|
|
}
|
|
if (!server.canConnect) {
|
|
_showSnack(l.tr('servers.notConfigured'));
|
|
return;
|
|
}
|
|
|
|
setState(() => _connecting = true);
|
|
try {
|
|
final config = await ref
|
|
.read(serverRepositoryProvider)
|
|
.fetchSshConfig(server.id);
|
|
await manager.connect(
|
|
server: server,
|
|
config: config,
|
|
loadFavorites: (hostId) =>
|
|
ref.read(serverRepositoryProvider).fetchSftpFavorites(hostId),
|
|
);
|
|
} catch (error) {
|
|
if (!mounted) return;
|
|
if (error is UnauthorizedFailure) {
|
|
await ref.read(authProvider.notifier).signOut();
|
|
return;
|
|
}
|
|
if (error is SftpConnectTimeoutException) {
|
|
_showSnack(l.tr('sftp.connectTimeout'));
|
|
} else {
|
|
_showSnack(error.toString());
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _connecting = false);
|
|
}
|
|
}
|
|
|
|
void _showSnack(String message) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(message)));
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l = AppLocalizations.of(context);
|
|
final hostsAsync = ref.watch(hostListProvider);
|
|
final SftpSessionManager manager =
|
|
widget.sessionManager ?? ref.watch(sftpSessionManagerProvider);
|
|
|
|
return AnimatedBuilder(
|
|
animation: manager,
|
|
builder: (context, _) {
|
|
final session = manager.activeSession;
|
|
final showSelector =
|
|
!widget.lockToHost &&
|
|
!_connecting &&
|
|
session != null &&
|
|
session.status != SftpConnectionStatus.connecting;
|
|
return Column(
|
|
children: [
|
|
if (widget.showHeader)
|
|
TabHeader(
|
|
title: l.tr('tabs.sftp'),
|
|
actions: showSelector
|
|
? [
|
|
_SftpHeaderSelector(
|
|
session: session,
|
|
onTap: _openServerPicker,
|
|
onDisconnect: widget.allowDisconnect
|
|
? () => manager.disconnectActive()
|
|
: null,
|
|
),
|
|
]
|
|
: const [],
|
|
),
|
|
if (!widget.showHeader && showSelector)
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 10),
|
|
child: Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: _SftpHeaderSelector(
|
|
session: session,
|
|
onTap: _openServerPicker,
|
|
onDisconnect: widget.allowDisconnect
|
|
? () => manager.disconnectActive()
|
|
: null,
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: RefreshIndicator(
|
|
color: context.colors.primary,
|
|
backgroundColor: context.colors.card,
|
|
onRefresh: _refresh,
|
|
child: hostsAsync.when(
|
|
loading: () => Center(
|
|
child: CircularProgressIndicator(
|
|
color: context.colors.primary,
|
|
),
|
|
),
|
|
error: (error, _) {
|
|
if (error is UnauthorizedFailure) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
return _SftpMessageList(
|
|
message: error.toString(),
|
|
action: TextButton(
|
|
onPressed: _refresh,
|
|
child: Text(l.tr('common.retry')),
|
|
),
|
|
);
|
|
},
|
|
data: (servers) {
|
|
_autoConnectInitialServer(servers);
|
|
if (_connecting ||
|
|
session?.status == SftpConnectionStatus.connecting) {
|
|
return _SftpConnectingView(
|
|
server: session?.server,
|
|
phase: session?.connectPhase,
|
|
);
|
|
}
|
|
if (session == null) {
|
|
return ListView(
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|
padding: const EdgeInsets.fromLTRB(16, 72, 16, 110),
|
|
children: [
|
|
SizedBox(
|
|
height: MediaQuery.sizeOf(context).height * 0.58,
|
|
child: Center(
|
|
child: _SftpEmptyCard(
|
|
onChooseServer: _openServerPicker,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
if (session.status == SftpConnectionStatus.error) {
|
|
return _SftpMessageList(
|
|
message: session.lastError == 'timeout'
|
|
? l.tr('sftp.connectTimeout')
|
|
: (session.lastError ?? l.tr('sftp.connectFailed')),
|
|
action: TextButton(
|
|
onPressed: () => _connectOrActivate(session.server),
|
|
child: Text(l.tr('common.retry')),
|
|
),
|
|
);
|
|
}
|
|
return _SftpConnectedView(
|
|
session: session,
|
|
manager: manager,
|
|
onExecCommand: widget.onExecCommand,
|
|
onCommandPanelClose: widget.onCommandPanelClose,
|
|
isPlusActive: ref.watch(isPlusActiveProvider),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _autoConnectInitialServer(List<ServerModel> servers) {
|
|
final hostId = widget.initialHostId;
|
|
if (hostId == null || hostId.isEmpty) return;
|
|
if (_autoConnectAttemptedHostId == hostId) return;
|
|
final manager = _manager;
|
|
if (manager.activeHostId == hostId && manager.activeSession != null) {
|
|
_autoConnectAttemptedHostId = hostId;
|
|
return;
|
|
}
|
|
ServerModel? target;
|
|
for (final server in servers) {
|
|
if (server.id == hostId) {
|
|
target = server;
|
|
break;
|
|
}
|
|
}
|
|
final targetServer = target;
|
|
if (targetServer == null) return;
|
|
_autoConnectAttemptedHostId = hostId;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
_connectOrActivate(targetServer);
|
|
});
|
|
}
|
|
}
|
|
|
|
class _SftpConnectedView extends StatelessWidget {
|
|
const _SftpConnectedView({
|
|
required this.session,
|
|
required this.manager,
|
|
this.onExecCommand,
|
|
this.onCommandPanelClose,
|
|
this.isPlusActive = false,
|
|
});
|
|
|
|
final SftpSessionState session;
|
|
final SftpSessionManager manager;
|
|
final void Function(String command)? onExecCommand;
|
|
final VoidCallback? onCommandPanelClose;
|
|
final bool isPlusActive;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AnimatedBuilder(
|
|
animation: session,
|
|
builder: (context, _) {
|
|
final showDirectoryLoading =
|
|
session.loadingDirectory && session.showDirectoryLoading;
|
|
return Column(
|
|
children: [
|
|
_SftpToolbar(session: session, manager: manager),
|
|
_SftpPathBar(session: session, manager: manager),
|
|
_SftpTableHeader(
|
|
allSelected: session.allVisibleSelected,
|
|
onSelectAll: session.toggleAllVisible,
|
|
),
|
|
Expanded(
|
|
child: Stack(
|
|
children: [
|
|
if (session.entries.isEmpty && !showDirectoryLoading)
|
|
_SftpMessageList(
|
|
message: AppLocalizations.of(
|
|
context,
|
|
).tr('sftp.emptyDirectory'),
|
|
)
|
|
else
|
|
ListView.builder(
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 110),
|
|
itemCount: session.entries.length,
|
|
itemBuilder: (context, index) {
|
|
final entry = session.entries[index];
|
|
return _SftpFileRow(
|
|
entry: entry,
|
|
selected: session.selectedNames.contains(entry.name),
|
|
onTap: () {
|
|
if (session.hasSelection) {
|
|
session.toggleSelection(entry.name);
|
|
} else if (entry.isDirectory) {
|
|
manager.openPath(
|
|
manager.entryPath(session, entry),
|
|
);
|
|
} else if (SftpSessionManager.isCompressedFile(
|
|
entry.name,
|
|
)) {
|
|
_promptAndExtract(context, session, entry);
|
|
} else {
|
|
_openFile(context, manager, session, entry);
|
|
}
|
|
},
|
|
onLongPress: () {
|
|
session.addSelection(entry.name);
|
|
_showFileActionSheet(context, session, entry);
|
|
},
|
|
onSelectionChanged: () =>
|
|
session.toggleSelection(entry.name),
|
|
);
|
|
},
|
|
),
|
|
if (showDirectoryLoading)
|
|
Positioned.fill(
|
|
child: ColoredBox(
|
|
color: context.colors.canvas.withValues(alpha: 0.27),
|
|
child: Center(
|
|
child: CircularProgressIndicator(
|
|
color: context.colors.primary,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _openFile(
|
|
BuildContext context,
|
|
SftpSessionManager manager,
|
|
SftpSessionState session,
|
|
SftpFileEntry entry,
|
|
) async {
|
|
final remotePath = manager.entryPath(session, entry);
|
|
List<SftpFileEntry>? imageSiblings;
|
|
var initialImageIndex = 0;
|
|
if (isImageFileName(entry.name)) {
|
|
final images = session.entries
|
|
.where((e) => !e.isDirectory && isImageFileName(e.name))
|
|
.toList(growable: false);
|
|
final index = images.indexWhere((e) => e.name == entry.name);
|
|
if (index >= 0) {
|
|
imageSiblings = images;
|
|
initialImageIndex = index;
|
|
}
|
|
}
|
|
final mutatedFile = await _openRemoteFile(
|
|
context: context,
|
|
manager: manager,
|
|
remotePath: remotePath,
|
|
entry: entry,
|
|
directoryPath: session.currentPath,
|
|
imageSiblings: imageSiblings,
|
|
initialImageIndex: initialImageIndex,
|
|
);
|
|
if (!context.mounted) return;
|
|
if (mutatedFile) {
|
|
await manager.refreshActive(showLoading: false);
|
|
}
|
|
}
|
|
|
|
static bool _isDockerComposeFile(String filename) {
|
|
return RegExp(
|
|
r'^docker-compose\.(yml|yaml)$',
|
|
caseSensitive: false,
|
|
).hasMatch(filename);
|
|
}
|
|
|
|
void _showFileActionSheet(
|
|
BuildContext context,
|
|
SftpSessionState session,
|
|
SftpFileEntry anchor,
|
|
) {
|
|
final l = AppLocalizations.of(context);
|
|
final selectedEntries = session.entries
|
|
.where((entry) => session.selectedNames.contains(entry.name))
|
|
.toList(growable: false);
|
|
final multi = selectedEntries.length > 1;
|
|
final canExtract =
|
|
!multi &&
|
|
selectedEntries.length == 1 &&
|
|
!selectedEntries.first.isDirectory &&
|
|
SftpSessionManager.isCompressedFile(selectedEntries.first.name);
|
|
final showCompose =
|
|
!multi &&
|
|
onExecCommand != null &&
|
|
!anchor.isDirectory &&
|
|
_isDockerComposeFile(anchor.name);
|
|
final actions = <_SftpMenuAction>[
|
|
_SftpMenuAction(
|
|
Icons.download_outlined,
|
|
l.tr('sftp.action.download'),
|
|
_SftpMenuActionType.download,
|
|
),
|
|
_SftpMenuAction(
|
|
Icons.copy_outlined,
|
|
l.tr('sftp.action.copyTo'),
|
|
_SftpMenuActionType.copyTo,
|
|
),
|
|
_SftpMenuAction(
|
|
Icons.drive_file_move_outline,
|
|
l.tr('sftp.action.moveTo'),
|
|
_SftpMenuActionType.moveTo,
|
|
),
|
|
_SftpMenuAction(
|
|
Icons.archive_outlined,
|
|
l.tr('sftp.action.compress'),
|
|
_SftpMenuActionType.compress,
|
|
),
|
|
if (canExtract)
|
|
_SftpMenuAction(
|
|
Icons.unarchive_outlined,
|
|
l.tr('sftp.action.extract'),
|
|
_SftpMenuActionType.extract,
|
|
),
|
|
_SftpMenuAction(
|
|
Icons.delete_outline,
|
|
l.tr('sftp.action.delete'),
|
|
_SftpMenuActionType.delete,
|
|
destructive: true,
|
|
),
|
|
if (!multi)
|
|
_SftpMenuAction(
|
|
Icons.edit_outlined,
|
|
l.tr('sftp.action.rename'),
|
|
_SftpMenuActionType.rename,
|
|
),
|
|
if (!multi)
|
|
_SftpMenuAction(
|
|
Icons.link_outlined,
|
|
l.tr('sftp.action.copyPath'),
|
|
_SftpMenuActionType.copyPath,
|
|
),
|
|
];
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (sheetContext) => SafeArea(
|
|
top: false,
|
|
child: Container(
|
|
margin: const EdgeInsets.all(12),
|
|
padding: const EdgeInsets.all(6),
|
|
decoration: BoxDecoration(
|
|
color: context.colors.card,
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(color: context.colors.strongBorder),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (showCompose)
|
|
ListTile(
|
|
dense: true,
|
|
leading: Icon(
|
|
Icons.play_circle_outline,
|
|
color: context.colors.primary,
|
|
),
|
|
title: Text(
|
|
l.tr('sftp.compose.title'),
|
|
style: TextStyle(
|
|
color: context.colors.primary,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
trailing: Icon(
|
|
Icons.chevron_right_rounded,
|
|
size: 18,
|
|
color: context.colors.muted,
|
|
),
|
|
onTap: () {
|
|
Navigator.of(sheetContext).pop();
|
|
_showDockerComposeSheet(context, session, anchor);
|
|
},
|
|
),
|
|
if (showCompose)
|
|
Divider(
|
|
height: 1,
|
|
indent: 16,
|
|
endIndent: 16,
|
|
color: context.colors.border,
|
|
),
|
|
for (final action in actions)
|
|
ListTile(
|
|
dense: true,
|
|
leading: Icon(
|
|
action.icon,
|
|
color: action.destructive
|
|
? context.colors.danger
|
|
: context.colors.muted,
|
|
),
|
|
title: Text(
|
|
action.label,
|
|
style: TextStyle(
|
|
color: action.destructive
|
|
? context.colors.danger
|
|
: context.colors.text,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
onTap: () async {
|
|
Navigator.of(sheetContext).pop();
|
|
await _handleAction(
|
|
context,
|
|
action.type,
|
|
session,
|
|
selectedEntries,
|
|
anchor,
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showDockerComposeSheet(
|
|
BuildContext context,
|
|
SftpSessionState session,
|
|
SftpFileEntry anchor,
|
|
) {
|
|
final l = AppLocalizations.of(context);
|
|
final fullPath = SftpSessionManager.joinPath(
|
|
session.currentPath,
|
|
anchor.name,
|
|
);
|
|
final composeActions = <(IconData, String, String)>[
|
|
(
|
|
Icons.play_arrow_rounded,
|
|
l.tr('sftp.compose.up'),
|
|
'docker compose -f $fullPath up -d\n',
|
|
),
|
|
(
|
|
Icons.restart_alt_rounded,
|
|
l.tr('sftp.compose.restart'),
|
|
'docker compose -f $fullPath restart\n',
|
|
),
|
|
(
|
|
Icons.stop_rounded,
|
|
l.tr('sftp.compose.down'),
|
|
'docker compose -f $fullPath down\n',
|
|
),
|
|
(
|
|
Icons.cloud_download_outlined,
|
|
l.tr('sftp.compose.pull'),
|
|
'docker compose -f $fullPath pull\n',
|
|
),
|
|
(
|
|
Icons.build_outlined,
|
|
l.tr('sftp.compose.rebuild'),
|
|
'docker compose -f $fullPath up -d --force-recreate\n',
|
|
),
|
|
(
|
|
Icons.upgrade_rounded,
|
|
l.tr('sftp.compose.upgrade'),
|
|
'docker compose -f $fullPath pull && docker compose -f $fullPath down && docker compose -f $fullPath up -d\n',
|
|
),
|
|
(
|
|
Icons.article_outlined,
|
|
l.tr('sftp.compose.logs'),
|
|
'docker compose -f $fullPath logs --tail=500 -f\n',
|
|
),
|
|
];
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (sheetContext) => SafeArea(
|
|
top: false,
|
|
child: Container(
|
|
margin: const EdgeInsets.all(12),
|
|
padding: const EdgeInsets.all(6),
|
|
decoration: BoxDecoration(
|
|
color: context.colors.card,
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(color: context.colors.strongBorder),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.play_circle_outline,
|
|
size: 16,
|
|
color: context.colors.primary,
|
|
),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
l.tr('sftp.compose.title'),
|
|
style: TextStyle(
|
|
color: context.colors.primary,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
for (final (icon, label, command) in composeActions)
|
|
ListTile(
|
|
dense: true,
|
|
leading: Icon(icon, color: context.colors.muted),
|
|
title: Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: context.colors.text,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
onTap: () {
|
|
Navigator.of(sheetContext).pop();
|
|
if (!isPlusActive) {
|
|
showTopNotice(context, l.tr('plus.serverManagedTip'));
|
|
return;
|
|
}
|
|
onExecCommand!(command);
|
|
onCommandPanelClose?.call();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _handleAction(
|
|
BuildContext context,
|
|
_SftpMenuActionType type,
|
|
SftpSessionState session,
|
|
List<SftpFileEntry> entries,
|
|
SftpFileEntry anchor,
|
|
) async {
|
|
final l = AppLocalizations.of(context);
|
|
try {
|
|
switch (type) {
|
|
case _SftpMenuActionType.download:
|
|
await _downloadEntries(context, session, entries);
|
|
case _SftpMenuActionType.copyTo:
|
|
final target = await _chooseRemoteDirectory(
|
|
context,
|
|
session.currentPath,
|
|
);
|
|
if (target == null) return;
|
|
await manager.copyEntries(entries, target);
|
|
case _SftpMenuActionType.moveTo:
|
|
final target = await _chooseRemoteDirectory(
|
|
context,
|
|
session.currentPath,
|
|
);
|
|
if (target == null) return;
|
|
await manager.moveEntries(entries, target);
|
|
case _SftpMenuActionType.compress:
|
|
final defaultName = entries.length == 1
|
|
? '${entries.first.name}.zip'
|
|
: 'archive.zip';
|
|
final name = await _showTextInputDialog(
|
|
context,
|
|
title: l.tr('sftp.compressTitle'),
|
|
hint: l.tr('sftp.nameHint'),
|
|
initialValue: defaultName,
|
|
);
|
|
if (name == null || name.trim().isEmpty) return;
|
|
await manager.compressEntriesToCurrentDirectory(
|
|
entries,
|
|
zipName: name.trim(),
|
|
);
|
|
case _SftpMenuActionType.extract:
|
|
await _promptAndExtract(context, session, anchor);
|
|
case _SftpMenuActionType.delete:
|
|
final confirmed = await _showDeleteConfirm(context, entries.length);
|
|
if (!confirmed) return;
|
|
await manager.deleteEntries(entries);
|
|
case _SftpMenuActionType.rename:
|
|
final name = await _showTextInputDialog(
|
|
context,
|
|
title: l.tr('sftp.renameTitle'),
|
|
hint: l.tr('sftp.nameHint'),
|
|
initialValue: anchor.name,
|
|
);
|
|
if (name == null || name.trim().isEmpty) return;
|
|
await manager.renameEntry(anchor, name.trim());
|
|
case _SftpMenuActionType.copyPath:
|
|
await Clipboard.setData(
|
|
ClipboardData(
|
|
text: SftpSessionManager.joinPath(
|
|
session.currentPath,
|
|
anchor.name,
|
|
),
|
|
),
|
|
);
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(l.tr('sftp.pathCopied'))));
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(error.toString())));
|
|
}
|
|
}
|
|
|
|
Future<void> _downloadEntries(
|
|
BuildContext context,
|
|
SftpSessionState session,
|
|
List<SftpFileEntry> entries,
|
|
) async {
|
|
if (entries.isEmpty) return;
|
|
final l = AppLocalizations.of(context);
|
|
final downloadDir = Directory(
|
|
'${(await getApplicationDocumentsDirectory()).path}${Platform.pathSeparator}sftp-downloads',
|
|
);
|
|
await downloadDir.create(recursive: true);
|
|
late final File output;
|
|
if (entries.length == 1 && !entries.first.isDirectory) {
|
|
final entry = entries.first;
|
|
output = File(
|
|
'${downloadDir.path}${Platform.pathSeparator}${_safeFileName(entry.name)}',
|
|
);
|
|
final bytes = await manager.downloadFileBytes(
|
|
SftpSessionManager.joinPath(session.currentPath, entry.name),
|
|
);
|
|
await output.writeAsBytes(bytes, flush: true);
|
|
} else {
|
|
final name = entries.length == 1
|
|
? '${entries.first.name}.zip'
|
|
: 'sftp-${DateTime.now().millisecondsSinceEpoch}.zip';
|
|
output = File(
|
|
'${downloadDir.path}${Platform.pathSeparator}${_safeFileName(name)}',
|
|
);
|
|
final bytes = await manager.zipEntries(entries);
|
|
await output.writeAsBytes(bytes, flush: true);
|
|
}
|
|
await SharePlus.instance.share(
|
|
ShareParams(
|
|
files: [XFile(output.path)],
|
|
text: l.tr('sftp.downloadReady'),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<String?> _chooseRemoteDirectory(
|
|
BuildContext context,
|
|
String initialPath,
|
|
) {
|
|
return showModalBottomSheet<String>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (_) =>
|
|
_SftpDirectoryPickerSheet(manager: manager, initialPath: initialPath),
|
|
);
|
|
}
|
|
|
|
Future<bool> _showDeleteConfirm(BuildContext context, int count) async {
|
|
final l = AppLocalizations.of(context);
|
|
return await showDialog<bool>(
|
|
context: context,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: Text(l.tr('sftp.deleteTitle')),
|
|
content: Text(l.tr('sftp.deleteBody').replaceAll('{0}', '$count')),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
|
child: Text(l.tr('common.cancel')),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
|
child: Text(l.tr('common.delete')),
|
|
),
|
|
],
|
|
),
|
|
) ??
|
|
false;
|
|
}
|
|
|
|
Future<String?> _showTextInputDialog(
|
|
BuildContext context, {
|
|
required String title,
|
|
required String hint,
|
|
String initialValue = '',
|
|
}) {
|
|
return showDialog<String>(
|
|
context: context,
|
|
builder: (_) =>
|
|
_SftpNameDialog(title: title, hint: hint, initialValue: initialValue),
|
|
);
|
|
}
|
|
|
|
Future<void> _promptAndExtract(
|
|
BuildContext context,
|
|
SftpSessionState session,
|
|
SftpFileEntry entry,
|
|
) async {
|
|
final l = AppLocalizations.of(context);
|
|
final target = await _showTextInputDialog(
|
|
context,
|
|
title: l.tr('sftp.extractToTitle'),
|
|
hint: l.tr('sftp.extractPathHint'),
|
|
initialValue: session.currentPath,
|
|
);
|
|
if (target == null || target.trim().isEmpty) return;
|
|
try {
|
|
await manager.extractEntry(entry, destDir: target.trim());
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(l.tr('sftp.extractDone'))));
|
|
} catch (error) {
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(error.toString())));
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<bool> _openRemoteFile({
|
|
required BuildContext context,
|
|
required SftpSessionManager manager,
|
|
required String remotePath,
|
|
required SftpFileEntry entry,
|
|
required String directoryPath,
|
|
List<SftpFileEntry>? imageSiblings,
|
|
int initialImageIndex = 0,
|
|
}) async {
|
|
final Widget page;
|
|
var mayMutate = false;
|
|
if (isImageFileName(entry.name)) {
|
|
page = SftpImagePreviewPage(
|
|
manager: manager,
|
|
directoryPath: directoryPath,
|
|
images: imageSiblings ?? [entry],
|
|
initialIndex: imageSiblings == null ? 0 : initialImageIndex,
|
|
);
|
|
} else {
|
|
page = TextEditorPage(
|
|
manager: manager,
|
|
remotePath: remotePath,
|
|
fileName: entry.name,
|
|
);
|
|
mayMutate = true;
|
|
}
|
|
await Navigator.of(context).push(MaterialPageRoute(builder: (_) => page));
|
|
return mayMutate;
|
|
}
|
|
|
|
class _SftpMenuAction {
|
|
const _SftpMenuAction(
|
|
this.icon,
|
|
this.label,
|
|
this.type, {
|
|
this.destructive = false,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String label;
|
|
final _SftpMenuActionType type;
|
|
final bool destructive;
|
|
}
|
|
|
|
enum _SftpMenuActionType {
|
|
download,
|
|
copyTo,
|
|
moveTo,
|
|
compress,
|
|
extract,
|
|
delete,
|
|
rename,
|
|
copyPath,
|
|
}
|
|
|
|
class _SftpHeaderSelector extends StatelessWidget {
|
|
const _SftpHeaderSelector({
|
|
required this.session,
|
|
required this.onTap,
|
|
required this.onDisconnect,
|
|
});
|
|
|
|
final SftpSessionState session;
|
|
final VoidCallback onTap;
|
|
final VoidCallback? onDisconnect;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.colors;
|
|
final isError = session.status == SftpConnectionStatus.error;
|
|
final dotColor = isError ? c.danger : c.success;
|
|
final screenWidth = MediaQuery.sizeOf(context).width;
|
|
return ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
minWidth: screenWidth / 4,
|
|
maxWidth: screenWidth / 2,
|
|
),
|
|
child: Material(
|
|
color: c.card,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
side: BorderSide(color: c.strongBorder),
|
|
),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(8),
|
|
onTap: onTap,
|
|
child: SizedBox(
|
|
height: 34,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 6,
|
|
height: 6,
|
|
decoration: BoxDecoration(
|
|
color: dotColor,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: Text(
|
|
session.server.displayName,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: c.text,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
Icon(
|
|
Icons.keyboard_arrow_down_rounded,
|
|
size: 16,
|
|
color: c.muted,
|
|
),
|
|
if (onDisconnect != null) ...[
|
|
const SizedBox(width: 2),
|
|
InkResponse(
|
|
radius: 14,
|
|
onTap: onDisconnect,
|
|
child: Icon(
|
|
Icons.close_rounded,
|
|
size: 14,
|
|
color: c.muted,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SftpToolbar extends StatelessWidget {
|
|
const _SftpToolbar({required this.session, required this.manager});
|
|
|
|
final SftpSessionState session;
|
|
final SftpSessionManager manager;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l = AppLocalizations.of(context);
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 10),
|
|
child: Row(
|
|
children: [
|
|
_SftpToolbarButton(
|
|
label: l.tr('sftp.upload'),
|
|
onTap: () => _showUploadMenu(context),
|
|
),
|
|
const SizedBox(width: 8),
|
|
_SftpToolbarButton(
|
|
label: l.tr('sftp.newItem'),
|
|
onTap: () => _showCreateMenu(context),
|
|
),
|
|
const SizedBox(width: 8),
|
|
_SftpToolbarButton(
|
|
label: l.tr('sftp.favorites'),
|
|
enabled: session.favorites.isNotEmpty,
|
|
onTap: () => _showFavoritesMenu(context),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showUploadMenu(BuildContext context) {
|
|
final l = AppLocalizations.of(context);
|
|
_showOptionSheet(context, [
|
|
_SftpSheetAction(Icons.upload_file_outlined, l.tr('sftp.uploadFile'), () {
|
|
_uploadFiles(context);
|
|
}),
|
|
_SftpSheetAction(
|
|
Icons.create_new_folder_outlined,
|
|
l.tr('sftp.uploadFolder'),
|
|
() {
|
|
_uploadFolder(context);
|
|
},
|
|
),
|
|
]);
|
|
}
|
|
|
|
Future<void> _uploadFiles(BuildContext context) async {
|
|
try {
|
|
final result = await FilePicker.pickFiles(
|
|
allowMultiple: true,
|
|
withData: false,
|
|
);
|
|
final paths =
|
|
result?.paths.whereType<String>().toList(growable: false) ??
|
|
const <String>[];
|
|
if (paths.isEmpty) return;
|
|
await manager.uploadFiles(paths.map(File.new).toList(growable: false));
|
|
} catch (error) {
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(error.toString())));
|
|
}
|
|
}
|
|
|
|
Future<void> _uploadFolder(BuildContext context) async {
|
|
final l = AppLocalizations.of(context);
|
|
try {
|
|
final path = await FilePicker.getDirectoryPath();
|
|
if (path == null) return;
|
|
final directory = Directory(path);
|
|
if (!await directory.exists()) {
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(l.tr('sftp.folderUploadUnsupported'))),
|
|
);
|
|
return;
|
|
}
|
|
await manager.uploadDirectory(directory);
|
|
} catch (error) {
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(error.toString())));
|
|
}
|
|
}
|
|
|
|
void _showCreateMenu(BuildContext context) {
|
|
final l = AppLocalizations.of(context);
|
|
_showOptionSheet(context, [
|
|
_SftpSheetAction(
|
|
Icons.insert_drive_file_outlined,
|
|
l.tr('sftp.newFile'),
|
|
() {
|
|
_showNameDialog(context, type: 'file');
|
|
},
|
|
),
|
|
_SftpSheetAction(
|
|
Icons.create_new_folder_outlined,
|
|
l.tr('sftp.newFolder'),
|
|
() {
|
|
_showNameDialog(context, type: 'folder');
|
|
},
|
|
),
|
|
]);
|
|
}
|
|
|
|
void _showFavoritesMenu(BuildContext context) {
|
|
_showOptionSheet(context, [
|
|
for (final favorite in session.favorites)
|
|
_SftpSheetAction(
|
|
favorite.type == 'folder'
|
|
? Icons.folder_outlined
|
|
: Icons.insert_drive_file_outlined,
|
|
favorite.path,
|
|
() => _openFavorite(context, favorite),
|
|
),
|
|
]);
|
|
}
|
|
|
|
Future<void> _openFavorite(
|
|
BuildContext context,
|
|
SftpFavorite favorite,
|
|
) async {
|
|
if (favorite.type == 'folder') {
|
|
await manager.openPath(favorite.path);
|
|
return;
|
|
}
|
|
final parent = SftpSessionManager.parentPath(favorite.path);
|
|
final fileName = _favoriteBasename(favorite.path);
|
|
await manager.openPath(parent);
|
|
if (!context.mounted) return;
|
|
final entry = SftpFileEntry(
|
|
name: fileName,
|
|
type: '-',
|
|
size: null,
|
|
modifyTime: null,
|
|
permissions: '',
|
|
ownerName: '',
|
|
groupName: '',
|
|
);
|
|
final mutatedFile = await _openRemoteFile(
|
|
context: context,
|
|
manager: manager,
|
|
remotePath: favorite.path,
|
|
entry: entry,
|
|
directoryPath: parent,
|
|
);
|
|
if (!context.mounted) return;
|
|
if (mutatedFile) {
|
|
await manager.refreshActive(showLoading: false);
|
|
}
|
|
}
|
|
|
|
String _favoriteBasename(String path) {
|
|
final normalized = path.replaceAll('\\', '/');
|
|
final trimmed = normalized.endsWith('/') && normalized.length > 1
|
|
? normalized.substring(0, normalized.length - 1)
|
|
: normalized;
|
|
final index = trimmed.lastIndexOf('/');
|
|
return index < 0 ? trimmed : trimmed.substring(index + 1);
|
|
}
|
|
|
|
Future<void> _showNameDialog(
|
|
BuildContext context, {
|
|
required String type,
|
|
}) async {
|
|
final l = AppLocalizations.of(context);
|
|
final name = await showDialog<String>(
|
|
context: context,
|
|
builder: (_) => _SftpCreateDialog(
|
|
title: type == 'folder' ? l.tr('sftp.newFolder') : l.tr('sftp.newFile'),
|
|
hint: l.tr('sftp.nameHint'),
|
|
createType: type,
|
|
),
|
|
);
|
|
final trimmed = name?.trim();
|
|
if (trimmed == null || trimmed.isEmpty) return;
|
|
try {
|
|
if (type == 'folder') {
|
|
await manager.createDirectory(trimmed);
|
|
} else {
|
|
await manager.createFile(trimmed);
|
|
}
|
|
_SftpCreateDialog.cacheCreatedName(type, trimmed);
|
|
} catch (error) {
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(error.toString())));
|
|
}
|
|
}
|
|
|
|
void _showOptionSheet(BuildContext context, List<_SftpSheetAction> actions) {
|
|
final c = context.colors;
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (sheetContext) => SafeArea(
|
|
top: false,
|
|
child: Container(
|
|
margin: const EdgeInsets.all(12),
|
|
padding: const EdgeInsets.all(6),
|
|
decoration: BoxDecoration(
|
|
color: c.card,
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(color: c.strongBorder),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
for (final action in actions)
|
|
ListTile(
|
|
dense: true,
|
|
leading: Icon(action.icon, color: c.muted),
|
|
title: Text(
|
|
action.label,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: c.text,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
onTap: () {
|
|
Navigator.of(sheetContext).pop();
|
|
action.onTap();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SftpToolbarButton extends StatelessWidget {
|
|
const _SftpToolbarButton({
|
|
required this.label,
|
|
required this.onTap,
|
|
this.enabled = true,
|
|
});
|
|
|
|
final String label;
|
|
final VoidCallback onTap;
|
|
final bool enabled;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.colors;
|
|
return Expanded(
|
|
child: Material(
|
|
color: enabled ? c.card : c.chip,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
side: BorderSide(color: c.border),
|
|
),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(10),
|
|
onTap: enabled ? onTap : null,
|
|
child: SizedBox(
|
|
height: 34,
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: enabled ? c.text : c.softMuted,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Icon(
|
|
Icons.keyboard_arrow_down_rounded,
|
|
size: 14,
|
|
color: enabled ? c.muted : c.softMuted,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SftpSheetAction {
|
|
const _SftpSheetAction(this.icon, this.label, this.onTap);
|
|
|
|
final IconData icon;
|
|
final String label;
|
|
final VoidCallback onTap;
|
|
}
|
|
|
|
class _SftpNameDialog extends StatefulWidget {
|
|
const _SftpNameDialog({
|
|
required this.title,
|
|
required this.hint,
|
|
this.initialValue = '',
|
|
});
|
|
|
|
final String title;
|
|
final String hint;
|
|
final String initialValue;
|
|
|
|
@override
|
|
State<_SftpNameDialog> createState() => _SftpNameDialogState();
|
|
}
|
|
|
|
class _SftpNameDialogState extends State<_SftpNameDialog> {
|
|
late final TextEditingController _controller;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = TextEditingController(text: widget.initialValue);
|
|
_controller.selection = TextSelection(
|
|
baseOffset: 0,
|
|
extentOffset: widget.initialValue.length,
|
|
);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l = AppLocalizations.of(context);
|
|
final c = context.colors;
|
|
return Dialog(
|
|
backgroundColor: c.card,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
side: BorderSide(color: c.border),
|
|
),
|
|
insetPadding: const EdgeInsets.symmetric(horizontal: 6, vertical: 48),
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(18, 18, 18, 12),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Text(
|
|
widget.title,
|
|
style: TextStyle(
|
|
color: c.text,
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
TextField(
|
|
controller: _controller,
|
|
autofocus: true,
|
|
autocorrect: false,
|
|
enableSuggestions: false,
|
|
style: TextStyle(fontSize: 14, color: c.text),
|
|
cursorColor: c.accent,
|
|
decoration: InputDecoration(
|
|
hintText: widget.hint,
|
|
hintStyle: TextStyle(color: c.softMuted),
|
|
filled: true,
|
|
fillColor: c.canvas,
|
|
contentPadding: const EdgeInsets.all(12),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
borderSide: BorderSide(color: c.border),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
borderSide: BorderSide(color: c.border),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
borderSide: BorderSide(color: c.accent),
|
|
),
|
|
),
|
|
onSubmitted: (value) => Navigator.of(context).pop(value),
|
|
),
|
|
const SizedBox(height: 14),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
style: TextButton.styleFrom(foregroundColor: c.muted),
|
|
child: Text(l.tr('common.cancel')),
|
|
),
|
|
const SizedBox(width: 8),
|
|
FilledButton(
|
|
onPressed: () => Navigator.of(context).pop(_controller.text),
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: c.primary,
|
|
foregroundColor: c.fontOnPrimary,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
),
|
|
child: Text(l.tr('common.save')),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SftpCreateDialog extends StatefulWidget {
|
|
const _SftpCreateDialog({
|
|
required this.title,
|
|
required this.hint,
|
|
required this.createType,
|
|
});
|
|
|
|
final String title;
|
|
final String hint;
|
|
final String createType;
|
|
|
|
static const _defaultFiles = [
|
|
'docker-compose.yml',
|
|
'Dockerfile',
|
|
'.env',
|
|
'k8s-deployment.yaml',
|
|
'config.json',
|
|
'config.yaml',
|
|
'authorized_keys',
|
|
'access.log',
|
|
'error.log',
|
|
'shell.sh',
|
|
'README.md',
|
|
'LICENSE',
|
|
'notes.txt',
|
|
];
|
|
|
|
static const _defaultFolders = [
|
|
'easynode',
|
|
'docker',
|
|
'k8s',
|
|
'nginx',
|
|
'mysql',
|
|
'redis',
|
|
'kafka',
|
|
];
|
|
|
|
static const _fileCacheKey = 'sftp.createFileCache';
|
|
static const _folderCacheKey = 'sftp.createFolderCache';
|
|
|
|
static Future<void> cacheCreatedName(String type, String name) async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final key = type == 'folder' ? _folderCacheKey : _fileCacheKey;
|
|
final cached = prefs.getStringList(key) ?? [];
|
|
cached.remove(name);
|
|
cached.insert(0, name);
|
|
await prefs.setStringList(key, cached);
|
|
}
|
|
|
|
@override
|
|
State<_SftpCreateDialog> createState() => _SftpCreateDialogState();
|
|
}
|
|
|
|
class _SftpCreateDialogState extends State<_SftpCreateDialog> {
|
|
late final TextEditingController _controller;
|
|
List<String> _allSuggestions = [];
|
|
List<String> _filtered = [];
|
|
bool _showSuggestions = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = TextEditingController();
|
|
_loadSuggestions();
|
|
}
|
|
|
|
Future<void> _loadSuggestions() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final isFolder = widget.createType == 'folder';
|
|
final cacheKey = isFolder
|
|
? _SftpCreateDialog._folderCacheKey
|
|
: _SftpCreateDialog._fileCacheKey;
|
|
final defaults = isFolder
|
|
? _SftpCreateDialog._defaultFolders
|
|
: _SftpCreateDialog._defaultFiles;
|
|
final cached = prefs.getStringList(cacheKey) ?? [];
|
|
final merged = <String>[...cached];
|
|
for (final d in defaults) {
|
|
if (!merged.contains(d)) merged.add(d);
|
|
}
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_allSuggestions = merged;
|
|
_filtered = merged;
|
|
_showSuggestions = true;
|
|
});
|
|
}
|
|
|
|
void _onChanged(String value) {
|
|
if (value.isEmpty) {
|
|
setState(() {
|
|
_filtered = _allSuggestions;
|
|
_showSuggestions = true;
|
|
});
|
|
return;
|
|
}
|
|
final lower = value.toLowerCase();
|
|
setState(() {
|
|
_filtered = _allSuggestions
|
|
.where((s) => s.toLowerCase().contains(lower))
|
|
.toList();
|
|
_showSuggestions = _filtered.isNotEmpty;
|
|
});
|
|
}
|
|
|
|
void _selectSuggestion(String item) {
|
|
_controller.text = item;
|
|
_controller.selection = TextSelection.collapsed(offset: item.length);
|
|
setState(() => _showSuggestions = false);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l = AppLocalizations.of(context);
|
|
final c = context.colors;
|
|
return Dialog(
|
|
backgroundColor: c.card,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
side: BorderSide(color: c.border),
|
|
),
|
|
insetPadding: const EdgeInsets.symmetric(horizontal: 6, vertical: 48),
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(18, 18, 18, 12),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
widget.createType == 'folder'
|
|
? Icons.create_new_folder_outlined
|
|
: Icons.insert_drive_file_outlined,
|
|
color: c.primary,
|
|
size: 20,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
widget.title,
|
|
style: TextStyle(
|
|
color: c.text,
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 14),
|
|
TextField(
|
|
controller: _controller,
|
|
autofocus: true,
|
|
autocorrect: false,
|
|
enableSuggestions: false,
|
|
style: TextStyle(fontSize: 14, color: c.text),
|
|
cursorColor: c.accent,
|
|
decoration: InputDecoration(
|
|
hintText: widget.hint,
|
|
hintStyle: TextStyle(color: c.softMuted),
|
|
filled: true,
|
|
fillColor: c.canvas,
|
|
contentPadding: const EdgeInsets.all(12),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
borderSide: BorderSide(color: c.border),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
borderSide: BorderSide(color: c.border),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
borderSide: BorderSide(color: c.accent),
|
|
),
|
|
),
|
|
onChanged: _onChanged,
|
|
onSubmitted: (value) => Navigator.of(context).pop(value),
|
|
),
|
|
if (_showSuggestions && _filtered.isNotEmpty)
|
|
Container(
|
|
margin: const EdgeInsets.only(top: 4),
|
|
constraints: const BoxConstraints(maxHeight: 200),
|
|
decoration: BoxDecoration(
|
|
color: c.canvas,
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(color: c.border),
|
|
),
|
|
child: ListView.builder(
|
|
shrinkWrap: true,
|
|
padding: EdgeInsets.zero,
|
|
itemCount: _filtered.length,
|
|
itemBuilder: (context, index) {
|
|
final item = _filtered[index];
|
|
return InkWell(
|
|
onTap: () => _selectSuggestion(item),
|
|
borderRadius: index == 0
|
|
? const BorderRadius.vertical(
|
|
top: Radius.circular(10),
|
|
)
|
|
: index == _filtered.length - 1
|
|
? const BorderRadius.vertical(
|
|
bottom: Radius.circular(10),
|
|
)
|
|
: BorderRadius.zero,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 10,
|
|
),
|
|
child: Text(
|
|
item,
|
|
style: TextStyle(color: c.text, fontSize: 14),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
style: TextButton.styleFrom(foregroundColor: c.muted),
|
|
child: Text(l.tr('common.cancel')),
|
|
),
|
|
const SizedBox(width: 8),
|
|
FilledButton(
|
|
onPressed: () => Navigator.of(context).pop(_controller.text),
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: c.primary,
|
|
foregroundColor: c.fontOnPrimary,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
),
|
|
child: Text(l.tr('common.save')),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SftpDirectoryPickerSheet extends StatefulWidget {
|
|
const _SftpDirectoryPickerSheet({
|
|
required this.manager,
|
|
required this.initialPath,
|
|
});
|
|
|
|
final SftpSessionManager manager;
|
|
final String initialPath;
|
|
|
|
@override
|
|
State<_SftpDirectoryPickerSheet> createState() =>
|
|
_SftpDirectoryPickerSheetState();
|
|
}
|
|
|
|
class _SftpDirectoryPickerSheetState extends State<_SftpDirectoryPickerSheet> {
|
|
late String _path;
|
|
var _directories = const <SftpFileEntry>[];
|
|
var _loading = true;
|
|
String? _error;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_path = widget.initialPath;
|
|
_load();
|
|
}
|
|
|
|
Future<void> _load() async {
|
|
setState(() {
|
|
_loading = true;
|
|
_error = null;
|
|
});
|
|
try {
|
|
final directories = await widget.manager.listDirectories(_path);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_directories = directories;
|
|
_loading = false;
|
|
});
|
|
} catch (error) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_error = error.toString();
|
|
_loading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _open(String path) async {
|
|
_path = path;
|
|
await _load();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l = AppLocalizations.of(context);
|
|
final c = context.colors;
|
|
return SafeArea(
|
|
top: false,
|
|
child: DraggableScrollableSheet(
|
|
initialChildSize: 0.7,
|
|
minChildSize: 0.42,
|
|
maxChildSize: 0.9,
|
|
expand: false,
|
|
builder: (context, scrollController) => Container(
|
|
decoration: BoxDecoration(
|
|
color: c.canvas,
|
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
|
border: Border(top: BorderSide(color: c.border)),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
const SizedBox(height: 8),
|
|
Container(
|
|
width: 40,
|
|
height: 4,
|
|
decoration: BoxDecoration(
|
|
color: c.strongBorder,
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 14, 20, 10),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
l.tr('sftp.chooseTargetFolder'),
|
|
style: TextStyle(
|
|
color: c.text,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
),
|
|
_SftpSheetIconButton(
|
|
tooltip: l.tr('common.close'),
|
|
icon: Icons.close_rounded,
|
|
onTap: () => Navigator.of(context).pop(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
|
|
child: Row(
|
|
children: [
|
|
_SftpIconAction(
|
|
icon: Icons.home_outlined,
|
|
onTap: () => _open('/'),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
_path,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: c.muted,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
_SftpIconAction(
|
|
icon: Icons.chevron_left_rounded,
|
|
onTap: () => _open(SftpSessionManager.parentPath(_path)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: _loading
|
|
? Center(child: CircularProgressIndicator(color: c.primary))
|
|
: _error != null
|
|
? Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Text(
|
|
_error!,
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(color: c.muted),
|
|
),
|
|
),
|
|
)
|
|
: ListView(
|
|
controller: scrollController,
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
|
children: [
|
|
for (final directory in _directories)
|
|
ListTile(
|
|
leading: Icon(
|
|
Icons.folder_rounded,
|
|
color: c.accent,
|
|
),
|
|
title: Text(
|
|
directory.name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: c.text,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
onTap: () => _open(
|
|
SftpSessionManager.joinPath(
|
|
_path,
|
|
directory.name,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 10, 20, 16),
|
|
child: SizedBox(
|
|
width: double.infinity,
|
|
child: FilledButton(
|
|
onPressed: () => Navigator.of(context).pop(_path),
|
|
child: Text(l.tr('sftp.selectThisFolder')),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SftpPathBar extends StatelessWidget {
|
|
const _SftpPathBar({required this.session, required this.manager});
|
|
|
|
final SftpSessionState session;
|
|
final SftpSessionManager manager;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
|
|
child: SizedBox(
|
|
height: 36,
|
|
child: Row(
|
|
children: [
|
|
_SftpIconAction(
|
|
icon: Icons.chevron_left_rounded,
|
|
onTap: manager.goParent,
|
|
),
|
|
const SizedBox(width: 8),
|
|
InkResponse(
|
|
radius: 18,
|
|
onTap: () => manager.openPath('/'),
|
|
child: Icon(
|
|
Icons.home_outlined,
|
|
size: 16,
|
|
color: context.colors.muted,
|
|
),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: _SftpBreadcrumb(
|
|
path: session.currentPath,
|
|
manager: manager,
|
|
),
|
|
),
|
|
_SftpIconAction(
|
|
icon: Icons.refresh_rounded,
|
|
onTap: manager.refreshActive,
|
|
),
|
|
const SizedBox(width: 10),
|
|
_SftpIconAction(
|
|
icon: session.showHidden
|
|
? Icons.visibility_outlined
|
|
: Icons.visibility_off_outlined,
|
|
onTap: () => manager.setShowHidden(!session.showHidden),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SftpBreadcrumb extends StatelessWidget {
|
|
const _SftpBreadcrumb({required this.path, required this.manager});
|
|
|
|
final String path;
|
|
final SftpSessionManager manager;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.colors;
|
|
final segments = path
|
|
.split('/')
|
|
.where((segment) => segment.isNotEmpty)
|
|
.toList(growable: false);
|
|
if (segments.isEmpty) {
|
|
return Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: InkWell(
|
|
onTap: () => manager.openPath('/'),
|
|
child: Text(
|
|
'/',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: c.text,
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return ListView.separated(
|
|
scrollDirection: Axis.horizontal,
|
|
itemCount: segments.length,
|
|
separatorBuilder: (_, _) => Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 5),
|
|
child: Icon(
|
|
Icons.chevron_right_rounded,
|
|
size: 14,
|
|
color: c.softMuted,
|
|
),
|
|
),
|
|
),
|
|
itemBuilder: (context, index) {
|
|
final target = '/${segments.take(index + 1).join('/')}';
|
|
return Center(
|
|
child: InkWell(
|
|
onTap: () => manager.openPath(target),
|
|
child: Text(
|
|
segments[index],
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: c.text,
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SftpIconAction extends StatelessWidget {
|
|
const _SftpIconAction({required this.icon, required this.onTap});
|
|
|
|
final IconData icon;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return InkResponse(
|
|
radius: 20,
|
|
onTap: onTap,
|
|
child: Icon(icon, size: 18, color: context.colors.muted),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SftpTableHeader extends StatelessWidget {
|
|
const _SftpTableHeader({
|
|
required this.allSelected,
|
|
required this.onSelectAll,
|
|
});
|
|
|
|
final bool allSelected;
|
|
final VoidCallback onSelectAll;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.colors;
|
|
return Container(
|
|
height: 32,
|
|
margin: const EdgeInsets.symmetric(horizontal: 16),
|
|
decoration: BoxDecoration(
|
|
border: Border(bottom: BorderSide(color: c.border)),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 32,
|
|
child: Checkbox(
|
|
value: allSelected,
|
|
onChanged: (_) => onSelectAll(),
|
|
activeColor: c.primary,
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Text(
|
|
'名称',
|
|
style: TextStyle(
|
|
color: c.muted,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 64,
|
|
child: Text(
|
|
'大小',
|
|
textAlign: TextAlign.right,
|
|
style: TextStyle(
|
|
color: c.muted,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 58,
|
|
child: Text(
|
|
'时间',
|
|
textAlign: TextAlign.right,
|
|
style: TextStyle(
|
|
color: c.muted,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SftpFileRow extends StatelessWidget {
|
|
const _SftpFileRow({
|
|
required this.entry,
|
|
required this.selected,
|
|
required this.onTap,
|
|
required this.onLongPress,
|
|
required this.onSelectionChanged,
|
|
});
|
|
|
|
final SftpFileEntry entry;
|
|
final bool selected;
|
|
final VoidCallback onTap;
|
|
final VoidCallback onLongPress;
|
|
final VoidCallback onSelectionChanged;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.colors;
|
|
return Material(
|
|
color: selected ? c.banner : Colors.transparent,
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
onLongPress: onLongPress,
|
|
child: Container(
|
|
height: 48,
|
|
decoration: BoxDecoration(
|
|
border: Border(bottom: BorderSide(color: c.border)),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 32,
|
|
child: Checkbox(
|
|
value: selected,
|
|
onChanged: (_) => onSelectionChanged(),
|
|
activeColor: c.primary,
|
|
),
|
|
),
|
|
Icon(
|
|
entry.isDirectory
|
|
? Icons.folder_rounded
|
|
: entry.isLink
|
|
? Icons.link_rounded
|
|
: Icons.insert_drive_file_outlined,
|
|
size: 20,
|
|
color: entry.isDirectory ? c.accent : c.muted,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
entry.name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: c.text,
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 58,
|
|
child: Text(
|
|
entry.isDirectory ? '-' : _formatSize(entry.size),
|
|
maxLines: 1,
|
|
textAlign: TextAlign.right,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(color: c.softMuted, fontSize: 12),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
SizedBox(
|
|
width: 58,
|
|
child: Text(
|
|
_formatDate(entry.modifyTime),
|
|
maxLines: 1,
|
|
textAlign: TextAlign.right,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(color: c.softMuted, fontSize: 10),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
static String _formatSize(int? size) {
|
|
if (size == null) return '-';
|
|
const units = ['B', 'KB', 'MB', 'GB'];
|
|
var value = size.toDouble();
|
|
var unit = 0;
|
|
while (value >= 1024 && unit < units.length - 1) {
|
|
value /= 1024;
|
|
unit++;
|
|
}
|
|
return unit == 0
|
|
? '${value.toInt()} ${units[unit]}'
|
|
: '${value.toStringAsFixed(1)} ${units[unit]}';
|
|
}
|
|
|
|
static String _formatDate(DateTime? date) {
|
|
if (date == null) return '-';
|
|
final local = date.toLocal();
|
|
return '${local.month}/${local.day}';
|
|
}
|
|
}
|
|
|
|
class _SftpConnectingView extends StatelessWidget {
|
|
const _SftpConnectingView({required this.server, this.phase});
|
|
|
|
final ServerModel? server;
|
|
final String? phase;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l = AppLocalizations.of(context);
|
|
final phaseLabel = switch (phase) {
|
|
'transport' => l.tr('sftp.phase.transport'),
|
|
'channel' => l.tr('sftp.phase.channel'),
|
|
'listing' => l.tr('sftp.phase.listing'),
|
|
_ => l.tr('sftp.connecting'),
|
|
};
|
|
final c = context.colors;
|
|
return ListView(
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|
padding: const EdgeInsets.fromLTRB(16, 120, 16, 110),
|
|
children: [
|
|
Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
SizedBox(
|
|
width: 42,
|
|
height: 42,
|
|
child: CircularProgressIndicator(
|
|
color: c.primary,
|
|
strokeWidth: 3,
|
|
),
|
|
),
|
|
const SizedBox(height: 18),
|
|
Text(
|
|
phaseLabel,
|
|
style: TextStyle(
|
|
color: c.text,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
server?.displayName ?? '',
|
|
style: TextStyle(
|
|
color: c.softMuted,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SftpEmptyCard extends StatelessWidget {
|
|
const _SftpEmptyCard({required this.onChooseServer});
|
|
|
|
final VoidCallback onChooseServer;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l = AppLocalizations.of(context);
|
|
final c = context.colors;
|
|
return Container(
|
|
width: double.infinity,
|
|
constraints: const BoxConstraints(maxWidth: 420, minHeight: 348),
|
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const _SftpOrbitIcon(),
|
|
const SizedBox(height: 18),
|
|
SizedBox(
|
|
height: 24,
|
|
child: Text(
|
|
l.tr('sftp.emptyTitle'),
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
color: c.text,
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
SizedBox(
|
|
height: 40,
|
|
child: Text(
|
|
l.tr('sftp.emptyBody'),
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
color: c.softMuted,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
height: 1.5,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 18),
|
|
_SftpPrimaryButton(
|
|
icon: Icons.folder_outlined,
|
|
label: l.tr('sftp.chooseServer'),
|
|
trailing: Icons.keyboard_arrow_down_rounded,
|
|
onTap: onChooseServer,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SftpOrbitIcon extends StatelessWidget {
|
|
const _SftpOrbitIcon();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.colors;
|
|
return SizedBox(
|
|
width: 120,
|
|
height: 120,
|
|
child: Stack(
|
|
children: [
|
|
Positioned(
|
|
left: 6,
|
|
top: 14,
|
|
child: _SftpDot(size: 10, color: c.banner),
|
|
),
|
|
Positioned(
|
|
right: 12,
|
|
top: 8,
|
|
child: _SftpDot(size: 8, color: c.accent),
|
|
),
|
|
Positioned(
|
|
right: 4,
|
|
bottom: 12,
|
|
child: _SftpDot(size: 12, color: c.banner),
|
|
),
|
|
const Positioned(
|
|
left: 0,
|
|
bottom: 24,
|
|
child: _SftpDot(size: 6, color: Color(0xFFEFD9A2)),
|
|
),
|
|
Center(
|
|
child: Container(
|
|
width: 92,
|
|
height: 92,
|
|
decoration: BoxDecoration(
|
|
color: c.banner,
|
|
borderRadius: BorderRadius.circular(28),
|
|
border: Border.all(color: const Color(0x66E5B33A)),
|
|
),
|
|
child: Icon(Icons.folder_rounded, color: c.primary, size: 42),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SftpDot extends StatelessWidget {
|
|
const _SftpDot({required this.size, required this.color});
|
|
|
|
final double size;
|
|
final Color color;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: size,
|
|
height: size,
|
|
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SftpPrimaryButton extends StatelessWidget {
|
|
const _SftpPrimaryButton({
|
|
required this.icon,
|
|
required this.label,
|
|
required this.onTap,
|
|
this.trailing,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String label;
|
|
final IconData? trailing;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.colors;
|
|
return Material(
|
|
color: c.primary,
|
|
borderRadius: BorderRadius.circular(10),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(10),
|
|
onTap: onTap,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, size: 16, color: c.fontOnPrimary),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: c.fontOnPrimary,
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
if (trailing != null) ...[
|
|
const SizedBox(width: 8),
|
|
Icon(trailing, size: 16, color: c.fontOnPrimary),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SftpServerPickerSheet extends ConsumerStatefulWidget {
|
|
const _SftpServerPickerSheet({
|
|
required this.connectedServerIds,
|
|
required this.activeServerId,
|
|
required this.onRefresh,
|
|
});
|
|
|
|
final Set<String> connectedServerIds;
|
|
final String? activeServerId;
|
|
final Future<void> Function() onRefresh;
|
|
|
|
@override
|
|
ConsumerState<_SftpServerPickerSheet> createState() =>
|
|
_SftpServerPickerSheetState();
|
|
}
|
|
|
|
class _SftpServerPickerSheetState
|
|
extends ConsumerState<_SftpServerPickerSheet> {
|
|
final TextEditingController _searchCtrl = TextEditingController();
|
|
String _query = '';
|
|
|
|
@override
|
|
void dispose() {
|
|
_searchCtrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final hostsAsync = ref.watch(hostListProvider);
|
|
final l = AppLocalizations.of(context);
|
|
final c = context.colors;
|
|
|
|
return SafeArea(
|
|
top: false,
|
|
child: DraggableScrollableSheet(
|
|
initialChildSize: 0.64,
|
|
minChildSize: 0.38,
|
|
maxChildSize: 0.86,
|
|
expand: false,
|
|
builder: (context, scrollController) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: c.canvas,
|
|
borderRadius: const BorderRadius.vertical(
|
|
top: Radius.circular(20),
|
|
),
|
|
border: Border(top: BorderSide(color: c.border)),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
const SizedBox(height: 8),
|
|
Container(
|
|
width: 40,
|
|
height: 4,
|
|
decoration: BoxDecoration(
|
|
color: c.strongBorder,
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 14, 20, 12),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l.tr('sftp.sheetTitle'),
|
|
style: TextStyle(
|
|
color: c.text,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
const SizedBox(height: 3),
|
|
Text(
|
|
l.tr('sftp.sheetSubtitle'),
|
|
style: TextStyle(
|
|
color: c.softMuted,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
_SftpSheetIconButton(
|
|
tooltip: l.tr('common.close'),
|
|
icon: Icons.close_rounded,
|
|
onTap: () => Navigator.of(context).pop(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
|
|
child: SizedBox(
|
|
height: 42,
|
|
child: TextField(
|
|
controller: _searchCtrl,
|
|
cursorColor: c.primary,
|
|
style: TextStyle(color: c.text, fontSize: 14),
|
|
decoration: _sftpSearchDecoration(
|
|
context,
|
|
hintText: l.tr('sftp.searchHint'),
|
|
),
|
|
onChanged: (value) =>
|
|
setState(() => _query = value.trim().toLowerCase()),
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: hostsAsync.when(
|
|
loading: () => Center(
|
|
child: CircularProgressIndicator(color: c.primary),
|
|
),
|
|
error: (error, _) => ListView(
|
|
controller: scrollController,
|
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
|
|
children: [
|
|
Text(
|
|
error.toString(),
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(color: c.muted),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Center(
|
|
child: TextButton(
|
|
onPressed: widget.onRefresh,
|
|
child: Text(l.tr('common.retry')),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
data: (servers) => _buildServerList(
|
|
context,
|
|
scrollController,
|
|
_filterServers(servers),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildServerList(
|
|
BuildContext context,
|
|
ScrollController scrollController,
|
|
List<ServerModel> servers,
|
|
) {
|
|
final l = AppLocalizations.of(context);
|
|
if (servers.isEmpty) {
|
|
return ListView(
|
|
controller: scrollController,
|
|
padding: const EdgeInsets.fromLTRB(20, 36, 20, 24),
|
|
children: [
|
|
Text(
|
|
l.tr('servers.emptyFiltered'),
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(color: context.colors.muted),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
final connected = servers
|
|
.where((server) => widget.connectedServerIds.contains(server.id))
|
|
.toList(growable: false);
|
|
final disconnected = servers
|
|
.where((server) => !widget.connectedServerIds.contains(server.id))
|
|
.toList(growable: false);
|
|
|
|
return ListView(
|
|
controller: scrollController,
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
|
children: [
|
|
if (connected.isNotEmpty) ...[
|
|
_SftpSectionLabel(l.tr('sftp.connectedSection')),
|
|
for (final server in connected)
|
|
_SftpServerRow(
|
|
server: server,
|
|
connected: true,
|
|
active: server.id == widget.activeServerId,
|
|
onTap: () => Navigator.of(context).pop(server),
|
|
),
|
|
],
|
|
if (disconnected.isNotEmpty) ...[
|
|
_SftpSectionLabel(l.tr('sftp.disconnectedSection')),
|
|
for (final server in disconnected)
|
|
_SftpServerRow(
|
|
server: server,
|
|
connected: false,
|
|
active: false,
|
|
onTap: () => Navigator.of(context).pop(server),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
List<ServerModel> _filterServers(List<ServerModel> servers) {
|
|
if (_query.isEmpty) return servers;
|
|
return servers
|
|
.where((server) {
|
|
final haystack = [
|
|
server.displayName,
|
|
server.host,
|
|
server.username,
|
|
...server.tag,
|
|
].join(' ').toLowerCase();
|
|
return haystack.contains(_query);
|
|
})
|
|
.toList(growable: false);
|
|
}
|
|
}
|
|
|
|
class _SftpSectionLabel extends StatelessWidget {
|
|
const _SftpSectionLabel(this.label);
|
|
|
|
final String label;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(4, 10, 4, 6),
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: context.colors.softMuted,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SftpServerRow extends StatelessWidget {
|
|
const _SftpServerRow({
|
|
required this.server,
|
|
required this.connected,
|
|
required this.active,
|
|
required this.onTap,
|
|
});
|
|
|
|
final ServerModel server;
|
|
final bool connected;
|
|
final bool active;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l = AppLocalizations.of(context);
|
|
final c = context.colors;
|
|
if (connected) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
child: Material(
|
|
color: active ? c.accentSoft : c.card,
|
|
borderRadius: BorderRadius.circular(14),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(14),
|
|
onTap: onTap,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 38,
|
|
height: 38,
|
|
decoration: BoxDecoration(
|
|
color: c.banner,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Icon(
|
|
Icons.folder_outlined,
|
|
size: 20,
|
|
color: c.primary,
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
server.displayName,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: c.text,
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
const SizedBox(height: 3),
|
|
Text(
|
|
server.connectionLabel,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(color: c.softMuted, fontSize: 12),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (active)
|
|
Icon(Icons.check_rounded, color: c.success, size: 20),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
child: Material(
|
|
color: active || connected ? c.banner : c.card,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
side: BorderSide(color: c.border),
|
|
),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(10),
|
|
onTap: onTap,
|
|
child: SizedBox(
|
|
height: 64,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 38,
|
|
height: 38,
|
|
decoration: BoxDecoration(
|
|
color: connected ? c.card : c.chip,
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Icon(
|
|
Icons.folder_outlined,
|
|
size: 21,
|
|
color: connected ? c.primary : c.muted,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
server.displayName,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: c.text,
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
server.connectionLabel,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: c.softMuted,
|
|
fontSize: 12,
|
|
fontFamily: 'monospace',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
if (active)
|
|
Icon(Icons.check_rounded, color: c.success, size: 20)
|
|
else if (!connected)
|
|
_SftpStatusPill(
|
|
label: l.tr('sftp.statusDisconnected'),
|
|
connected: false,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SftpStatusPill extends StatelessWidget {
|
|
const _SftpStatusPill({required this.label, required this.connected});
|
|
|
|
final String label;
|
|
final bool connected;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.colors;
|
|
final foreground = connected ? c.success : c.muted;
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
|
|
decoration: BoxDecoration(
|
|
color: connected ? c.success.withValues(alpha: 0.15) : c.chip,
|
|
borderRadius: BorderRadius.circular(999),
|
|
),
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: foreground,
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SftpSheetIconButton extends StatelessWidget {
|
|
const _SftpSheetIconButton({
|
|
required this.tooltip,
|
|
required this.icon,
|
|
required this.onTap,
|
|
});
|
|
|
|
final String tooltip;
|
|
final IconData icon;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.colors;
|
|
return Tooltip(
|
|
message: tooltip,
|
|
child: Material(
|
|
color: c.chip,
|
|
borderRadius: BorderRadius.circular(999),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(999),
|
|
onTap: onTap,
|
|
child: SizedBox(
|
|
width: 32,
|
|
height: 32,
|
|
child: Icon(icon, color: c.muted, size: 18),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SftpMessageList extends StatelessWidget {
|
|
const _SftpMessageList({required this.message, this.action});
|
|
|
|
final String message;
|
|
final Widget? action;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListView(
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|
padding: const EdgeInsets.all(24),
|
|
children: [
|
|
const SizedBox(height: 56),
|
|
Center(
|
|
child: Text(
|
|
message,
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(color: context.colors.muted),
|
|
),
|
|
),
|
|
if (action != null) ...[
|
|
const SizedBox(height: 8),
|
|
Center(child: action),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
InputDecoration _sftpSearchDecoration(
|
|
BuildContext context, {
|
|
required String hintText,
|
|
}) {
|
|
final c = context.colors;
|
|
return InputDecoration(
|
|
hintText: hintText,
|
|
isDense: true,
|
|
filled: true,
|
|
fillColor: c.chip,
|
|
prefixIcon: Icon(Icons.search_rounded, size: 18, color: c.softMuted),
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
|
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),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _safeFileName(String name) {
|
|
return name.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_');
|
|
}
|