Files
xianyan/lib/features/profile/presentation/spotlight_search/spotlight_search_overlay.dart
Developer f91be94e9c refactor: 完成项目架构重构,统一模块导入路径
- 清理大量废弃的 barrel 导出文件,移除冗余的中间导出层
- 修复所有相对路径导入错误,统一调整为扁平化模块引用
- 更新多平台 pubspec 版本号与依赖库版本
- 补充后端功能问题管理后台与脚本工具
- 调整部分页面的快捷方式文案适配新功能
- 更新部分翻译覆盖率与API文档
2026-06-12 08:53:57 +08:00

860 lines
27 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// ============================================================
/// 闲言APP — Spotlight搜索浮层
/// 创建时间: 2026-06-07
/// 更新时间: 2026-06-07
/// 作用: macOS Spotlight风格全局搜索浮层支持实时搜索/分类展示/键盘导航
/// 上次更新: 搜索框位置上移(Align替代Center)
/// ============================================================
import 'dart:io' as io;
import 'dart:ui';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/theme/app_spacing.dart';
import '../../../../core/theme/app_typography.dart';
import '../../../../core/theme/app_radius.dart';
import '../../../../core/router/app_nav_extension.dart';
import '../../../../core/utils/logger.dart';
import 'spotlight_search_data.dart';
import 'spotlight_search_provider.dart';
// ============================================================
// Spotlight搜索浮层 — 静态入口
// ============================================================
/// Spotlight搜索浮层 — 静态方法调用
///
/// 使用方式:
/// ```dart
/// SpotlightSearchOverlay.show(context, ref);
/// ```
class SpotlightSearchOverlay {
SpotlightSearchOverlay._();
/// 显示搜索浮层
static void show(BuildContext context, WidgetRef ref) {
ref.read(spotlightSearchProvider.notifier).open();
showGeneralDialog<void>(
context: context,
barrierDismissible: true,
barrierLabel: 'Spotlight Search',
barrierColor: Colors.transparent,
transitionDuration: const Duration(milliseconds: 350),
transitionBuilder: (context, animation, secondaryAnimation, child) {
return child;
},
pageBuilder: (context, animation, secondaryAnimation) {
return _SpotlightSearchDialog(animation: animation);
},
).then((_) {
ref.read(spotlightSearchProvider.notifier).close();
});
}
}
// ============================================================
// 搜索浮层主体
// ============================================================
class _SpotlightSearchDialog extends ConsumerStatefulWidget {
const _SpotlightSearchDialog({required this.animation});
/// 入场动画控制器
final Animation<double> animation;
@override
ConsumerState<_SpotlightSearchDialog> createState() =>
_SpotlightSearchDialogState();
}
class _SpotlightSearchDialogState extends ConsumerState<_SpotlightSearchDialog>
with SingleTickerProviderStateMixin {
// ---- 控制器 ----
final _searchController = TextEditingController();
final _focusNode = FocusNode();
final _scrollController = ScrollController();
// ---- 动画 ----
late final AnimationController _entryController;
late final Animation<double> _fadeAnimation;
late final Animation<double> _scaleAnimation;
late final Animation<Offset> _slideAnimation;
@override
void initState() {
super.initState();
_focusNode.requestFocus();
// ---- 入场动画 ----
_entryController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 350),
);
// 遮罩渐入
_fadeAnimation = CurvedAnimation(
parent: _entryController,
curve: Curves.easeOutCubic,
);
// 搜索框缩放(弹性曲线)
_scaleAnimation = CurvedAnimation(
parent: _entryController,
curve: const Cubic(0.34, 1.56, 0.64, 1.0), // 弹性过冲
);
// 搜索框从上方滑入
_slideAnimation =
Tween<Offset>(begin: const Offset(0, -0.08), end: Offset.zero).animate(
CurvedAnimation(parent: _entryController, curve: Curves.easeOutCubic),
);
// 启动入场动画
_entryController.forward();
}
@override
void dispose() {
_searchController.dispose();
_focusNode.dispose();
_scrollController.dispose();
_entryController.dispose();
super.dispose();
}
// ---- 关闭浮层 ----
void _dismiss() {
_entryController.reverse().then((_) {
if (mounted) Navigator.pop(context);
});
}
// ---- 确认选择并导航 ----
void _confirmAndNavigate(SpotlightItem item) {
Log.i('Spotlight搜索: 选择 → ${item.name} (${item.route})');
Navigator.pop(context);
context.appPush(item.route);
}
// ============================================================
// 构建
// ============================================================
@override
Widget build(BuildContext context) {
final ext = AppTheme.ext(context);
final searchState = ref.watch(spotlightSearchProvider);
return KeyboardListener(
focusNode: FocusNode(),
onKeyEvent: _handleKeyEvent,
child: GestureDetector(
onTap: _dismiss,
child: FadeTransition(
opacity: _fadeAnimation,
child: Container(
color: ext.bgPrimary.withValues(alpha: 0.5),
child: Align(
alignment: const Alignment(0, -0.35),
child: SlideTransition(
position: _slideAnimation,
child: ScaleTransition(
scale: _scaleAnimation,
child: GestureDetector(
onTap: () {}, // 阻止点击穿透
child: Semantics(
label: 'Spotlight 搜索',
child: Container(
width: _dialogWidth(context),
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.65,
),
decoration: _buildDialogDecoration(ext),
clipBehavior: Clip.antiAlias,
child: ClipRRect(
borderRadius: AppRadius.xlBorder,
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: 40 * ext.glassBlurMultiplier,
sigmaY: 40 * ext.glassBlurMultiplier,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildSearchBar(ext, searchState),
if (searchState.query.isEmpty &&
searchState.recentSearches.isNotEmpty)
_buildRecentSearches(ext, searchState),
if (searchState.results.isNotEmpty)
Expanded(
child: _buildResults(ext, searchState),
),
_buildShortcutHints(ext),
],
),
),
),
),
),
),
),
),
),
),
),
),
);
}
// ---- 弹窗宽度 ----
double _dialogWidth(BuildContext context) {
final screenWidth = MediaQuery.sizeOf(context).width;
final isTablet = screenWidth > 768;
if (isTablet) return 520; // iPad 横屏时更宽
if (screenWidth > 480) return 400;
return screenWidth - AppSpacing.xl * 2;
}
// ---- 弹窗装饰 ----
BoxDecoration _buildDialogDecoration(AppThemeExtension ext) {
return BoxDecoration(
color: ext.bgCard.withValues(alpha: 0.82),
borderRadius: AppRadius.xlBorder,
border: Border.all(
color: ext.overlaySubtle.withValues(alpha: 0.3),
width: 0.5,
),
boxShadow: [
BoxShadow(
color: CupertinoColors.black.withValues(alpha: 0.18),
blurRadius: 40,
offset: const Offset(0, 20),
spreadRadius: -4,
),
BoxShadow(
color: ext.accent.withValues(alpha: 0.06),
blurRadius: 60,
offset: const Offset(0, 8),
),
],
);
}
// ============================================================
// 搜索栏
// ============================================================
Widget _buildSearchBar(AppThemeExtension ext, SpotlightSearchState state) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: ext.overlaySubtle.withValues(alpha: 0.15),
width: 0.5,
),
),
),
child: Row(
children: [
// 搜索图标
Icon(CupertinoIcons.search, size: 20, color: ext.textHint),
const SizedBox(width: AppSpacing.sm),
// 输入框
Expanded(
child: Semantics(
label: '搜索页面、功能、工具',
child: CupertinoTextField(
controller: _searchController,
focusNode: _focusNode,
style: AppTypography.body.copyWith(color: ext.textPrimary),
placeholder: '搜索页面、功能、工具…',
placeholderStyle: AppTypography.body.copyWith(
color: ext.textHint,
),
decoration: const BoxDecoration(),
padding: const EdgeInsets.symmetric(
vertical: AppSpacing.sm + 2,
),
onChanged: (value) {
ref.read(spotlightSearchProvider.notifier).updateQuery(value);
},
),
),
),
// 清除按钮
if (state.query.isNotEmpty)
GestureDetector(
onTap: () {
_searchController.clear();
ref.read(spotlightSearchProvider.notifier).updateQuery('');
_focusNode.requestFocus();
},
child: Container(
padding: const EdgeInsets.all(AppSpacing.xs),
decoration: BoxDecoration(
color: ext.overlaySubtle.withValues(alpha: 0.3),
shape: BoxShape.circle,
),
child: Icon(
CupertinoIcons.xmark,
size: 12,
color: ext.textSecondary,
),
),
)
.animate()
.fadeIn(duration: 150.ms)
.scale(
begin: const Offset(0.5, 0.5),
end: const Offset(1.0, 1.0),
duration: 200.ms,
),
],
),
);
}
// ============================================================
// 最近搜索
// ============================================================
Widget _buildRecentSearches(
AppThemeExtension ext,
SpotlightSearchState state,
) {
return Container(
constraints: const BoxConstraints(maxHeight: 120),
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: ext.overlaySubtle.withValues(alpha: 0.1),
width: 0.5,
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// 标题行
Row(
children: [
Icon(CupertinoIcons.clock, size: 14, color: ext.textHint),
const SizedBox(width: AppSpacing.xs),
Text(
'最近搜索',
style: AppTypography.caption1.copyWith(
color: ext.textHint,
fontWeight: FontWeight.w500,
),
),
const Spacer(),
GestureDetector(
onTap: () {
ref
.read(spotlightSearchProvider.notifier)
.clearRecentSearches();
},
child: Text(
'清除',
style: AppTypography.caption1.copyWith(color: ext.accent),
),
),
],
),
const SizedBox(height: AppSpacing.sm),
// 标签列表
Wrap(
spacing: AppSpacing.xs,
runSpacing: AppSpacing.xs,
children: state.recentSearches.map((keyword) {
return _buildRecentTag(ext, keyword);
}).toList(),
),
],
),
);
}
/// 最近搜索标签
Widget _buildRecentTag(AppThemeExtension ext, String keyword) {
return Semantics(
label: '搜索 $keyword',
button: true,
child: GestureDetector(
onTap: () {
_searchController.text = keyword;
ref.read(spotlightSearchProvider.notifier).updateQuery(keyword);
_focusNode.requestFocus();
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: AppSpacing.xs + 1,
),
decoration: BoxDecoration(
color: ext.bgSecondary.withValues(alpha: 0.7),
borderRadius: AppRadius.pillBorder,
border: Border.all(
color: ext.overlaySubtle.withValues(alpha: 0.15),
width: 0.5,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
keyword,
style: AppTypography.caption1.copyWith(
color: ext.textSecondary,
),
),
const SizedBox(width: 2),
GestureDetector(
onTap: () {
ref
.read(spotlightSearchProvider.notifier)
.removeRecentSearch(keyword);
},
child: Icon(
CupertinoIcons.xmark,
size: 10,
color: ext.textHint,
),
),
],
),
),
),
);
}
// ============================================================
// 搜索结果列表
Widget _buildResults(AppThemeExtension ext, SpotlightSearchState state) {
final flatEntries = state.flatEntries;
return AnimationLimiter(
child: ListView.builder(
controller: _scrollController,
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: flatEntries.length,
itemBuilder: (context, index) {
final entry = flatEntries[index];
return AnimationConfiguration.staggeredList(
position: index,
duration: const Duration(milliseconds: 375),
child: SlideAnimation(
verticalOffset: 12.0,
child: FadeInAnimation(
child: switch (entry) {
SpotlightCategoryEntry(:final category) => Semantics(
header: true,
child: _buildCategoryHeader(ext, category),
),
SpotlightItemEntry(:final item) => _buildResultItem(
ext,
item,
state.results.indexOf(item),
state.selectedIndex,
state.query,
),
},
),
),
);
},
),
);
}
// ---- 分类标题 ----
Widget _buildCategoryHeader(AppThemeExtension ext, SpotlightCategory cat) {
return Container(
padding: const EdgeInsets.fromLTRB(
AppSpacing.md,
AppSpacing.sm + 4,
AppSpacing.md,
AppSpacing.xs,
),
child: Row(
children: [
Text(cat.emoji, style: const TextStyle(fontSize: 12)),
const SizedBox(width: AppSpacing.xs),
Text(
cat.label,
style: AppTypography.caption1.copyWith(
color: ext.textHint,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
// ---- 搜索结果项 ----
Widget _buildResultItem(
AppThemeExtension ext,
SpotlightItem item,
int itemIndex,
int selectedIndex,
String query,
) {
final isSelected = itemIndex == selectedIndex;
return Semantics(
label: '${item.name}, ${item.subtitle ?? item.category.label}',
button: true,
selected: isSelected,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _confirmAndNavigate(item),
child: MouseRegion(
onEnter: (_) {
// 鼠标悬停时更新选中
ref.read(spotlightSearchProvider.notifier).selectItem(itemIndex);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
margin: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: 1,
),
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
color: isSelected
? ext.accent.withValues(alpha: 0.12)
: Colors.transparent,
borderRadius: AppRadius.mdBorder,
),
child: Row(
children: [
// 图标
_buildItemIcon(ext, item),
const SizedBox(width: AppSpacing.sm + 2),
// 文字区域
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 名称(高亮关键词)
_buildHighlightedText(ext, item.name, query),
// 副标题
if (item.subtitle != null)
Padding(
padding: const EdgeInsets.only(top: 1),
child: Text(
item.subtitle!,
style: AppTypography.caption2.copyWith(
color: ext.textHint,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
// 分类标签
Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm - 2,
vertical: 2,
),
decoration: BoxDecoration(
color: _categoryColor(
item.category,
).withValues(alpha: 0.12),
borderRadius: AppRadius.pillBorder,
),
child: Text(
item.category.label,
style: AppTypography.caption2.copyWith(
color: _categoryColor(item.category),
fontWeight: FontWeight.w500,
),
),
),
// 选中指示箭头
if (isSelected) ...[
const SizedBox(width: AppSpacing.xs),
Icon(
CupertinoIcons.chevron_right,
size: 14,
color: ext.accent,
),
],
],
),
),
),
),
);
}
// ---- 项图标 ----
Widget _buildItemIcon(AppThemeExtension ext, SpotlightItem item) {
final catColor = _categoryColor(item.category);
return Container(
width: 34,
height: 34,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
catColor.withValues(alpha: 0.2),
catColor.withValues(alpha: 0.08),
],
),
borderRadius: AppRadius.mdBorder,
border: Border.all(color: catColor.withValues(alpha: 0.15), width: 0.5),
),
child: Center(
child: Text(
item.iconEmoji ?? item.category.emoji,
style: const TextStyle(fontSize: 18),
),
),
);
}
// ---- 关键词高亮文本 ----
Widget _buildHighlightedText(
AppThemeExtension ext,
String text,
String query,
) {
if (query.isEmpty) {
return Text(
text,
style: AppTypography.subhead.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
}
final lowerText = text.toLowerCase();
final lowerQuery = query.toLowerCase();
final matchIndex = lowerText.indexOf(lowerQuery);
if (matchIndex == -1) {
return Text(
text,
style: AppTypography.subhead.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
}
return RichText(
maxLines: 1,
overflow: TextOverflow.ellipsis,
text: TextSpan(
children: [
// 匹配前
if (matchIndex > 0)
TextSpan(
text: text.substring(0, matchIndex),
style: AppTypography.subhead.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w500,
),
),
// 匹配部分(高亮)
TextSpan(
text: text.substring(matchIndex, matchIndex + query.length),
style: AppTypography.subhead.copyWith(
color: ext.accent,
fontWeight: FontWeight.w700,
),
),
// 匹配后
if (matchIndex + query.length < text.length)
TextSpan(
text: text.substring(matchIndex + query.length),
style: AppTypography.subhead.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
// ---- 分类颜色 ----
Color _categoryColor(SpotlightCategory category) {
final ext = AppTheme.ext(context);
return switch (category) {
SpotlightCategory.page => ext.iconTintBlue,
SpotlightCategory.feature => ext.iconTintMint,
SpotlightCategory.tool => ext.iconTintYellow,
SpotlightCategory.setting => ext.iconTintGrey,
SpotlightCategory.content => ext.iconTintPurple,
};
}
// ============================================================
// 快捷键提示 — 响应式竖屏2栏 / 横屏1栏
// ============================================================
Widget _buildShortcutHints(AppThemeExtension ext) {
final hints = [
('↑↓', '导航'),
('', '打开'),
('esc', '关闭'),
(io.Platform.isMacOS ? '⌘K' : 'Ctrl+J', '搜索'),
];
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm - 2,
),
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: ext.overlaySubtle.withValues(alpha: 0.1),
width: 0.5,
),
),
),
child: OrientationBuilder(
builder: (context, orientation) {
// 竖屏2栏Wrap自动换行每行约2个
if (orientation == Orientation.portrait) {
return Wrap(
alignment: WrapAlignment.center,
spacing: AppSpacing.md,
runSpacing: AppSpacing.xs,
children: hints
.map((h) => _buildHintKey(ext, h.$1, h.$2))
.toList(),
);
}
// 横屏1栏单行
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
...hints
.expand(
(h) => [
_buildHintKey(ext, h.$1, h.$2),
_buildHintSeparator(ext),
],
)
.toList()
..removeLast(),
],
);
},
),
);
}
Widget _buildHintKey(AppThemeExtension ext, String key, String label) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xs + 1,
vertical: 1,
),
decoration: BoxDecoration(
color: ext.bgSecondary.withValues(alpha: 0.6),
borderRadius: AppRadius.smBorder,
border: Border.all(
color: ext.overlaySubtle.withValues(alpha: 0.2),
width: 0.5,
),
),
child: Text(
key,
style: AppTypography.caption2.copyWith(
color: ext.textSecondary,
fontFamily: 'SF Mono',
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 3),
Text(
label,
style: AppTypography.caption2.copyWith(color: ext.textHint),
),
],
);
}
Widget _buildHintSeparator(AppThemeExtension ext) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm),
child: Text(
'·',
style: AppTypography.caption2.copyWith(
color: ext.textHint.withValues(alpha: 0.4),
),
),
);
}
// ============================================================
// 键盘事件处理
// ============================================================
void _handleKeyEvent(KeyEvent event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) return;
final notifier = ref.read(spotlightSearchProvider.notifier);
final state = ref.read(spotlightSearchProvider);
if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
notifier.selectPrevious();
} else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
notifier.selectNext();
} else if (event.logicalKey == LogicalKeyboardKey.enter ||
event.logicalKey == LogicalKeyboardKey.numpadEnter) {
final item = notifier.confirmSelection();
if (item != null) _confirmAndNavigate(item);
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
_dismiss();
} else if (event.logicalKey == LogicalKeyboardKey.delete &&
state.query.isEmpty) {
// 空查询时按删除键关闭
_dismiss();
}
}
}