- 清理大量废弃的 barrel 导出文件,移除冗余的中间导出层 - 修复所有相对路径导入错误,统一调整为扁平化模块引用 - 更新多平台 pubspec 版本号与依赖库版本 - 补充后端功能问题管理后台与脚本工具 - 调整部分页面的快捷方式文案适配新功能 - 更新部分翻译覆盖率与API文档
860 lines
27 KiB
Dart
860 lines
27 KiB
Dart
/// ============================================================
|
||
/// 闲言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();
|
||
}
|
||
}
|
||
}
|