## 新增功能 - EPUB文件导入功能:支持文件选择、解析和存储 - 文件重复检测:避免重复导入同一文件 - 导入状态反馈:成功/失败消息提示 ## 模型扩展 - Book模型新增多作者支持(authors字段) - 新增章节数统计(chapterCount字段) - 新增语言标识(language字段) - 新增EPUB标识符(identifier字段) - 优化TypeAdapter序列化支持 ## 服务优化 - 新增EpubParserService:EPUB文件解析服务 - 改进DatabaseService:错误处理和数据迁移 - 优化BookRepository:调试日志和错误追踪 ## 依赖更新 - 新增epubx ^4.0.0:EPUB电子书解析库 - 更新pubspec.lock:同步依赖版本 ## UI改进 - AppHeader组件集成完整导入功能 - SafeArea适配:避免系统状态栏重叠 - 优化测试数据结构 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
234 lines
6.8 KiB
Dart
234 lines
6.8 KiB
Dart
import 'package:flutter/material.dart';
|
||
import '../pages/search_page.dart';
|
||
import '../services/epub_parser_service.dart';
|
||
import '../services/book_repository.dart';
|
||
import 'package:file_picker/file_picker.dart';
|
||
|
||
/// 应用顶部导航组件
|
||
///
|
||
/// 可复用的顶部导航栏组件,集成在搜索栏内的导入按钮。
|
||
/// 提供统一的搜索和文件导入功能,支持主题自适应。
|
||
class AppHeader extends StatefulWidget {
|
||
/// 标题文本(可选),某些页面可以显示标题而非搜索栏
|
||
final String? title;
|
||
|
||
/// 搜索按钮点击回调函数
|
||
final VoidCallback? onSearchPressed;
|
||
|
||
/// 导入按钮点击回调函数
|
||
final VoidCallback? onImportPressed;
|
||
|
||
/// 是否显示搜索栏(默认值为true)
|
||
final bool showSearchBar;
|
||
|
||
/// 搜索栏占位符文本
|
||
final String searchHint;
|
||
|
||
const AppHeader({
|
||
super.key,
|
||
this.title,
|
||
this.onSearchPressed,
|
||
this.onImportPressed,
|
||
this.showSearchBar = true,
|
||
this.searchHint = '搜索书名或内容...',
|
||
});
|
||
|
||
@override
|
||
State<AppHeader> createState() => _AppHeaderState();
|
||
}
|
||
|
||
class _AppHeaderState extends State<AppHeader> {
|
||
final EpubParserService _epubParserService = EpubParserService();
|
||
final BookRepository _bookRepository = BookRepository();
|
||
|
||
/// 构建组件的UI结构
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
height: 60,
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(context).colorScheme.surface,
|
||
border: Border(
|
||
bottom: BorderSide(
|
||
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
|
||
width: 1,
|
||
),
|
||
),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
// 标题区域(条件渲染)
|
||
if (widget.title != null) ...[
|
||
Text(
|
||
widget.title!,
|
||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
const SizedBox(width: 16),
|
||
],
|
||
|
||
// 搜索栏区域(占据剩余空间)
|
||
if (widget.showSearchBar) ...[
|
||
Expanded(
|
||
child: _buildSearchBar(context),
|
||
),
|
||
]
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 构建搜索栏组件,包含搜索图标、占位符文本和导入按钮
|
||
Widget _buildSearchBar(BuildContext context) {
|
||
return Container(
|
||
height: 44,
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(context).colorScheme.surfaceVariant,
|
||
borderRadius: BorderRadius.circular(22),
|
||
border: Border.all(
|
||
color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
|
||
),
|
||
),
|
||
child: Material(
|
||
color: Colors.transparent,
|
||
child: InkWell(
|
||
borderRadius: BorderRadius.circular(22),
|
||
onTap: widget.onSearchPressed ?? () => _navigateToSearchPage(context),
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
child: Row(
|
||
children: [
|
||
Icon(
|
||
Icons.search,
|
||
size: 24,
|
||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(
|
||
widget.searchHint,
|
||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||
color: Theme.of(context)
|
||
.colorScheme
|
||
.onSurfaceVariant
|
||
.withOpacity(0.6),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
_buildImportButton(context),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 构建导入按钮组件
|
||
Widget _buildImportButton(BuildContext context) {
|
||
return Container(
|
||
height: 32,
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(context).colorScheme.primaryContainer,
|
||
borderRadius: BorderRadius.circular(16),
|
||
),
|
||
child: Material(
|
||
color: Colors.transparent,
|
||
child: InkWell(
|
||
borderRadius: BorderRadius.circular(16),
|
||
onTap: widget.onImportPressed ?? () => _importEpubFile(),
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
'导入',
|
||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 导航到搜索页面
|
||
void _navigateToSearchPage(BuildContext context) {
|
||
Navigator.of(context).push(
|
||
MaterialPageRoute(
|
||
builder: (context) => const SearchPage(),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 导入EPUB文件
|
||
Future<void> _importEpubFile() async {
|
||
try {
|
||
// 1. 使用FilePicker选择EPUB文件
|
||
FilePickerResult? result = await FilePicker.platform.pickFiles(
|
||
type: FileType.custom,
|
||
allowedExtensions: ['epub'],
|
||
);
|
||
|
||
if (result != null && result.files.single.path != null) {
|
||
final filePath = result.files.single.path!;
|
||
|
||
// 2. 检查文件是否已存在
|
||
final existingBooks = await _bookRepository.getAllBooks();
|
||
final isDuplicate = existingBooks.any((book) => book.filePath == filePath);
|
||
|
||
if (isDuplicate) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(
|
||
content: Text('该文件已经导入过了'),
|
||
backgroundColor: Colors.orange,
|
||
),
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 3. 解析EPUB文件
|
||
final epubBook = await _epubParserService.parseEpubFile(filePath);
|
||
|
||
// 4. 提取元数据并创建Book对象
|
||
final book = await _epubParserService.extractBookMetadata(epubBook, filePath);
|
||
|
||
// 5. 保存到数据库
|
||
await _bookRepository.addBook(book);
|
||
|
||
// 6. 显示成功消息
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text('成功导入: ${book.title}'),
|
||
backgroundColor: Colors.green,
|
||
duration: const Duration(seconds: 2),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text('导入失败: $e'),
|
||
backgroundColor: Colors.red,
|
||
duration: const Duration(seconds: 3),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
|