1. 全局替换tool_center/inspiration为discover模块,统一路由路径 2. 调整AppRoutes路由常量,将discover作为主Tab页,inspiration作为子页面 3. 更新页面注册表与路由配置,修正跳转目标 4. 调整启动页可选配置项,修正路由ID对应关系 5. 新增翻译服务、内容发现、热搜相关工具类与数据模型 6. 修复缓存清理后未刷新统计的问题,调整x86_64架构注释 7. 更新AGENTS.md文档约束规则 8. 新增一批调试用截图资源文件
897 lines
26 KiB
Dart
897 lines
26 KiB
Dart
/// ============================================================
|
|
/// 闲言APP — 汉语工具通用查询页
|
|
/// 创建时间: 2026-04-28
|
|
/// 更新时间: 2026-04-28
|
|
/// 作用: 17种汉语工具的通用查询界面 + 本地缓存 + 骨架屏
|
|
/// 上次更新: 迁移缓存到 Drift (HanziCaches表)
|
|
/// ============================================================
|
|
|
|
import 'package:xianyan/core/utils/data/pattern_utils.dart';
|
|
import 'dart:convert';
|
|
|
|
import 'package:flutter/cupertino.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:share_plus/share_plus.dart' as share_plus;
|
|
|
|
import 'package:xianyan/core/storage/kv_storage.dart';
|
|
import 'package:xianyan/core/storage/database/app_database.dart';
|
|
import 'package:xianyan/core/theme/app_theme.dart';
|
|
import 'package:xianyan/core/theme/app_spacing.dart';
|
|
import 'package:xianyan/core/theme/app_typography.dart';
|
|
import 'package:xianyan/core/theme/app_radius.dart';
|
|
import 'package:xianyan/core/utils/logger.dart';
|
|
import 'package:xianyan/core/utils/data/extensions.dart';
|
|
import 'package:xianyan/shared/widgets/adaptive/adaptive_back_button.dart';
|
|
import 'package:xianyan/shared/widgets/containers/app_sticky_header.dart';
|
|
import 'package:xianyan/shared/widgets/input/app_slidable.dart';
|
|
import 'package:xianyan/shared/widgets/display/skeleton.dart';
|
|
import 'package:xianyan/shared/widgets/feedback/app_toast.dart';
|
|
import 'package:xianyan/shared/widgets/input/app_markdown.dart';
|
|
import '../../../models/hanzi_result.dart';
|
|
import '../../../services/tool_api_service.dart';
|
|
import '../../../models/tool_item.dart';
|
|
import '../../widgets/common/data_source_badge.dart';
|
|
import '../../widgets/tool/tool_icon_helper.dart';
|
|
|
|
/// 汉语工具类型配置
|
|
class HanziToolConfig {
|
|
const HanziToolConfig({
|
|
required this.type,
|
|
required this.title,
|
|
required this.emoji,
|
|
required this.hintText,
|
|
required this.emptyMessage,
|
|
this.isSingleChar = true,
|
|
});
|
|
|
|
final String type;
|
|
final String title;
|
|
final String emoji;
|
|
final String hintText;
|
|
final String emptyMessage;
|
|
final bool isSingleChar;
|
|
}
|
|
|
|
/// 汉语工具查询状态
|
|
class HanziQueryState {
|
|
const HanziQueryState({
|
|
this.isLoading = false,
|
|
this.result,
|
|
this.error,
|
|
this.query = '',
|
|
});
|
|
|
|
final bool isLoading;
|
|
final Map<String, dynamic>? result;
|
|
final String? error;
|
|
final String query;
|
|
|
|
HanziQueryState copyWith({
|
|
bool? isLoading,
|
|
Map<String, dynamic>? result,
|
|
String? error,
|
|
String? query,
|
|
}) {
|
|
return HanziQueryState(
|
|
isLoading: isLoading ?? this.isLoading,
|
|
result: result ?? this.result,
|
|
error: error ?? this.error,
|
|
query: query ?? this.query,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 汉语工具查询 Provider
|
|
class HanziQueryNotifier extends Notifier<HanziQueryState> {
|
|
@override
|
|
HanziQueryState build() => const HanziQueryState();
|
|
|
|
HanziToolConfig _config = const HanziToolConfig(
|
|
type: 'zi',
|
|
title: '汉字查询',
|
|
emoji: '🔍',
|
|
hintText: '输入汉字',
|
|
emptyMessage: '暂无结果',
|
|
);
|
|
|
|
void setConfig(HanziToolConfig config) => _config = config;
|
|
|
|
static const _cachePrefix = 'hanzi_cache_';
|
|
|
|
Future<void> query(String keyword) async {
|
|
if (keyword.trim().isEmpty) return;
|
|
|
|
final trimmed = keyword.trim();
|
|
state = state.copyWith(isLoading: true, query: trimmed);
|
|
|
|
final cacheKey = '$_cachePrefix${_config.type}_$trimmed';
|
|
final cached = await _loadCache(cacheKey);
|
|
if (cached != null) {
|
|
state = state.copyWith(isLoading: false, result: cached);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
final result = await ToolApiService.hanziSearch(
|
|
keyword: trimmed,
|
|
type: _config.type,
|
|
);
|
|
|
|
if (_config.type == 'zi' && trimmed.length == 1) {
|
|
try {
|
|
final bishunResult = await ToolApiService.bishun(trimmed);
|
|
final bishunData = bishunResult['data'];
|
|
if (bishunData is Map<String, dynamic>) {
|
|
final mergedData = <String, dynamic>{};
|
|
final existingData = result['data'];
|
|
if (existingData is Map<String, dynamic>) {
|
|
mergedData.addAll(existingData);
|
|
}
|
|
mergedData.addAll(bishunData);
|
|
result['data'] = mergedData;
|
|
}
|
|
} catch (e) {
|
|
Log.w('笔顺查询失败', e);
|
|
}
|
|
}
|
|
|
|
await _saveCache(cacheKey, _config.type, trimmed, result);
|
|
state = state.copyWith(isLoading: false, result: result);
|
|
} catch (e) {
|
|
Log.e('汉语工具查询失败: ${_config.type}', e);
|
|
state = state.copyWith(isLoading: false, error: '查询失败,请检查网络后重试');
|
|
}
|
|
}
|
|
|
|
Future<Map<String, dynamic>?> _loadCache(String cacheKey) async {
|
|
try {
|
|
final db = AppDatabase.instance;
|
|
final row = await db.getHanziCache(cacheKey);
|
|
if (row == null) return null;
|
|
if (row.expiresAt.isBefore(DateTime.now())) {
|
|
await db.deleteExpiredHanziCache();
|
|
return null;
|
|
}
|
|
return jsonDecode(row.resultJson) as Map<String, dynamic>;
|
|
} catch (e) {
|
|
Log.w('Drift缓存读取失败', e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<void> _saveCache(
|
|
String cacheKey,
|
|
String queryType,
|
|
String keyword,
|
|
Map<String, dynamic> data,
|
|
) async {
|
|
try {
|
|
final db = AppDatabase.instance;
|
|
await db.saveHanziCache(
|
|
cacheKey: cacheKey,
|
|
queryType: queryType,
|
|
keyword: keyword,
|
|
resultJson: jsonEncode(data),
|
|
);
|
|
} catch (e) {
|
|
Log.w('Drift缓存保存失败', e);
|
|
}
|
|
}
|
|
|
|
void clearResult() {
|
|
state = const HanziQueryState();
|
|
}
|
|
}
|
|
|
|
/// 查询历史 Provider
|
|
class HanziHistoryNotifier extends Notifier<List<HanziQueryRecord>> {
|
|
@override
|
|
List<HanziQueryRecord> build() {
|
|
Future.microtask(_loadHistory).catchError((_) {});
|
|
return [];
|
|
}
|
|
|
|
static const _historyKey = 'hanzi_query_history';
|
|
static const _maxRecords = 50;
|
|
|
|
Future<void> _loadHistory() async {
|
|
try {
|
|
final raw = KvStorage.getStringList(_historyKey) ?? [];
|
|
state = raw
|
|
.map((r) {
|
|
try {
|
|
return HanziQueryRecord.fromJson(
|
|
jsonDecode(r) as Map<String, dynamic>,
|
|
);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
})
|
|
.whereType<HanziQueryRecord>()
|
|
.toList();
|
|
} catch (e) {
|
|
Log.w('加载查询历史失败', e);
|
|
}
|
|
}
|
|
|
|
Future<void> addRecord(HanziQueryRecord record) async {
|
|
state = [
|
|
record,
|
|
...state.where(
|
|
(r) => !(r.type == record.type && r.query == record.query),
|
|
),
|
|
].take(_maxRecords).toList();
|
|
await _saveHistory();
|
|
}
|
|
|
|
Future<void> removeRecord(int index) async {
|
|
if (index < 0 || index >= state.length) return;
|
|
state = [...state]..removeAt(index);
|
|
await _saveHistory();
|
|
}
|
|
|
|
Future<void> clearHistory() async {
|
|
state = [];
|
|
await _saveHistory();
|
|
}
|
|
|
|
Future<void> _saveHistory() async {
|
|
try {
|
|
await KvStorage.setStringList(
|
|
_historyKey,
|
|
state.map((r) => jsonEncode(r.toJson())).toList(),
|
|
);
|
|
} catch (e) {
|
|
Log.w('保存查询历史失败', e);
|
|
}
|
|
}
|
|
}
|
|
|
|
final hanziHistoryProvider =
|
|
NotifierProvider<HanziHistoryNotifier, List<HanziQueryRecord>>(
|
|
HanziHistoryNotifier.new,
|
|
);
|
|
|
|
/// 汉语工具 Provider 工厂
|
|
final _hanziProviders =
|
|
<String, NotifierProvider<HanziQueryNotifier, HanziQueryState>>{};
|
|
|
|
NotifierProvider<HanziQueryNotifier, HanziQueryState> hanziQueryProvider(
|
|
HanziToolConfig config,
|
|
) {
|
|
return _hanziProviders.putIfAbsent(
|
|
config.type,
|
|
() => NotifierProvider<HanziQueryNotifier, HanziQueryState>(
|
|
HanziQueryNotifier.new,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 汉语工具通用查询页
|
|
class HanziToolPage extends ConsumerStatefulWidget {
|
|
const HanziToolPage({super.key, required this.config});
|
|
|
|
final HanziToolConfig config;
|
|
|
|
@override
|
|
ConsumerState<HanziToolPage> createState() => _HanziToolPageState();
|
|
}
|
|
|
|
class _HanziToolPageState extends ConsumerState<HanziToolPage> {
|
|
final _controller = TextEditingController();
|
|
final _focusNode = FocusNode();
|
|
|
|
@override
|
|
void dispose() {
|
|
_onDispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onDispose() {
|
|
_controller.dispose();
|
|
_focusNode.dispose();
|
|
}
|
|
|
|
void _doQuery() {
|
|
final text = _controller.text.trim();
|
|
if (text.isEmpty) {
|
|
AppToast.showWarning('请输入查询内容');
|
|
return;
|
|
}
|
|
if (widget.config.isSingleChar && text.length > 1) {
|
|
AppToast.showWarning('该工具仅支持单字查询');
|
|
return;
|
|
}
|
|
final provider = hanziQueryProvider(widget.config);
|
|
ref.read(provider.notifier).query(text);
|
|
ref
|
|
.read(hanziHistoryProvider.notifier)
|
|
.addRecord(
|
|
HanziQueryRecord(
|
|
type: widget.config.type,
|
|
query: text,
|
|
timestamp: DateTime.now(),
|
|
),
|
|
);
|
|
_focusNode.unfocus();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final ext = AppTheme.ext(context);
|
|
final provider = hanziQueryProvider(widget.config);
|
|
final queryState = ref.watch(provider);
|
|
|
|
return CupertinoPageScaffold(
|
|
navigationBar: CupertinoNavigationBar(
|
|
middle: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
ToolIconHelper.iconForHanziType(widget.config.type),
|
|
size: 18,
|
|
color: ext.textPrimary,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(widget.config.title),
|
|
],
|
|
),
|
|
backgroundColor: ext.bgPrimary.withValues(alpha: 0.9),
|
|
border: null,
|
|
leading: const AdaptiveBackButton(),
|
|
trailing: DataSourceBadge(
|
|
toolType: ToolType.online,
|
|
apiPath: '/api/hanzi/${widget.config.type}',
|
|
compact: true,
|
|
),
|
|
),
|
|
child: SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(AppSpacing.md),
|
|
child: Column(
|
|
children: [
|
|
_buildSearchBar(ext),
|
|
const SizedBox(height: AppSpacing.md),
|
|
Expanded(child: _buildResultArea(ext, queryState)),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSearchBar(AppThemeExtension ext) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: ext.bgSecondary,
|
|
borderRadius: AppRadius.lgBorder,
|
|
),
|
|
child: CupertinoTextField(
|
|
controller: _controller,
|
|
focusNode: _focusNode,
|
|
placeholder: widget.config.hintText,
|
|
placeholderStyle: AppTypography.body.copyWith(color: ext.textHint),
|
|
style: AppTypography.body.copyWith(color: ext.textPrimary),
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: AppSpacing.md,
|
|
vertical: AppSpacing.sm + 2,
|
|
),
|
|
decoration: const BoxDecoration(),
|
|
onSubmitted: (_) => _doQuery(),
|
|
suffix: Padding(
|
|
padding: const EdgeInsets.only(right: AppSpacing.sm),
|
|
child: CupertinoButton(
|
|
padding: const EdgeInsets.all(AppSpacing.sm),
|
|
minimumSize: Size.zero,
|
|
onPressed: _doQuery,
|
|
child: Icon(CupertinoIcons.search, color: ext.accent, size: 20),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildResultArea(AppThemeExtension ext, HanziQueryState state) {
|
|
if (state.isLoading) {
|
|
return _buildLoadingSkeleton(ext);
|
|
}
|
|
|
|
if (state.error != null) {
|
|
return _buildErrorView(ext, state.error!);
|
|
}
|
|
|
|
if (state.result == null) {
|
|
return _buildEmptyView(ext);
|
|
}
|
|
|
|
return _buildResultContent(ext, state.result!);
|
|
}
|
|
|
|
Widget _buildLoadingSkeleton(AppThemeExtension ext) {
|
|
return ListView.separated(
|
|
itemCount: 3,
|
|
separatorBuilder: (_, __) => const SizedBox(height: AppSpacing.sm),
|
|
itemBuilder: (_, __) => const ListItemSkeleton(),
|
|
);
|
|
}
|
|
|
|
Widget _buildEmptyView(AppThemeExtension ext) {
|
|
final history = ref.watch(hanziHistoryProvider);
|
|
final filteredHistory = history
|
|
.where((r) => r.type == widget.config.type)
|
|
.toList();
|
|
|
|
return SingleChildScrollView(
|
|
child: Column(
|
|
children: [
|
|
const SizedBox(height: AppSpacing.xl),
|
|
Text(widget.config.emoji, style: const TextStyle(fontSize: 48)),
|
|
const SizedBox(height: AppSpacing.md),
|
|
Text(
|
|
widget.config.emptyMessage,
|
|
style: AppTypography.subhead.copyWith(color: ext.textSecondary),
|
|
),
|
|
const SizedBox(height: AppSpacing.xl),
|
|
_buildQuickActions(ext),
|
|
if (filteredHistory.isNotEmpty) ...[
|
|
const SizedBox(height: AppSpacing.xl),
|
|
_buildHistorySection(ext, filteredHistory),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHistorySection(
|
|
AppThemeExtension ext,
|
|
List<HanziQueryRecord> records,
|
|
) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
|
child: Row(
|
|
children: [
|
|
Text(
|
|
'🕐 查询历史',
|
|
style: AppTypography.subhead.copyWith(
|
|
color: ext.textPrimary,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
CupertinoButton(
|
|
padding: EdgeInsets.zero,
|
|
minimumSize: Size.zero,
|
|
onPressed: () {
|
|
ref.read(hanziHistoryProvider.notifier).clearHistory();
|
|
AppToast.showSuccess('历史已清空');
|
|
},
|
|
child: Text(
|
|
'清空',
|
|
style: AppTypography.caption1.copyWith(color: ext.textHint),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: AppSpacing.sm),
|
|
...records.take(10).toList().asMap().entries.map((entry) {
|
|
final index = entry.key;
|
|
final record = entry.value;
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: AppSpacing.md,
|
|
vertical: AppSpacing.xs / 2,
|
|
),
|
|
child: AppSlidable(
|
|
slideKey: ValueKey('history_$index'),
|
|
groupTag: 'hanzi_history',
|
|
rightActions: [
|
|
SlideActionConfig(
|
|
type: SlideActionType.delete,
|
|
onPressed: () {
|
|
final allHistory = ref.read(hanziHistoryProvider);
|
|
final globalIndex = allHistory.indexWhere(
|
|
(r) =>
|
|
r.type == record.type &&
|
|
r.query == record.query &&
|
|
r.timestamp == record.timestamp,
|
|
);
|
|
if (globalIndex >= 0) {
|
|
ref
|
|
.read(hanziHistoryProvider.notifier)
|
|
.removeRecord(globalIndex);
|
|
}
|
|
},
|
|
),
|
|
],
|
|
borderRadius: AppRadius.mdBorder,
|
|
child: GestureDetector(
|
|
onTap: () {
|
|
_controller.text = record.query;
|
|
_doQuery();
|
|
},
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: AppSpacing.md,
|
|
vertical: AppSpacing.sm,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: ext.bgSecondary,
|
|
borderRadius: AppRadius.mdBorder,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Text(
|
|
record.query,
|
|
style: AppTypography.body.copyWith(
|
|
color: ext.textPrimary,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
Text(
|
|
record.displayType,
|
|
style: AppTypography.caption1.copyWith(
|
|
color: ext.textHint,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildQuickActions(AppThemeExtension ext) {
|
|
final quickChars = _getQuickChars();
|
|
if (quickChars.isEmpty) return const SizedBox.shrink();
|
|
|
|
return Wrap(
|
|
spacing: AppSpacing.sm,
|
|
runSpacing: AppSpacing.sm,
|
|
children: quickChars.map((char) {
|
|
return CupertinoButton(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: AppSpacing.md,
|
|
vertical: AppSpacing.sm,
|
|
),
|
|
minimumSize: Size.zero,
|
|
borderRadius: AppRadius.mdBorder,
|
|
color: ext.bgSecondary,
|
|
onPressed: () {
|
|
_controller.text = char;
|
|
_doQuery();
|
|
},
|
|
child: Text(
|
|
char,
|
|
style: AppTypography.callout.copyWith(color: ext.textPrimary),
|
|
),
|
|
);
|
|
}).toList(),
|
|
);
|
|
}
|
|
|
|
List<String> _getQuickChars() {
|
|
switch (widget.config.type) {
|
|
case 'zi':
|
|
return ['风', '花', '雪', '月', '山', '水'];
|
|
case 'zuci':
|
|
return ['大', '小', '天', '地', '人', '心'];
|
|
case 'cidian':
|
|
return ['美丽', '快乐', '勇敢', '智慧'];
|
|
case 'chengyu':
|
|
return ['龙', '虎', '金', '玉'];
|
|
case 'jinyici':
|
|
return ['快乐', '美丽', '勇敢', '聪明'];
|
|
case 'fanyici':
|
|
return ['快乐', '美丽', '勇敢', '聪明'];
|
|
case 'juzi':
|
|
return ['春', '夏', '秋', '冬'];
|
|
default:
|
|
return [];
|
|
}
|
|
}
|
|
|
|
Widget _buildErrorView(AppThemeExtension ext, String error) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Text('😔', style: TextStyle(fontSize: 48)),
|
|
const SizedBox(height: AppSpacing.md),
|
|
Text(
|
|
error,
|
|
style: AppTypography.body.copyWith(color: ext.textSecondary),
|
|
),
|
|
const SizedBox(height: AppSpacing.md),
|
|
CupertinoButton(
|
|
onPressed: _doQuery,
|
|
child: Text('重试', style: TextStyle(color: ext.accent)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildResultContent(
|
|
AppThemeExtension ext,
|
|
Map<String, dynamic> result,
|
|
) {
|
|
final data = result['data'];
|
|
|
|
if (data is List) {
|
|
if (data.isEmpty) {
|
|
return Center(
|
|
child: Text(
|
|
'暂无结果',
|
|
style: AppTypography.body.copyWith(color: ext.textSecondary),
|
|
),
|
|
);
|
|
}
|
|
return _buildListResult(ext, data);
|
|
}
|
|
|
|
if (data is Map) {
|
|
return _buildMapResult(ext, data);
|
|
}
|
|
|
|
if (data is String) {
|
|
return SingleChildScrollView(child: AppMarkdownBody(data: data));
|
|
}
|
|
|
|
return _buildMapResult(ext, result);
|
|
}
|
|
|
|
Widget _buildListResult(AppThemeExtension ext, List<dynamic> data) {
|
|
if (data.length > 5) {
|
|
return _buildGroupedListResult(ext, data);
|
|
}
|
|
|
|
return ListView.separated(
|
|
itemCount: data.length,
|
|
separatorBuilder: (_, __) => const SizedBox(height: AppSpacing.sm),
|
|
itemBuilder: (context, index) {
|
|
final item = data[index];
|
|
if (item is Map<String, dynamic>) {
|
|
return _buildResultCard(ext, item);
|
|
}
|
|
return _buildSimpleCard(ext, item.toString());
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildGroupedListResult(AppThemeExtension ext, List<dynamic> data) {
|
|
final groups = <String, List<Map<String, dynamic>>>{};
|
|
|
|
for (final item in data) {
|
|
if (item is! Map<String, dynamic>) continue;
|
|
final pinyin = item['py']?.toString() ?? item['pinyin']?.toString() ?? '';
|
|
final letter = pinyin.isNotEmpty
|
|
? pinyin[0].toUpperCase()
|
|
: (item['zi']?.toString() ?? item['name']?.toString() ?? '#')[0]
|
|
.toUpperCase();
|
|
final key = regex(r'[A-Z]').hasMatch(letter) ? letter : '#';
|
|
groups.putIfAbsent(key, () => []).add(item);
|
|
}
|
|
|
|
final sortedKeys = groups.keys.toList()..sort();
|
|
|
|
return CustomScrollView(
|
|
slivers: [
|
|
for (final key in sortedKeys)
|
|
AppStickyHeaderSliver(
|
|
title: key,
|
|
emoji: '🔤',
|
|
trailing: '${groups[key]!.length}',
|
|
sliver: SliverPadding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: AppSpacing.md,
|
|
vertical: AppSpacing.xs,
|
|
),
|
|
sliver: SliverList(
|
|
delegate: SliverChildBuilderDelegate(
|
|
(context, index) => Padding(
|
|
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
|
|
child: _buildResultCard(ext, groups[key]![index]),
|
|
),
|
|
childCount: groups[key]!.length,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildMapResult(AppThemeExtension ext, Map<dynamic, dynamic> data) {
|
|
return SingleChildScrollView(
|
|
child: _buildResultCard(ext, Map<String, dynamic>.from(data)),
|
|
);
|
|
}
|
|
|
|
Widget _buildResultCard(AppThemeExtension ext, Map<String, dynamic> item) {
|
|
final entries = item.entries.where((e) {
|
|
final key = e.key.toLowerCase();
|
|
return key != 'id' && e.value != null && e.value.toString().isNotEmpty;
|
|
}).toList();
|
|
|
|
final shareText = entries
|
|
.map((e) => '${_formatLabel(e.key)}: ${e.value}')
|
|
.join('\n');
|
|
|
|
return AppSlidable(
|
|
slideKey: ValueKey(item.hashCode),
|
|
groupTag: 'hanzi_results',
|
|
rightActions: [
|
|
SlideActionConfig(
|
|
type: SlideActionType.share,
|
|
onPressed: () {
|
|
share_plus.SharePlus.instance.share(
|
|
share_plus.ShareParams(
|
|
text:
|
|
'${widget.config.emoji} ${widget.config.title}\n$shareText',
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
borderRadius: AppRadius.lgBorder,
|
|
child: Container(
|
|
padding: const EdgeInsets.all(AppSpacing.md),
|
|
decoration: BoxDecoration(
|
|
color: ext.bgCard,
|
|
borderRadius: AppRadius.lgBorder,
|
|
border: Border.all(
|
|
color: ext.textHint.withValues(alpha: 0.08),
|
|
width: 0.5,
|
|
),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: entries.map((entry) {
|
|
final label = _formatLabel(entry.key);
|
|
final value = entry.value;
|
|
|
|
if (value is List) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: AppTypography.caption1.copyWith(
|
|
color: ext.textSecondary,
|
|
),
|
|
),
|
|
const SizedBox(height: AppSpacing.xs),
|
|
Wrap(
|
|
spacing: AppSpacing.xs,
|
|
runSpacing: AppSpacing.xs,
|
|
children: value.map((v) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: AppSpacing.sm,
|
|
vertical: AppSpacing.xs,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: ext.bgSecondary,
|
|
borderRadius: AppRadius.smBorder,
|
|
),
|
|
child: Text(
|
|
v.toString().cleanHtml,
|
|
style: AppTypography.footnote.copyWith(
|
|
color: ext.textPrimary,
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(
|
|
width: 72,
|
|
child: Text(
|
|
label,
|
|
style: AppTypography.caption1.copyWith(
|
|
color: ext.textSecondary,
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Text(
|
|
value.toString().cleanHtml,
|
|
style: AppTypography.body.copyWith(
|
|
color: ext.textPrimary,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSimpleCard(AppThemeExtension ext, String text) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(AppSpacing.md),
|
|
decoration: BoxDecoration(
|
|
color: ext.bgCard,
|
|
borderRadius: AppRadius.lgBorder,
|
|
),
|
|
child: Text(
|
|
text.cleanHtml,
|
|
style: AppTypography.body.copyWith(color: ext.textPrimary),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _formatLabel(String key) {
|
|
const labelMap = {
|
|
'zi': '汉字',
|
|
'py': '拼音',
|
|
'pinyin': '拼音',
|
|
'bushou': '部首',
|
|
'bihua': '笔画',
|
|
'bishun': '笔顺',
|
|
'zuci': '组词',
|
|
'jijie': '简解',
|
|
'xiangjie': '详解',
|
|
'chengyu': '成语',
|
|
'jinyici': '近义词',
|
|
'fanyici': '反义词',
|
|
'juzi': '造句',
|
|
'nick': '网名',
|
|
'wubi': '五笔',
|
|
'cangjie': '仓颉',
|
|
'zhengma': '郑码',
|
|
'sijiao': '四角',
|
|
'uni': 'Unicode',
|
|
'name': '名称',
|
|
'title': '标题',
|
|
'content': '内容',
|
|
'desc': '描述',
|
|
'type': '类型',
|
|
'category': '分类',
|
|
'origin': '出处',
|
|
'author': '作者',
|
|
'meaning': '释义',
|
|
'example': '例句',
|
|
'source': '来源',
|
|
'color': '颜色',
|
|
'hex': 'HEX',
|
|
'rgb': 'RGB',
|
|
'cyjs': '成语解释',
|
|
'cyzy': '成语造句',
|
|
'cylz': '成语例句',
|
|
'sxckzle': '近反义词',
|
|
'czbj': '成语备注',
|
|
'chuchu': '出处',
|
|
'gushi': '典故',
|
|
'phonetic': '音标',
|
|
'translation': '翻译',
|
|
'full_name': '全称',
|
|
'abbreviation': '缩写',
|
|
'definition': '定义',
|
|
};
|
|
return labelMap[key.toLowerCase()] ?? key;
|
|
}
|
|
}
|