Files
gongyun b02daf1448 主要合入内容:
- 文件管理器分页加载、排序偏好、桌面表头排序、移动端排序菜单、Ctrl/Cmd+F搜索、Esc 关闭搜索、鼠标右滑返回上级。
- 移动端同步状态页、同步统计卡片、同步任务/累计统计事件、同步页布局更新。
- Android 相册同步默认路径改为 DCIM/Camera,下载路径改用 external_path。
- Rust sync-core 更新:Android 日志依赖、AlbumUpload/AlbumDownload、MirrorWcf 统计修复、Linux FUSE镜像挂载与读写同步支持。

- 依赖更新:external_path、fl_chart、pdfrx 升级,并修了 pdfrx 新版本的deprecated API。
- 新增文件包括:sort_options.dart、sync_page_android.dart、sync_stats_card.dart、platform/fuse.rs、sync_engine/fuse.rs。
2026-06-04 07:11:43 +08:00

481 lines
14 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
import '../../core/utils/date_utils.dart' as date_utils;
import '../../core/utils/file_icon_utils.dart';
import '../../core/utils/file_utils.dart';
import '../../data/models/file_model.dart';
import '../../services/file_service.dart';
import '../../services/storage_service.dart';
import '../providers/file_manager_provider.dart';
import '../providers/navigation_provider.dart';
import 'glassmorphism_container.dart';
/// 搜索对话框
class SearchDialog extends StatefulWidget {
const SearchDialog({super.key});
static bool _isShowing = false;
static bool get isShowing => _isShowing;
static Future<void> show(BuildContext context) {
if (_isShowing) return Future.value();
_isShowing = true;
return showGeneralDialog(
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) =>
const SearchDialog(),
).whenComplete(() => _isShowing = false);
}
@override
State<SearchDialog> createState() => _SearchDialogState();
}
class _SearchDialogState extends State<SearchDialog> {
final TextEditingController _searchController = TextEditingController();
final FocusNode _searchFocusNode = FocusNode();
final ScrollController _scrollController = ScrollController();
List<FileModel> _files = [];
List<String> _searchHistory = [];
bool _isLoading = false;
String? _errorMessage;
bool _caseFolding = false;
Timer? _debounceTimer;
@override
void initState() {
super.initState();
_loadHistory();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _searchFocusNode.requestFocus();
});
}
@override
void dispose() {
_debounceTimer?.cancel();
_searchController.dispose();
_searchFocusNode.dispose();
_scrollController.dispose();
super.dispose();
}
Future<void> _loadHistory() async {
final history = await StorageService.instance.getSearchHistory();
if (mounted) setState(() => _searchHistory = history);
}
void _onSearchChanged(String value) {
_debounceTimer?.cancel();
if (value.trim().isEmpty) {
setState(() {
_files = [];
_isLoading = false;
_errorMessage = null;
});
return;
}
setState(() => _isLoading = true);
_debounceTimer = Timer(const Duration(milliseconds: 300), () {
_performSearch(value.trim());
});
}
Future<void> _performSearch(String query) async {
if (query.isEmpty) return;
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final response = await FileService().searchFiles(
name: query,
caseFolding: _caseFolding,
);
final filesData = response['files'] as List<dynamic>? ?? [];
final files = filesData
.map((f) => FileModel.fromJson(f as Map<String, dynamic>))
.toList();
if (!mounted) return;
setState(() {
_files = files;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_isLoading = false;
_errorMessage = e.toString();
});
}
}
void _toggleCaseFolding() {
setState(() => _caseFolding = !_caseFolding);
final query = _searchController.text.trim();
if (query.isNotEmpty) _performSearch(query);
}
Future<void> _clearHistory() async {
await StorageService.instance.clearSearchHistory();
if (mounted) setState(() => _searchHistory = []);
}
Future<void> _onResultTap(FileModel file) async {
final query = _searchController.text.trim();
// 在 async gap 之前获取所有引用
final navProvider = Provider.of<NavigationProvider>(context, listen: false);
final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
final parentPath = _extractParentPath(file.path);
final filePath = file.path;
if (query.isNotEmpty) {
await StorageService.instance.addToSearchHistory(query);
}
if (parentPath == null || !mounted) return;
Navigator.of(context).pop();
navProvider.setIndex(1);
fileManager.navigateAndHighlight(parentPath, filePath);
}
String? _extractParentPath(String filePath) {
const prefix = 'cloudreve://my';
if (!filePath.startsWith(prefix)) return null;
final relativePath = filePath.substring(prefix.length);
if (relativePath.isEmpty) return null;
final parts = relativePath.split('/');
if (parts.length > 1) parts.removeLast();
return parts.isEmpty ? '/' : parts.join('/');
}
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final screenHeight = MediaQuery.of(context).size.height;
final isWide = screenWidth >= 600;
final dialogWidth = isWide ? 560.0 : screenWidth - 32.0;
return Shortcuts(
shortcuts: <ShortcutActivator, Intent>{
const SingleActivator(LogicalKeyboardKey.escape): DismissIntent(),
},
child: Actions(
actions: <Type, Action<Intent>>{
DismissIntent: CallbackAction<DismissIntent>(
onInvoke: (_) {
Navigator.of(context).pop();
return null;
},
),
},
child: Center(
child: Container(
width: dialogWidth,
constraints: BoxConstraints(maxHeight: screenHeight * 0.75),
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: [
_buildSearchInput(context),
const Divider(height: 1),
Flexible(child: _buildBody(context)),
],
),
),
),
),
),
),
),
);
}
Widget _buildSearchInput(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 8, 12),
child: Row(
children: [
Icon(LucideIcons.search, size: 20, color: theme.hintColor),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _searchController,
focusNode: _searchFocusNode,
decoration: InputDecoration(
hintText: '搜索文件...',
border: InputBorder.none,
hintStyle: TextStyle(color: theme.hintColor),
),
style: theme.textTheme.bodyLarge,
textInputAction: TextInputAction.search,
onChanged: _onSearchChanged,
onSubmitted: (value) {
_debounceTimer?.cancel();
_performSearch(value.trim());
},
),
),
IconButton(
icon: Icon(
LucideIcons.caseSensitive,
size: 18,
color: _caseFolding ? colorScheme.primary : theme.hintColor,
),
onPressed: _toggleCaseFolding,
tooltip: '忽略大小写',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
if (_searchController.text.isNotEmpty)
IconButton(
icon: Icon(LucideIcons.x, size: 18, color: theme.hintColor),
onPressed: () {
_searchController.clear();
_onSearchChanged('');
},
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
],
),
);
}
Widget _buildBody(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
if (_isLoading && _files.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
if (_errorMessage != null) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 20),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.alertCircle,
size: 40,
color: colorScheme.error.withValues(alpha: 0.7),
),
const SizedBox(height: 8),
Text(
_errorMessage!,
style: TextStyle(color: theme.hintColor),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: () => _performSearch(_searchController.text.trim()),
child: const Text('重试'),
),
],
),
),
);
}
if (_files.isEmpty) {
final query = _searchController.text.trim();
if (query.isEmpty && _searchHistory.isNotEmpty) {
return _buildHistorySection(context);
}
if (query.isNotEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.searchX,
size: 40,
color: theme.hintColor.withValues(alpha: 0.5),
),
const SizedBox(height: 8),
Text('未找到匹配文件', style: TextStyle(color: theme.hintColor)),
],
),
),
);
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Center(
child: Text('输入关键词搜索文件', style: TextStyle(color: theme.hintColor)),
),
);
}
return ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 400),
child: ListView.separated(
controller: _scrollController,
shrinkWrap: true,
itemCount: _files.length,
separatorBuilder: (context, index) =>
Divider(height: 1, indent: 68, endIndent: 20),
itemBuilder: (context, index) =>
_buildResultItem(context, _files[index]),
),
);
}
Widget _buildHistorySection(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Icon(LucideIcons.history, size: 16, color: theme.hintColor),
const SizedBox(width: 6),
Text(
'搜索历史',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: theme.hintColor,
),
),
const Spacer(),
GestureDetector(
onTap: _clearHistory,
child: Text(
'清除',
style: TextStyle(fontSize: 12, color: colorScheme.primary),
),
),
],
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: _searchHistory.map((query) {
return ActionChip(
label: Text(query, style: const TextStyle(fontSize: 13)),
onPressed: () {
_searchController.text = query;
_onSearchChanged(query);
},
avatar: Icon(LucideIcons.search, size: 14),
);
}).toList(),
),
],
),
);
}
Widget _buildResultItem(BuildContext context, FileModel file) {
final theme = Theme.of(context);
return InkWell(
onTap: () => _onResultTap(file),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
child: Row(
children: [
FileIconUtils.buildIconWidget(context: context, file: file),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
file.name,
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
FileUtils.safeDecodePathSegment(file.relativePath),
style: TextStyle(fontSize: 12, color: theme.hintColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 8),
Text(
file.isFolder
? '文件夹'
: date_utils.DateUtils.formatFileSize(file.size),
style: TextStyle(fontSize: 12, color: theme.hintColor),
),
],
),
),
);
}
}