Files
app/lib/presentation/widgets/file_operation_dialogs.dart
T
2026-05-27 19:19:11 +08:00

1448 lines
50 KiB
Dart

import 'package:cloudreve4_flutter/data/models/file_model.dart';
import 'package:cloudreve4_flutter/services/share_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../providers/file_manager_provider.dart';
import 'folder_picker.dart';
import 'glassmorphism_container.dart';
import 'toast_helper.dart';
/// 文件操作对话框工具类
class FileOperationDialogs {
/// 显示创建文件夹对话框(毛玻璃风格)
static Future<void> showCreateDialog(
BuildContext context,
FileManagerProvider fileManager,
) async {
final controller = TextEditingController();
final confirmed = await showGeneralDialog<bool>(
context: context,
barrierDismissible: true,
barrierLabel: '创建文件夹',
barrierColor: Colors.black38,
transitionDuration: const Duration(milliseconds: 250),
transitionBuilder: (context, animation, secondaryAnimation, child) {
final scaleAnim = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
).drive(Tween(begin: 0.92, end: 1.0));
final fadeAnim = CurvedAnimation(
parent: animation,
curve: Curves.easeOut,
).drive(Tween(begin: 0.0, end: 1.0));
return ScaleTransition(
scale: scaleAnim,
child: FadeTransition(opacity: fadeAnim, child: child),
);
},
pageBuilder: (context, animation, secondaryAnimation) {
final screenWidth = MediaQuery.of(context).size.width;
final dialogWidth = screenWidth >= 600 ? 400.0 : screenWidth - 48.0;
return Center(
child: SizedBox(
width: dialogWidth,
child: GlassmorphismContainer(
borderRadius: 16,
sigmaX: 20,
sigmaY: 20,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Material(
color: Colors.transparent,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildDialogTitle(context, LucideIcons.folderPlus, '创建文件夹'),
Padding(
padding: const EdgeInsets.fromLTRB(24, 4, 24, 20),
child: TextField(
controller: controller,
decoration: InputDecoration(
hintText: '文件夹名称',
prefixIcon: const Icon(LucideIcons.folder, size: 20),
filled: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
autofocus: true,
onSubmitted: (_) => Navigator.of(context).pop(true),
),
),
_buildDialogActions(
context,
onCancel: () => Navigator.of(context).pop(false),
onConfirm: () => Navigator.of(context).pop(true),
confirmLabel: '创建',
),
],
),
),
),
),
),
);
},
);
if (confirmed == true && controller.text.isNotEmpty) {
final error = await fileManager.createFolder(controller.text);
if (error != null && context.mounted) {
ToastHelper.failure('创建文件夹失败: $error');
} else if (context.mounted) {
ToastHelper.success('文件夹创建成功');
}
}
}
/// 显示重命名对话框(毛玻璃风格)
static Future<void> showRenameDialog(
BuildContext context,
FileManagerProvider fileManager,
FileModel file,
) async {
final controller = TextEditingController(text: file.name);
final confirmed = await showGeneralDialog<bool>(
context: context,
barrierDismissible: true,
barrierLabel: '重命名',
barrierColor: Colors.black38,
transitionDuration: const Duration(milliseconds: 250),
transitionBuilder: (context, animation, secondaryAnimation, child) {
final scaleAnim = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
).drive(Tween(begin: 0.92, end: 1.0));
final fadeAnim = CurvedAnimation(
parent: animation,
curve: Curves.easeOut,
).drive(Tween(begin: 0.0, end: 1.0));
return ScaleTransition(
scale: scaleAnim,
child: FadeTransition(opacity: fadeAnim, child: child),
);
},
pageBuilder: (context, animation, secondaryAnimation) {
final screenWidth = MediaQuery.of(context).size.width;
final dialogWidth = screenWidth >= 600 ? 400.0 : screenWidth - 48.0;
return Center(
child: SizedBox(
width: dialogWidth,
child: GlassmorphismContainer(
borderRadius: 16,
sigmaX: 20,
sigmaY: 20,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Material(
color: Colors.transparent,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildDialogTitle(context, LucideIcons.pencil, '重命名'),
Padding(
padding: const EdgeInsets.fromLTRB(24, 4, 24, 20),
child: TextField(
controller: controller,
decoration: InputDecoration(
hintText: '新名称',
prefixIcon: const Icon(LucideIcons.edit3, size: 20),
filled: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
autofocus: true,
onSubmitted: (_) => Navigator.of(context).pop(true),
),
),
_buildDialogActions(
context,
onCancel: () => Navigator.of(context).pop(false),
onConfirm: () => Navigator.of(context).pop(true),
confirmLabel: '确定',
),
],
),
),
),
),
),
);
},
);
if (confirmed == true && controller.text.isNotEmpty) {
await fileManager.renameFile(file.path, controller.text);
}
}
/// 显示删除确认对话框(多个文件,毛玻璃风格)
static Future<void> showDeleteConfirmation(
BuildContext context,
FileManagerProvider fileManager,
List<String> filePaths,
) async {
final confirmed = await _showConfirmDialog(
context,
icon: LucideIcons.trash2,
title: '删除确认',
message: '确定删除这 ${filePaths.length} 个文件吗?',
confirmLabel: '删除',
isDestructive: true,
);
if (confirmed == true) {
final error = await fileManager.deleteSelectedFiles();
if (error != null && context.mounted) {
ToastHelper.failure('删除失败: $error');
} else if (context.mounted) {
ToastHelper.success('删除成功');
}
}
}
/// 显示删除确认对话框(单个文件,毛玻璃风格)
static Future<void> showDeleteSingleConfirmation(
BuildContext context,
FileManagerProvider fileManager,
FileModel file,
) async {
final confirmed = await _showConfirmDialog(
context,
icon: LucideIcons.trash2,
title: '删除确认',
message: '确定删除文件 "${file.name}" 吗?',
confirmLabel: '删除',
isDestructive: true,
);
if (confirmed == true) {
final error = await fileManager.deleteFile(file.path);
if (context.mounted) {
if (error != null) {
ToastHelper.failure('删除失败: $error');
} else {
ToastHelper.success('删除成功');
}
}
}
}
/// 显示移动/复制文件对话框
static Future<void> showMoveDialog(
BuildContext context,
FileManagerProvider fileManager,
FileModel file,
bool copy,
) async {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(copy ? '复制文件' : '移动文件'),
contentPadding: const EdgeInsets.fromLTRB(0, 16, 0, 0),
content: SizedBox(
width: MediaQuery.of(context).size.width * 0.95,
height: 400,
child: FolderPicker(
currentPath: fileManager.currentPath,
onFolderSelected: (selectedPath) async {
Navigator.of(dialogContext).pop();
final error = await fileManager.moveFiles(
[file.path],
selectedPath,
copy: copy,
);
if (context.mounted) {
if (error != null) {
ToastHelper.failure('${copy ? '复制' : '移动'}失败: $error');
} else {
ToastHelper.success(copy ? '复制成功' : '移动成功');
}
}
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('取消'),
),
],
),
);
}
/// 显示多选移动/复制文件对话框
static Future<void> showBatchMoveDialog(
BuildContext context,
FileManagerProvider fileManager,
List<String> uris,
bool copy,
) async {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(copy ? '复制 ${uris.length} 个文件' : '移动 ${uris.length} 个文件'),
contentPadding: const EdgeInsets.fromLTRB(0, 16, 0, 0),
content: SizedBox(
width: MediaQuery.of(context).size.width * 0.95,
height: 400,
child: FolderPicker(
currentPath: fileManager.currentPath,
onFolderSelected: (selectedPath) async {
Navigator.of(dialogContext).pop();
final error = await fileManager.moveFiles(
uris,
selectedPath,
copy: copy,
);
if (context.mounted) {
if (error != null) {
ToastHelper.failure('${copy ? '复制' : '移动'}失败: $error');
} else {
ToastHelper.success(copy ? '复制成功' : '移动成功');
}
}
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('取消'),
),
],
),
);
}
/// 显示创建分享对话框。
///
/// 贴近 Cloudreve V4 Web 端原生创建分享面板:
/// - 搜索框可添加内置集合、用户组、用户;
/// - 权限使用「查看 / 创建 / 修改 / 删除」四项;
/// - 颜色跟随当前应用主题。
static Future<void> showShareDialog(
BuildContext context,
FileModel file,
) async {
final service = ShareService();
final searchController = TextEditingController();
final passwordController = TextEditingController();
final expireDaysController = TextEditingController(text: '7');
final downloadLimitController = TextEditingController(text: '1');
final priceController = TextEditingController(text: '20');
var showAdvanced = false;
var isCreating = false;
var isSearching = false;
var searchOpen = false;
var groupsLoaded = false;
String? searchError;
var passwordProtected = false;
var timeoutExpire = false;
var downloadExpire = false;
var paidDownload = false;
var shareView = true;
var showReadme = file.isFolder;
var searchEntries = <_SharePermissionEntry>[];
var groups = <SharePrincipal>[];
var dialogAlive = true;
var searchGeneration = 0;
void safeUpdate(StateSetter update, VoidCallback change) {
if (!dialogAlive) return;
update(change);
}
void dismissShareDialog(BuildContext dialogContext, [_ShareCreationResult? result]) {
if (!dialogAlive) return;
dialogAlive = false;
searchGeneration++;
FocusManager.instance.primaryFocus?.unfocus();
Navigator.of(dialogContext).pop(result);
}
final entries = <_SharePermissionEntry>[
_SharePermissionEntry.regular(
id: 'anonymous',
title: '匿名访客',
subtitle: '无需登录即可访问',
icon: Icons.account_circle,
color: Colors.grey,
mask: _SharePermissionMask.read,
removable: true,
),
_SharePermissionEntry.regular(
id: 'everyone',
title: '其他所有人',
subtitle: '已登录用户',
icon: Icons.public,
color: Theme.of(context).colorScheme.primary,
mask: _SharePermissionMask.read,
removable: false,
),
];
List<_SharePermissionEntry> builtInEntries() {
return <_SharePermissionEntry>[
_SharePermissionEntry.builtin(
id: 'same_group',
title: '和我同一用户组',
subtitle: '当前用户组内成员',
icon: Icons.group_add,
color: Colors.green,
),
_SharePermissionEntry.builtin(
id: 'other_group',
title: '其他用户组',
subtitle: '其他已登录用户组',
icon: Icons.groups,
color: Colors.orange,
),
];
}
bool alreadyAdded(_SharePermissionEntry candidate) {
return entries.any((entry) => entry.kind == candidate.kind && entry.id == candidate.id);
}
Map<String, dynamic> buildPermissions() {
final permissions = <String, dynamic>{};
final userExplicit = <String, String>{};
final groupExplicit = <String, String>{};
for (final entry in entries) {
final encoded = entry.encodedPermission;
if (entry.kind == _SharePermissionKind.anonymous) {
permissions['anonymous'] = encoded;
} else if (entry.kind == _SharePermissionKind.everyone) {
permissions['everyone'] = encoded;
} else if (entry.kind == _SharePermissionKind.sameGroup) {
permissions['same_group'] = encoded;
} else if (entry.kind == _SharePermissionKind.otherGroup) {
permissions['other_group'] = encoded;
} else if (entry.kind == _SharePermissionKind.user) {
userExplicit[entry.id] = encoded;
} else if (entry.kind == _SharePermissionKind.group) {
groupExplicit[entry.id] = encoded;
}
}
if (userExplicit.isNotEmpty) permissions['user_explicit'] = userExplicit;
if (groupExplicit.isNotEmpty) permissions['group_explicit'] = groupExplicit;
return permissions;
}
int? parsePositiveInt(TextEditingController controller) {
final value = int.tryParse(controller.text.trim());
if (value == null || value <= 0) return null;
return value;
}
void closeSearchPanel(StateSetter update) {
searchGeneration++;
FocusManager.instance.primaryFocus?.unfocus();
if (searchOpen || searchEntries.isNotEmpty || searchError != null) {
safeUpdate(update, () {
searchOpen = false;
searchEntries = [];
searchError = null;
isSearching = false;
});
}
}
void addEntry(_SharePermissionEntry candidate, StateSetter update) {
if (!dialogAlive) return;
if (alreadyAdded(candidate)) {
ToastHelper.info('已添加过 ${candidate.title}');
closeSearchPanel(update);
return;
}
searchGeneration++;
safeUpdate(update, () {
entries.add(candidate.copyForEntry());
searchController.clear();
searchEntries = [];
searchOpen = false;
searchError = null;
isSearching = false;
});
FocusManager.instance.primaryFocus?.unfocus();
}
Future<void> performSearch(StateSetter update) async {
if (!dialogAlive) return;
final currentGeneration = ++searchGeneration;
final keyword = searchController.text.trim().toLowerCase();
safeUpdate(update, () {
isSearching = true;
searchOpen = true;
searchError = null;
});
try {
final usersFuture = keyword.isEmpty
? Future<List<SharePrincipal>>.value(const [])
: service.searchUsers(keyword);
List<SharePrincipal>? groupsResult;
if (groupsLoaded) {
groupsResult = groups;
} else {
groupsResult = await service.listGroups();
if (groupsResult == null) {
groupsLoaded = true;
ToastHelper.info('用户组功能为 Pro 专属,当前站点不可用');
}
}
final users = await usersFuture;
if (!dialogAlive || currentGeneration != searchGeneration) return;
final loadedGroups = groupsResult ?? const <SharePrincipal>[];
final builtIns = builtInEntries()
.where((item) => keyword.isEmpty || item.title.toLowerCase().contains(keyword))
.where((item) => !alreadyAdded(item))
.toList();
final filteredGroups = loadedGroups
.where((g) => keyword.isEmpty || g.name.toLowerCase().contains(keyword))
.map(_SharePermissionEntry.principal)
.where((item) => !alreadyAdded(item))
.toList();
final filteredUsers = users
.map(_SharePermissionEntry.principal)
.where((item) => !alreadyAdded(item))
.toList();
safeUpdate(update, () {
searchEntries = <_SharePermissionEntry>[
...builtIns,
...filteredGroups,
...filteredUsers,
];
groups = loadedGroups;
groupsLoaded = true;
isSearching = false;
searchError = searchEntries.isEmpty && keyword.isNotEmpty ? '没有找到用户或用户组' : null;
});
} catch (e) {
if (!dialogAlive || currentGeneration != searchGeneration) return;
safeUpdate(update, () {
searchEntries = [];
isSearching = false;
searchError = e.toString();
});
}
}
Future<void> createShare(StateSetter update, BuildContext dialogContext) async {
if (isCreating) return;
final expireDays = timeoutExpire ? parsePositiveInt(expireDaysController) : null;
final downloads = downloadExpire ? parsePositiveInt(downloadLimitController) : null;
final price = paidDownload ? parsePositiveInt(priceController) : null;
if (timeoutExpire && expireDays == null) {
ToastHelper.failure('请输入有效的过期天数');
return;
}
if (downloadExpire && downloads == null) {
ToastHelper.failure('请输入有效的下载次数');
return;
}
if (paidDownload && price == null) {
ToastHelper.failure('请输入有效的付费金额');
return;
}
safeUpdate(update, () => isCreating = true);
try {
final shareUrl = await service.createShare(
uri: file.path,
permissions: buildPermissions(),
isPrivate: passwordProtected,
password: passwordProtected && passwordController.text.trim().isNotEmpty
? passwordController.text.trim()
: null,
shareView: shareView,
expire: expireDays == null ? null : expireDays * 24 * 60 * 60,
downloads: downloads,
price: price,
showReadme: showReadme,
);
if (dialogContext.mounted) {
dismissShareDialog(dialogContext, _ShareCreationResult(shareUrl));
}
} catch (e) {
safeUpdate(update, () => isCreating = false);
if (context.mounted) {
ToastHelper.failure('分享创建失败: $e');
}
}
}
Future<void> editPermission(_SharePermissionEntry entry, StateSetter update) async {
var mask = entry.mask | _SharePermissionMask.read;
final result = await showModalBottomSheet<int>(
context: context,
useSafeArea: true,
showDragHandle: true,
builder: (sheetContext) {
final theme = Theme.of(sheetContext);
return StatefulBuilder(
builder: (sheetContext, sheetUpdate) {
Widget option({
required int bit,
required String title,
required String subtitle,
bool locked = false,
}) {
final checked = (mask & bit) != 0;
return CheckboxListTile(
value: checked,
onChanged: locked
? null
: (value) {
sheetUpdate(() {
if (value == true) {
mask |= bit;
} else {
mask &= ~bit;
}
mask |= _SharePermissionMask.read;
});
},
controlAffinity: ListTileControlAffinity.leading,
activeColor: theme.colorScheme.primary,
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w700)),
subtitle: Text(subtitle),
);
}
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('权限:${entry.title}', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w900)),
const SizedBox(height: 8),
option(
bit: _SharePermissionMask.read,
title: '查看',
subtitle: '对于文件,可查看其内容、元数据等详细信息;对于目录,可查看其下的子文件列表及其元数据。',
locked: true,
),
option(
bit: _SharePermissionMask.create,
title: '创建',
subtitle: '只对目录有效,可在其下创建或上传新文件,可将文件移动或复制到其下。',
),
option(
bit: _SharePermissionMask.update,
title: '修改',
subtitle: '可修改元数据、重命名对象、查看活动记录;对于文件,可更新其内容。',
),
option(
bit: _SharePermissionMask.delete,
title: '删除',
subtitle: '可删除对象或将其移动到别处。',
),
const SizedBox(height: 8),
FilledButton(
onPressed: () => Navigator.of(sheetContext).pop(mask),
child: const Text('完成'),
),
],
),
),
);
},
);
},
);
if (result != null) {
update(() => entry.mask = result | _SharePermissionMask.read);
}
}
Widget buildPermissionButton(_SharePermissionEntry entry, StateSetter update) {
final theme = Theme.of(context);
return InkWell(
borderRadius: BorderRadius.circular(999),
onTap: () => editPermission(entry, update),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
entry.permissionLabel,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w800,
),
),
const SizedBox(width: 2),
Icon(Icons.arrow_drop_down, size: 16, color: theme.colorScheme.primary),
],
),
),
);
}
Widget buildPermissionRow(_SharePermissionEntry entry, StateSetter update) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
CircleAvatar(
radius: 22,
backgroundColor: entry.color.withValues(alpha: isDark ? 0.2 : 0.15),
child: Icon(entry.icon, color: entry.color, size: 22),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
),
const SizedBox(height: 4),
Row(
children: [
Flexible(
child: Text(
entry.subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: Colors.grey.shade600, fontSize: 12),
),
),
const SizedBox(width: 8),
buildPermissionButton(entry, update),
],
),
],
),
),
if (entry.removable)
IconButton(
icon: const Icon(Icons.close, size: 24),
color: Colors.grey.shade600,
onPressed: () => update(() => entries.remove(entry)),
),
],
),
);
}
Widget buildSearchBox(StateSetter update) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return Column(
children: [
Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(16),
),
child: TextField(
controller: searchController,
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: '搜索用户或用户组...',
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16),
suffixIcon: isSearching
? const Padding(
padding: EdgeInsets.all(14),
child: SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: IconButton(
icon: Icon(searchOpen ? Icons.arrow_drop_up : Icons.arrow_drop_down),
onPressed: () {
if (searchOpen) {
closeSearchPanel(update);
} else {
performSearch(update);
}
},
),
),
onTap: () => performSearch(update),
onChanged: (_) => performSearch(update),
onSubmitted: (_) => performSearch(update),
),
),
if (searchError != null) ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: Text(searchError!, style: TextStyle(color: Colors.grey.shade600, fontSize: 12)),
),
],
if (searchOpen && searchEntries.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
constraints: const BoxConstraints(maxHeight: 260),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: theme.dividerColor.withValues(alpha: 0.3)),
),
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 6),
itemCount: searchEntries.length,
separatorBuilder: (_, _) => Divider(height: 1, color: theme.dividerColor.withValues(alpha: 0.2)),
itemBuilder: (context, index) {
final item = searchEntries[index];
return ListTile(
dense: true,
leading: CircleAvatar(
backgroundColor: item.color.withValues(alpha: isDark ? 0.2 : 0.15),
child: Icon(item.icon, color: item.color),
),
title: Text(item.title, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
item.searchSubtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: const Icon(Icons.add_circle_outline),
onTap: () => addEntry(item, update),
);
},
),
),
],
],
);
}
Widget buildGeneralPage(StateSetter update) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildSearchBox(update),
GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => closeSearchPanel(update),
child: Padding(
padding: const EdgeInsets.only(top: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('常规访问权限', style: TextStyle(fontWeight: FontWeight.w800, fontSize: 14)),
const SizedBox(height: 10),
...entries.map((entry) => buildPermissionRow(entry, update)),
],
),
),
),
],
);
}
Widget buildAdvancedSwitch({
required IconData icon,
required String title,
required bool value,
required ValueChanged<bool> onChanged,
Widget? child,
}) {
return Column(
children: [
InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => onChanged(!value),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
children: [
SizedBox(width: 42, child: Icon(icon, color: Colors.grey.shade600, size: 24)),
Expanded(
child: Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
),
Checkbox(value: value, onChanged: (checked) => onChanged(checked ?? false)),
],
),
),
),
if (value && child != null)
Padding(
padding: const EdgeInsets.fromLTRB(42, 0, 0, 12),
child: child,
),
],
);
}
Widget buildCompactField({
required TextEditingController controller,
required String hint,
String? suffix,
bool obscure = false,
}) {
return TextField(
controller: controller,
obscureText: obscure,
keyboardType: suffix == null ? TextInputType.text : TextInputType.number,
decoration: InputDecoration(
isDense: true,
hintText: hint,
suffixText: suffix,
filled: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
);
}
Widget buildAdvancedPage(StateSetter update) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
buildAdvancedSwitch(
icon: Icons.remove_red_eye_outlined,
title: '使用密码保护链接',
value: passwordProtected,
onChanged: (value) => update(() => passwordProtected = value),
child: buildCompactField(
controller: passwordController,
hint: '分享密码,留空则自动生成',
obscure: false,
),
),
buildAdvancedSwitch(
icon: Icons.timer_outlined,
title: '超时自动过期',
value: timeoutExpire,
onChanged: (value) => update(() => timeoutExpire = value),
child: buildCompactField(
controller: expireDaysController,
hint: '有效期',
suffix: '',
),
),
buildAdvancedSwitch(
icon: Icons.download_outlined,
title: '下载后自动过期',
value: downloadExpire,
onChanged: (value) => update(() => downloadExpire = value),
child: buildCompactField(
controller: downloadLimitController,
hint: '下载次数',
suffix: '',
),
),
buildAdvancedSwitch(
icon: Icons.account_balance_wallet_outlined,
title: '付费下载',
value: paidDownload,
onChanged: (value) => update(() => paidDownload = value),
child: buildCompactField(
controller: priceController,
hint: '价格',
suffix: '积分',
),
),
SwitchListTile(
contentPadding: const EdgeInsets.only(left: 42, right: 0),
title: const Text('启用分享视图'),
subtitle: const Text('允许使用分享页面预览文件'),
value: shareView,
onChanged: (value) => update(() => shareView = value),
),
if (file.isFolder)
SwitchListTile(
contentPadding: const EdgeInsets.only(left: 42, right: 0),
title: const Text('显示 README'),
subtitle: const Text('文件夹分享中展示说明文件'),
value: showReadme,
onChanged: (value) => update(() => showReadme = value),
),
],
);
}
Widget buildFooter(StateSetter update, BuildContext dialogContext) {
final theme = Theme.of(dialogContext);
return Padding(
padding: const EdgeInsets.fromLTRB(18, 8, 18, 18),
child: Row(
children: [
IconButton(
icon: Icon(showAdvanced ? Icons.arrow_back : Icons.settings),
color: theme.colorScheme.primary,
onPressed: isCreating ? null : () => update(() => showAdvanced = !showAdvanced),
),
const Spacer(),
TextButton(
onPressed: isCreating ? null : () => dismissShareDialog(dialogContext),
child: const Text('取消', style: TextStyle(fontWeight: FontWeight.w700)),
),
const SizedBox(width: 10),
FilledButton(
onPressed: isCreating ? null : () => createShare(update, dialogContext),
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 12),
),
child: isCreating
? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('确定', style: TextStyle(fontWeight: FontWeight.w800)),
),
],
),
);
}
final result = await showGeneralDialog<_ShareCreationResult>(
context: context,
barrierDismissible: false,
barrierLabel: '创建分享链接',
barrierColor: Colors.black45,
transitionDuration: const Duration(milliseconds: 220),
transitionBuilder: (context, animation, secondaryAnimation, child) {
final curved = CurvedAnimation(parent: animation, curve: Curves.easeOutCubic);
return FadeTransition(
opacity: curved,
child: ScaleTransition(
scale: Tween<double>(begin: 0.96, end: 1).animate(curved),
child: child,
),
);
},
pageBuilder: (dialogContext, animation, secondaryAnimation) {
final screenWidth = MediaQuery.of(dialogContext).size.width;
final screenHeight = MediaQuery.of(dialogContext).size.height;
final dialogWidth = screenWidth >= 720 ? 640.0 : screenWidth - 16.0;
final maxHeight = screenHeight * 0.88;
return PopScope(
canPop: true,
onPopInvokedWithResult: (didPop, _) {
if (!didPop && dialogAlive) {
dialogAlive = false;
searchGeneration++;
FocusManager.instance.primaryFocus?.unfocus();
}
},
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: dialogWidth, maxHeight: maxHeight),
child: Material(
color: Theme.of(dialogContext).colorScheme.surface,
elevation: 24,
shadowColor: Colors.black26,
borderRadius: BorderRadius.circular(18),
clipBehavior: Clip.antiAlias,
child: StatefulBuilder(
builder: (dialogContext, update) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(28, 20, 18, 12),
child: Row(
children: [
const Expanded(
child: Text(
'创建分享链接',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w900),
),
),
IconButton(
icon: const Icon(Icons.close, size: 28),
onPressed: isCreating ? null : () => dismissShareDialog(dialogContext),
),
],
),
),
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(28, 12, 28, 8),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
child: showAdvanced
? KeyedSubtree(key: const ValueKey('advanced'), child: buildAdvancedPage(update))
: KeyedSubtree(key: const ValueKey('general'), child: buildGeneralPage(update)),
),
),
),
buildFooter(update, dialogContext),
],
);
},
),
),
),
),
);
},
);
dialogAlive = false;
searchGeneration++;
FocusManager.instance.primaryFocus?.unfocus();
await Future<void>.delayed(const Duration(milliseconds: 200));
passwordController.dispose();
expireDaysController.dispose();
downloadLimitController.dispose();
priceController.dispose();
searchController.dispose();
if (result == null || !context.mounted) return;
ToastHelper.success('分享创建成功');
await showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('分享链接'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(result.url, style: const TextStyle(fontSize: 12)),
const SizedBox(height: 16),
FilledButton.icon(
icon: const Icon(Icons.copy, size: 16),
label: const Text('复制到剪贴板'),
onPressed: () {
Clipboard.setData(ClipboardData(text: result.url));
Navigator.of(dialogContext).pop();
ToastHelper.success('已复制到剪贴板');
},
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('关闭'),
),
],
),
);
}
// ─── 内部工具方法 ───
static Widget _buildDialogTitle(BuildContext context, IconData icon, String title) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 8, 12),
child: Row(
children: [
Icon(icon, size: 20, color: colorScheme.primary),
const SizedBox(width: 10),
Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const Spacer(),
IconButton(
icon: const Icon(LucideIcons.x, size: 20),
onPressed: () => Navigator.of(context).pop(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
],
),
);
}
static Widget _buildDialogActions(
BuildContext context, {
required VoidCallback onCancel,
required VoidCallback onConfirm,
required String confirmLabel,
bool isDestructive = false,
}) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: onCancel,
style: TextButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
),
child: const Text('取消'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: onConfirm,
style: FilledButton.styleFrom(
backgroundColor: isDestructive ? colorScheme.error : null,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
),
child: Text(confirmLabel),
),
],
),
);
}
static Future<bool?> _showConfirmDialog(
BuildContext context, {
required IconData icon,
required String title,
required String message,
required String confirmLabel,
bool isDestructive = false,
}) {
return showGeneralDialog<bool>(
context: context,
barrierDismissible: true,
barrierLabel: title,
barrierColor: Colors.black38,
transitionDuration: const Duration(milliseconds: 250),
transitionBuilder: (context, animation, secondaryAnimation, child) {
final scaleAnim = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
).drive(Tween(begin: 0.92, end: 1.0));
final fadeAnim = CurvedAnimation(
parent: animation,
curve: Curves.easeOut,
).drive(Tween(begin: 0.0, end: 1.0));
return ScaleTransition(
scale: scaleAnim,
child: FadeTransition(opacity: fadeAnim, child: child),
);
},
pageBuilder: (context, animation, secondaryAnimation) {
final screenWidth = MediaQuery.of(context).size.width;
final dialogWidth = screenWidth >= 600 ? 400.0 : screenWidth - 48.0;
final theme = Theme.of(context);
return Center(
child: SizedBox(
width: dialogWidth,
child: GlassmorphismContainer(
borderRadius: 16,
sigmaX: 20,
sigmaY: 20,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Material(
color: Colors.transparent,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildDialogTitle(context, icon, title),
Padding(
padding: const EdgeInsets.fromLTRB(24, 4, 24, 20),
child: Text(message, style: theme.textTheme.bodyMedium),
),
_buildDialogActions(
context,
onCancel: () => Navigator.of(context).pop(false),
onConfirm: () => Navigator.of(context).pop(true),
confirmLabel: confirmLabel,
isDestructive: isDestructive,
),
],
),
),
),
),
),
);
},
);
}
}
enum _SharePermissionKind { anonymous, everyone, sameGroup, otherGroup, user, group }
class _SharePermissionMask {
static const int read = 1;
static const int create = 2;
static const int update = 4;
static const int delete = 8;
static String encode(int mask) {
final normalized = ((mask | read).clamp(1, 15));
const values = <int, String>{
1: 'AQ==',
2: 'Ag==',
3: 'Aw==',
4: 'BA==',
5: 'BQ==',
6: 'Bg==',
7: 'Bw==',
8: 'CA==',
9: 'CQ==',
10: 'Cg==',
11: 'Cw==',
12: 'DA==',
13: 'DQ==',
14: 'Dg==',
15: 'Dw==',
};
return values[normalized] ?? 'AQ==';
}
static String label(int mask) {
final parts = <String>['查看'];
if ((mask & create) != 0) parts.add('创建');
if ((mask & update) != 0) parts.add('修改');
if ((mask & delete) != 0) parts.add('删除');
return parts.join('');
}
}
class _SharePermissionEntry {
final String id;
final String title;
final String subtitle;
final IconData icon;
final Color color;
final _SharePermissionKind kind;
final bool removable;
int mask;
_SharePermissionEntry({
required this.id,
required this.title,
required this.subtitle,
required this.icon,
required this.color,
required this.kind,
required this.mask,
required this.removable,
});
String get encodedPermission => _SharePermissionMask.encode(mask);
String get permissionLabel => _SharePermissionMask.label(mask);
String get searchSubtitle {
if (kind == _SharePermissionKind.sameGroup || kind == _SharePermissionKind.otherGroup) {
return '内置集合';
}
if (kind == _SharePermissionKind.group) return '用户组';
if (kind == _SharePermissionKind.user) return subtitle;
return subtitle;
}
factory _SharePermissionEntry.regular({
required String id,
required String title,
required String subtitle,
required IconData icon,
required Color color,
required int mask,
required bool removable,
}) {
return _SharePermissionEntry(
id: id,
title: title,
subtitle: subtitle,
icon: icon,
color: color,
kind: id == 'anonymous'
? _SharePermissionKind.anonymous
: _SharePermissionKind.everyone,
mask: mask | _SharePermissionMask.read,
removable: removable,
);
}
factory _SharePermissionEntry.builtin({
required String id,
required String title,
required String subtitle,
required IconData icon,
required Color color,
}) {
return _SharePermissionEntry(
id: id,
title: title,
subtitle: subtitle,
icon: icon,
color: color,
kind: id == 'same_group'
? _SharePermissionKind.sameGroup
: _SharePermissionKind.otherGroup,
mask: _SharePermissionMask.read,
removable: true,
);
}
factory _SharePermissionEntry.principal(SharePrincipal principal) {
final isGroup = principal.type == SharePrincipalType.group;
return _SharePermissionEntry(
id: principal.id,
title: principal.name,
subtitle: isGroup ? '用户组' : (principal.email ?? principal.groupName ?? '用户'),
icon: isGroup ? Icons.group : Icons.person,
color: isGroup ? Colors.deepPurple : Colors.blue,
kind: isGroup ? _SharePermissionKind.group : _SharePermissionKind.user,
mask: _SharePermissionMask.read,
removable: true,
);
}
_SharePermissionEntry copyForEntry() {
return _SharePermissionEntry(
id: id,
title: title,
subtitle: subtitle,
icon: icon,
color: color,
kind: kind,
mask: mask | _SharePermissionMask.read,
removable: removable,
);
}
}
class _ShareCreationResult {
final String url;
const _ShareCreationResult(this.url);
}