diff --git a/lib/models/data_models.dart b/lib/models/data_models.dart index d4bcbc1..37802da 100644 --- a/lib/models/data_models.dart +++ b/lib/models/data_models.dart @@ -200,6 +200,8 @@ class Book { final String status; // read/reading/want_to_read final String? isbn; // ISBN编号 final DateTime? publishDate; // 出版时间 + final DateTime? startDate; // 开始阅读日期 + final DateTime? finishDate; // 读完日期 final DateTime createdAt; final DateTime updatedAt; final bool isDeleted; @@ -218,6 +220,8 @@ class Book { required this.status, this.isbn, this.publishDate, + this.startDate, + this.finishDate, required this.createdAt, required this.updatedAt, this.isDeleted = false, @@ -238,6 +242,8 @@ class Book { status: json['status'] ?? 'want_to_read', isbn: json['isbn'], publishDate: _safeParseDate(json['publish_date']), + startDate: _safeParseDate(json['start_date']), + finishDate: _safeParseDate(json['finish_date']), createdAt: _safeParseDate(json['created_at'], fallback: DateTime.now())!, updatedAt: _safeParseDate(json['updated_at'], fallback: DateTime.now())!, isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true, @@ -259,6 +265,8 @@ class Book { 'status': status, 'isbn': isbn, 'publish_date': publishDate?.toUtc().toIso8601String(), + 'start_date': startDate?.toUtc().toIso8601String(), + 'finish_date': finishDate?.toUtc().toIso8601String(), 'created_at': createdAt.toUtc().toIso8601String(), 'updated_at': updatedAt.toUtc().toIso8601String(), 'is_deleted': isDeleted ? 1 : 0, @@ -286,6 +294,8 @@ class Book { String? status, Object? isbn = _copyWithNull, DateTime? publishDate, + DateTime? startDate, + DateTime? finishDate, DateTime? createdAt, DateTime? updatedAt, bool? isDeleted, @@ -304,6 +314,8 @@ class Book { status: status ?? this.status, isbn: isbn is _CopyWithNullSentinel ? this.isbn : (isbn as String?), publishDate: publishDate ?? this.publishDate, + startDate: startDate ?? this.startDate, + finishDate: finishDate ?? this.finishDate, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, isDeleted: isDeleted ?? this.isDeleted, diff --git a/lib/pages/book/book_detail_page.dart b/lib/pages/book/book_detail_page.dart index a6086ec..4828307 100644 --- a/lib/pages/book/book_detail_page.dart +++ b/lib/pages/book/book_detail_page.dart @@ -93,6 +93,7 @@ class _BookDetailPageState extends State { if (book.isbn != null && book.isbn!.isNotEmpty) _buildIsbnSection(book), if (book.publisher != null && book.publisher!.isNotEmpty) _buildPublisherSection(book), if (book.publishDate != null) _buildPublishDateSection(book), + if (book.startDate != null || book.finishDate != null) _buildReadingDatesSection(book), Divider(height: 0.5, thickness: 0.5, color: colors.outline), if (book.summary != null && book.summary!.isNotEmpty) _buildSummarySection(book), Divider(height: 0.5, thickness: 0.5, color: colors.outline), @@ -192,6 +193,7 @@ class _BookDetailPageState extends State { if (book.isbn != null && book.isbn!.isNotEmpty) _buildIsbnSection(book), if (book.publisher != null && book.publisher!.isNotEmpty) _buildPublisherSection(book), if (book.publishDate != null) _buildPublishDateSection(book), + if (book.startDate != null || book.finishDate != null) _buildReadingDatesSection(book), // 类型标签毛玻璃 if (book.genres.isNotEmpty) _buildGenresSection(book), // 简介:内部已有毛玻璃卡片 @@ -855,6 +857,59 @@ class _BookDetailPageState extends State { ); } + Widget _buildReadingDatesSection(Book book) { + final isOverlay = _detailStyle == 1; + final colors = Theme.of(context).colorScheme; + return Padding( + padding: EdgeInsets.symmetric(horizontal: 24, vertical: isOverlay ? 5 : 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 64, + child: Text( + '阅读日期', + style: TextStyle( + fontSize: 13, + color: isOverlay ? const Color(0x66FFFFFF) : colors.onSurface.withValues(alpha: 0.4), + ), + ), + ), + Expanded( + child: Wrap( + spacing: 12, + runSpacing: 8, + children: [ + if (book.startDate != null) + _buildDateChip('开始', book.startDate!, isOverlay), + if (book.finishDate != null) + _buildDateChip('读完', book.finishDate!, isOverlay), + ], + ), + ), + ], + ), + ); + } + + Widget _buildDateChip(String label, DateTime date, bool isOverlay) { + final colors = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: isOverlay ? Colors.white.withValues(alpha: 0.12) : colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '$label ${_formatDate(date)}', + style: TextStyle( + fontSize: 13, + color: isOverlay ? Colors.white.withValues(alpha: 0.85) : colors.onSurface.withValues(alpha: 0.7), + ), + ), + ); + } + Widget _buildGenresSection(Book book) { final isOverlay = _detailStyle == 1; return Padding( diff --git a/lib/pages/book/book_form_page.dart b/lib/pages/book/book_form_page.dart index 3f24edf..28ad946 100644 --- a/lib/pages/book/book_form_page.dart +++ b/lib/pages/book/book_form_page.dart @@ -48,6 +48,8 @@ class _BookFormPageState extends State { String? _coverPath; String _status = 'want_to_read'; DateTime? _publishDate; + DateTime? _startDate; + DateTime? _finishDate; bool _isDownloading = false; @override @@ -77,6 +79,8 @@ class _BookFormPageState extends State { _coverPath = book.coverPath; _status = book.status; _publishDate = book.publishDate; + _startDate = book.startDate; + _finishDate = book.finishDate; } else if (widget.initialStatus != null) { _status = widget.initialStatus!; } @@ -208,6 +212,14 @@ class _BookFormPageState extends State { ), ), + // 开始阅读日期 + 读完日期(同一行) + _halfCard('开始阅读', _startDate != null ? '${_startDate!.year}.${_startDate!.month.toString().padLeft(2, '0')}.${_startDate!.day.toString().padLeft(2, '0')}' : '', Icons.play_circle_outlined, + onTap: () => _selectStartDate(), + ), + _halfCard('读完日期', _finishDate != null ? '${_finishDate!.year}.${_finishDate!.month.toString().padLeft(2, '0')}.${_finishDate!.day.toString().padLeft(2, '0')}' : '', Icons.check_circle_outlined, + onTap: () => _selectFinishDate(), + ), + // 书籍简介 SizedBox( width: double.infinity, @@ -536,6 +548,16 @@ class _BookFormPageState extends State { if (picked != null) setState(() => _publishDate = picked); } + Future _selectStartDate() async { + final picked = await showDatePicker(context: context, initialDate: _startDate ?? DateTime.now(), firstDate: DateTime(1900), lastDate: DateTime.now().add(const Duration(days: 365 * 5))); + if (picked != null) setState(() => _startDate = picked); + } + + Future _selectFinishDate() async { + final picked = await showDatePicker(context: context, initialDate: _finishDate ?? DateTime.now(), firstDate: DateTime(1900), lastDate: DateTime.now().add(const Duration(days: 365 * 5))); + if (picked != null) setState(() => _finishDate = picked); + } + Future _editSummary() async { final result = await Navigator.push(context, MaterialPageRoute(builder: (_) => _SummaryEditorPage(initialText: _summaryController.text))); if (result != null) setState(() => _summaryController.text = result); @@ -551,6 +573,8 @@ class _BookFormPageState extends State { if (_coverPath != null) return true; if (_authors.isNotEmpty || _alternateTitles.isNotEmpty || _genres.isNotEmpty) return true; if (_publishDate != null) return true; + if (_startDate != null) return true; + if (_finishDate != null) return true; return false; } @@ -594,7 +618,7 @@ class _BookFormPageState extends State { authors: _authors, alternateTitles: _alternateTitles, publisher: _publisherController.text.trim(), genres: _genres, summary: _summaryController.text.trim(), rating: rating, status: _status, isbn: _isbnController.text.trim().isNotEmpty ? _isbnController.text.trim() : null, - publishDate: _publishDate, createdAt: now, updatedAt: now, + publishDate: _publishDate, startDate: _startDate, finishDate: _finishDate, createdAt: now, updatedAt: now, ); await context.read().addBook(newBook); } else { @@ -603,7 +627,7 @@ class _BookFormPageState extends State { authors: _authors, alternateTitles: _alternateTitles, publisher: _publisherController.text.trim(), genres: _genres, summary: _summaryController.text.trim(), rating: rating, status: _status, isbn: _isbnController.text.trim().isNotEmpty ? _isbnController.text.trim() : null, - publishDate: _publishDate, updatedAt: now, + publishDate: _publishDate, startDate: _startDate, finishDate: _finishDate, updatedAt: now, ); await context.read().updateBook(updatedBook); } diff --git a/lib/pages/epub_reader/epub_detail_page.dart b/lib/pages/epub_reader/epub_detail_page.dart index 2fe7b1d..1657aa7 100644 --- a/lib/pages/epub_reader/epub_detail_page.dart +++ b/lib/pages/epub_reader/epub_detail_page.dart @@ -44,12 +44,14 @@ class _EpubDetailPageState extends State { bool _descriptionExpanded = false; Future>? _excerptsFuture; Future>>? _highlightsFuture; + String? _linkedBookCoverPath; @override void initState() { super.initState(); _book = widget.book; _loadBookInfo(); + _loadLinkedBookCover(); final linkedBookId = _book['book_id'] as String? ?? ''; if (linkedBookId.isNotEmpty) { _excerptsFuture = BookExcerptDao().getExcerptsByBookId(linkedBookId); @@ -64,12 +66,22 @@ class _EpubDetailPageState extends State { if (mounted && info != null) setState(() => _bookInfo = info); } + Future _loadLinkedBookCover() async { + final linkedBookId = _book['book_id'] as String? ?? ''; + if (linkedBookId.isEmpty) return; + final book = await _bookDao.getBookById(linkedBookId); + if (mounted && book != null && book.coverPath != null && book.coverPath!.isNotEmpty) { + setState(() => _linkedBookCoverPath = book.coverPath); + } + } + Future _refreshBook() async { final updated = await _dao.getReaderBookById(widget.bookId); if (mounted && updated != null) { final linkedBookId = updated['book_id'] as String? ?? ''; setState(() { _book = updated; + _linkedBookCoverPath = null; _highlightsFuture = _dao.getHighlightsByBookId(widget.bookId); if (linkedBookId.isNotEmpty) { _excerptsFuture = BookExcerptDao().getExcerptsByBookId(linkedBookId); @@ -77,10 +89,12 @@ class _EpubDetailPageState extends State { _excerptsFuture = null; } }); + _loadLinkedBookCover(); } } void _navigateToReader() { + final coverPath = _linkedBookCoverPath ?? _book['cover_path'] as String?; Navigator.push( context, MaterialPageRoute( @@ -88,7 +102,7 @@ class _EpubDetailPageState extends State { bookId: _book['id'] as String, filePath: _book['file_path'] as String, title: _book['title'] as String? ?? '', - coverPath: _book['cover_path'] as String?, + coverPath: coverPath, bookData: _book, ), ), @@ -113,7 +127,7 @@ class _EpubDetailPageState extends State { final progress = (_book['reading_percentage'] as num?)?.toDouble() ?? 0.0; final title = _book['title'] as String? ?? ''; final author = _book['author'] as String? ?? ''; - final coverPath = _book['cover_path'] as String?; + final coverPath = _linkedBookCoverPath ?? _book['cover_path'] as String?; return Scaffold( backgroundColor: colors.surface, @@ -755,6 +769,7 @@ class _EpubDetailPageState extends State { final chapter = int.tryParse(highlight['chapter'] as String? ?? '') ?? 0; final xpath = _extractStartXPath(highlight['cfi'] as String? ?? ''); final text = highlight['content'] as String? ?? ''; + final coverPath = _linkedBookCoverPath ?? _book['cover_path'] as String?; Navigator.push( context, MaterialPageRoute( @@ -762,7 +777,7 @@ class _EpubDetailPageState extends State { bookId: _book['id'] as String, filePath: _book['file_path'] as String, title: _book['title'] as String? ?? '', - coverPath: _book['cover_path'] as String?, + coverPath: coverPath, bookData: _book, initialSpineIndex: chapter, scrollToXPath: xpath, diff --git a/lib/pages/profile_page.dart b/lib/pages/profile_page.dart index 9c8e53c..dc05618 100644 --- a/lib/pages/profile_page.dart +++ b/lib/pages/profile_page.dart @@ -844,6 +844,62 @@ class _ProfilePageState extends State with RouteAware { ), ), const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.group_outlined, + size: 20, color: colors.primary.withValues(alpha: 0.8)), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('QQ 群', + style: TextStyle( + fontSize: 12, + color: colors.onSurface.withValues(alpha: 0.5))), + const SizedBox(height: 2), + Text('1087203310', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: colors.onSurface)), + ], + ), + ), + GestureDetector( + onTap: () { + Clipboard.setData(ClipboardData(text: '1087203310')); + ToastUtil.show(context, '已复制到剪贴板'); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: colors.primary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.copy, size: 14, color: colors.primary), + const SizedBox(width: 4), + Text('复制', style: TextStyle(fontSize: 12, color: colors.primary, fontWeight: FontWeight.w600)), + ], + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), ]), ), ); diff --git a/lib/utils/database_helper.dart b/lib/utils/database_helper.dart index 803ed4f..61acdad 100644 --- a/lib/utils/database_helper.dart +++ b/lib/utils/database_helper.dart @@ -72,7 +72,7 @@ class DatabaseHelper { return await openDatabase( path, - version: 26, + version: 27, onCreate: _createDB, onUpgrade: _onUpgrade, ); @@ -245,6 +245,40 @@ class DatabaseHelper { await db.execute("ALTER TABLE movies ADD COLUMN category TEXT NOT NULL DEFAULT 'movie'"); } } + if (oldVersion < 26) { + await _upgradeBooksTableV26(db); + } + if (oldVersion < 27) { + await _upgradeBooksTableV27(db); + } + } + + /// 升级books表到V27(添加阅读始末日期字段) + Future _upgradeBooksTableV27(Database db) async { + final columns = await db.rawQuery('PRAGMA table_info(books)'); + final hasStartDate = columns.any((col) => col['name'] == 'start_date'); + final hasFinishDate = columns.any((col) => col['name'] == 'finish_date'); + + if (!hasStartDate) { + await db.execute('ALTER TABLE books ADD COLUMN start_date TEXT'); + } + if (!hasFinishDate) { + await db.execute('ALTER TABLE books ADD COLUMN finish_date TEXT'); + } + } + + /// 升级books表到V26(添加阅读始末日期字段) + Future _upgradeBooksTableV26(Database db) async { + final columns = await db.rawQuery('PRAGMA table_info(books)'); + final hasStartDate = columns.any((col) => col['name'] == 'start_date'); + final hasFinishDate = columns.any((col) => col['name'] == 'finish_date'); + + if (!hasStartDate) { + await db.execute('ALTER TABLE books ADD COLUMN start_date TEXT'); + } + if (!hasFinishDate) { + await db.execute('ALTER TABLE books ADD COLUMN finish_date TEXT'); + } } /// 升级books表到V11(添加ISBN和出版时间字段) @@ -635,6 +669,8 @@ class DatabaseHelper { status TEXT NOT NULL, isbn TEXT, publish_date TEXT, + start_date TEXT, + finish_date TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, is_deleted INTEGER DEFAULT 0, diff --git a/lib/utils/epub/reader_dao.dart b/lib/utils/epub/reader_dao.dart index b296da4..2194e6b 100644 --- a/lib/utils/epub/reader_dao.dart +++ b/lib/utils/epub/reader_dao.dart @@ -6,14 +6,27 @@ class ReaderDao { // ─── reader_books ───────────────────────────────────────────────── - /// 获取所有未删除的阅读记录 + /// 获取所有未删除的阅读记录(包含关联书籍封面) Future>> getAllReaderBooks() async { final db = await _db.database; - return db.query( - 'reader_books', - where: 'is_deleted = 0', - orderBy: 'updated_at DESC', - ); + final results = await db.rawQuery(''' + SELECT rb.*, + COALESCE(b.cover_path, rb.cover_path) as display_cover_path + FROM reader_books rb + LEFT JOIN books b ON rb.book_id = b.id AND b.is_deleted = 0 + WHERE rb.is_deleted = 0 + ORDER BY rb.updated_at DESC + '''); + return results.map((row) { + final map = Map.from(row); + // 如果有关联封面,覆盖 cover_path 供 UI 使用 + final displayCover = map['display_cover_path'] as String?; + if (displayCover != null && displayCover.isNotEmpty) { + map['cover_path'] = displayCover; + } + map.remove('display_cover_path'); + return map; + }).toList(); } /// 根据 ID 获取阅读记录