From 7a2dcccf24b7c77f26a1ecaf1e6b8b9a4a7eaf3e Mon Sep 17 00:00:00 2001 From: DelLevin-Home Date: Sat, 4 Jul 2026 22:24:48 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=BE=AE=E4=BF=A1=E8=AF=BB?= =?UTF-8?q?=E4=B9=A6=E5=AF=BC=E5=85=A5=E6=91=98=E6=8A=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/images/imp_book/wxreader.webp | Bin 0 -> 6342 bytes lib/pages/book/book_excerpt_form_page.dart | 36 +- lib/pages/book/book_excerpt_share_page.dart | 267 ++++++++ lib/pages/book/book_excerpts_page.dart | 613 +++++++++++++++--- lib/pages/epub_reader/epub_detail_page.dart | 5 +- .../epub_reader/epub_highlights_page.dart | 20 +- .../epub_reader/highlight_detail_sheet.dart | 83 ++- .../epub_reader/highlight_share_page.dart | 209 ++++++ lib/pages/sync/webdav_sync_page.dart | 7 +- pubspec.yaml | 1 + 10 files changed, 1101 insertions(+), 140 deletions(-) create mode 100644 assets/images/imp_book/wxreader.webp create mode 100644 lib/pages/book/book_excerpt_share_page.dart create mode 100644 lib/pages/epub_reader/highlight_share_page.dart diff --git a/assets/images/imp_book/wxreader.webp b/assets/images/imp_book/wxreader.webp new file mode 100644 index 0000000000000000000000000000000000000000..8c2f64b147e6aa9f095230154c1ebf0da18976b5 GIT binary patch literal 6342 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 B2+IHf literal 0 HcmV?d00001 diff --git a/lib/pages/book/book_excerpt_form_page.dart b/lib/pages/book/book_excerpt_form_page.dart index 1dbe506..d5a1129 100644 --- a/lib/pages/book/book_excerpt_form_page.dart +++ b/lib/pages/book/book_excerpt_form_page.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import '../../providers/app_provider.dart'; import '../../models/data_models.dart'; import '../../utils/toast_util.dart'; +import 'book_excerpt_share_page.dart'; import 'package:uuid/uuid.dart'; /// 摘抄表单页面 - 新增/编辑摘抄 @@ -52,19 +53,9 @@ class _BookExcerptFormPageState extends State { } void _loadBookTitle() { - setState(() { - _bookTitle = _resolveBookTitle(widget.bookId); - }); - } - - String _resolveBookTitle(String bookId) { - try { - final provider = AppProvider(); - for (final b in provider.books) { - if (b.id == bookId) return b.title; - } - } catch (_) {} - return ''; + final provider = context.read(); + final book = provider.books.where((b) => b.id == widget.bookId).firstOrNull; + if (book != null) setState(() => _bookTitle = book.title); } @override @@ -88,11 +79,18 @@ class _BookExcerptFormPageState extends State { padding: EdgeInsets.all(14), child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)), ) - else + else ...[ + if (_isEditing) + IconButton( + icon: const Icon(Icons.ios_share), + tooltip: '分享', + onPressed: _shareExcerpt, + ), TextButton( onPressed: _saveExcerpt, child: Text('保存', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.primary)), ), + ], const SizedBox(width: 4), ], ), @@ -301,4 +299,14 @@ class _BookExcerptFormPageState extends State { if (mounted) setState(() => _isLoading = false); } } + + void _shareExcerpt() { + if (widget.excerpt == null) return; + final provider = context.read(); + final book = provider.books.where((b) => b.id == widget.bookId).firstOrNull; + if (book == null) return; + Navigator.push(context, MaterialPageRoute( + builder: (_) => BookExcerptSharePage(excerpt: widget.excerpt!, book: book), + )); + } } diff --git a/lib/pages/book/book_excerpt_share_page.dart b/lib/pages/book/book_excerpt_share_page.dart new file mode 100644 index 0000000..b89869b --- /dev/null +++ b/lib/pages/book/book_excerpt_share_page.dart @@ -0,0 +1,267 @@ +import 'dart:io'; +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; +import '../../models/data_models.dart'; +import '../../utils/toast_util.dart'; +import '../../widgets/fade_in_local_image.dart'; + +/// 摘抄分享海报页面 +class BookExcerptSharePage extends StatefulWidget { + final BookExcerpt excerpt; + final Book book; + + const BookExcerptSharePage({super.key, required this.excerpt, required this.book}); + + @override + State createState() => _BookExcerptSharePageState(); +} + +class _BookExcerptSharePageState extends State { + final GlobalKey _posterKey = GlobalKey(); + bool _isGenerating = false; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: colors.surfaceContainerHighest, + appBar: AppBar( + backgroundColor: colors.surface, + elevation: 0, + leading: IconButton( + icon: Icon(Icons.close, color: colors.onSurface), + onPressed: () => Navigator.pop(context), + ), + title: Text( + '分享摘抄', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface), + ), + centerTitle: true, + actions: [ + TextButton( + onPressed: _isGenerating ? null : _generateAndShare, + child: _isGenerating + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) + : Text('分享', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)), + ), + const SizedBox(width: 8), + ], + ), + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: RepaintBoundary( + key: _posterKey, + child: _buildPosterWidget(), + ), + ), + ), + ); + } + + Widget _buildPosterWidget() { + final colors = Theme.of(context).colorScheme; + final excerpt = widget.excerpt; + final book = widget.book; + final hasCover = book.coverPath != null && book.coverPath!.isNotEmpty; + final dateStr = '${excerpt.createdAt.year}.${excerpt.createdAt.month.toString().padLeft(2, '0')}.${excerpt.createdAt.day.toString().padLeft(2, '0')}'; + + return Container( + width: 320, + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 20, offset: const Offset(0, 10)), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 顶部:书籍信息条 + if (hasCover || book.title.isNotEmpty) + Container( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 0), + child: Row( + children: [ + // 小封面 + if (hasCover) + Container( + width: 36, + height: 50, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: colors.surfaceContainerHighest, + ), + clipBehavior: Clip.antiAlias, + child: FadeInLocalImage( + path: book.coverPath, + fit: BoxFit.cover, + errorWidget: const SizedBox.shrink(), + ), + ), + if (hasCover) const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + book.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface), + ), + if (book.authors.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + book.authors.join(' / '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4)), + ), + ], + ], + ), + ), + ], + ), + ), + + // 分隔线 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Container(height: 0.5, color: colors.outline), + ), + + // 摘抄内容 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 想法 + if (excerpt.comment.isNotEmpty) ...[ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.chat_bubble_outline, size: 14, color: colors.primary.withValues(alpha: 0.6)), + const SizedBox(width: 6), + Expanded( + child: Text( + excerpt.comment, + style: TextStyle( + fontSize: 13, + color: colors.onSurface.withValues(alpha: 0.5), + height: 1.7, + ), + ), + ), + ], + ), + const SizedBox(height: 14), + ], + // 引号 + 内容 + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '\u201C', + style: TextStyle( + fontSize: 28, + color: colors.primary.withValues(alpha: 0.4), + fontWeight: FontWeight.bold, + height: 1.1, + ), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + excerpt.content, + style: TextStyle( + fontSize: 15, + color: colors.onSurface.withValues(alpha: 0.85), + height: 1.9, + ), + ), + ), + ], + ), + ], + ), + ), + + // 底部:章节 + 日期 + 品牌 + Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 20), + child: Column( + children: [ + Container(height: 0.5, color: colors.outline), + const SizedBox(height: 12), + Row( + children: [ + if (excerpt.chapter.isNotEmpty) ...[ + Flexible( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: colors.primary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + excerpt.chapter, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 11, color: colors.primary.withValues(alpha: 0.7)), + ), + ), + ), + const SizedBox(width: 8), + ], + Text(dateStr, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))), + const Spacer(), + Text( + 'MookNote', + style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.25), fontWeight: FontWeight.w500), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } + + Future _generateAndShare() async { + setState(() => _isGenerating = true); + + try { + final boundary = _posterKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; + if (boundary == null) throw Exception('无法获取海报边界'); + + final image = await boundary.toImage(pixelRatio: 3.0); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + if (byteData == null) throw Exception('无法生成图片数据'); + + final bytes = byteData.buffer.asUint8List(); + final tempDir = await getTemporaryDirectory(); + final fileName = 'excerpt_share_${DateTime.now().millisecondsSinceEpoch}.png'; + final file = File('${tempDir.path}/$fileName'); + await file.writeAsBytes(bytes); + + await Share.shareXFiles( + [XFile(file.path)], + text: '分享摘抄:${widget.book.title}', + ); + } catch (e) { + if (mounted) ToastUtil.show(context, '生成海报失败:$e'); + } finally { + if (mounted) setState(() => _isGenerating = false); + } + } +} diff --git a/lib/pages/book/book_excerpts_page.dart b/lib/pages/book/book_excerpts_page.dart index b3656cb..25968ef 100644 --- a/lib/pages/book/book_excerpts_page.dart +++ b/lib/pages/book/book_excerpts_page.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../providers/app_provider.dart'; import '../../models/data_models.dart'; import '../../utils/toast_util.dart'; import '../../utils/epub/reader_dao.dart'; +import '../../widgets/fade_in_local_image.dart'; import 'book_excerpt_form_page.dart'; /// 书籍摘抄列表页面 @@ -42,31 +44,124 @@ class _BookExcerptsPageState extends State { } } + int get _thoughtCount => _excerpts.where((e) => e.comment.isNotEmpty).length; + @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; return Scaffold( backgroundColor: colors.surface, appBar: AppBar( - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('摘抄', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), - if (_excerpts.isNotEmpty) - Text('共 ${_excerpts.length} 条', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), - ], - ), + title: const Text('摘抄', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + actions: [ + IconButton( + icon: const Icon(Icons.note_add_outlined), + tooltip: '导入', + onPressed: _showImportSheet, + ), + IconButton( + icon: const Icon(Icons.delete_sweep_outlined), + tooltip: '删除全部', + onPressed: _excerpts.isEmpty ? null : _showDeleteAllDialog, + ), + ], ), + body: _isLoading + ? Center(child: CircularProgressIndicator(color: colors.primary)) + : Column( + children: [ + _buildBookHeader(colors), + Expanded( + child: _excerpts.isEmpty + ? _buildEmptyState(colors) + : _buildExcerptList(colors), + ), + ], + ), floatingActionButton: FloatingActionButton.extended( onPressed: _navigateToAddExcerpt, icon: const Icon(Icons.add, size: 20), label: const Text('添加摘抄'), ), - body: _isLoading - ? Center(child: CircularProgressIndicator(color: colors.primary)) - : _excerpts.isEmpty - ? _buildEmptyState(colors) - : _buildExcerptList(colors), + ); + } + + // ── 书籍头部 ── + + Widget _buildBookHeader(ColorScheme colors) { + final book = widget.book; + return Container( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 封面 + Container( + width: 52, + height: 70, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + color: colors.surfaceContainerHighest, + ), + clipBehavior: Clip.antiAlias, + child: book.coverPath != null && book.coverPath!.isNotEmpty + ? FadeInLocalImage( + path: book.coverPath, + fit: BoxFit.cover, + errorWidget: Icon(Icons.menu_book_outlined, size: 22, color: colors.onSurface.withValues(alpha: 0.25)), + ) + : Icon(Icons.menu_book_outlined, size: 22, color: colors.onSurface.withValues(alpha: 0.25)), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + book.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface), + ), + if (book.authors.isNotEmpty) ...[ + const SizedBox(height: 3), + Text( + book.authors.take(2).join('、'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)), + ), + ], + const SizedBox(height: 10), + // 统计行 + Row( + children: [ + _buildStatChip(colors, Icons.format_quote, '${_excerpts.length} 条摘抄'), + if (_thoughtCount > 0) ...[ + const SizedBox(width: 12), + _buildStatChip(colors, Icons.chat_bubble_outline, '$_thoughtCount 条想法'), + ], + ], + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildStatChip(ColorScheme colors, IconData icon, String label) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: colors.onSurface.withValues(alpha: 0.35)), + const SizedBox(width: 4), + Text( + label, + style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.45)), + ), + ], ); } @@ -103,7 +198,7 @@ class _BookExcerptsPageState extends State { Widget _buildExcerptList(ColorScheme colors) { final chapters = _groupExcerptsByChapter().keys.toList(); return ListView.builder( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 80), + padding: const EdgeInsets.fromLTRB(16, 4, 16, 80), itemCount: chapters.length, itemBuilder: (context, index) { final chapter = chapters[index]; @@ -130,7 +225,7 @@ class _BookExcerptsPageState extends State { Widget _buildChapterSection(String chapter, List excerpts, ColorScheme colors, int chapterIndex) { return Padding( - padding: EdgeInsets.only(bottom: 20), + padding: const EdgeInsets.only(bottom: 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -177,49 +272,55 @@ class _BookExcerptsPageState extends State { // ── 摘抄卡片 ── Widget _buildExcerptCard(BookExcerpt excerpt, ColorScheme colors) { - return Dismissible( - key: ValueKey(excerpt.id), - direction: DismissDirection.endToStart, - background: Container( - alignment: Alignment.centerRight, - padding: const EdgeInsets.only(right: 20), - decoration: BoxDecoration( - color: colors.error, - borderRadius: BorderRadius.circular(14), - ), - child: const Icon(Icons.delete_outline, color: Colors.white), - ), - confirmDismiss: (direction) => _showDeleteDialog(excerpt), - child: Material( - color: colors.surfaceContainerHigh, - borderRadius: BorderRadius.circular(14), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: () => _navigateToEditExcerpt(excerpt), - child: Container( + return Material( + color: colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => _navigateToEditExcerpt(excerpt), + onLongPress: () => _showDeleteDialog(excerpt), + child: Container( width: double.infinity, padding: const EdgeInsets.fromLTRB(16, 14, 14, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 引号 + 摘抄内容 - Stack( - children: [ - Positioned( - left: -6, - top: -6, - child: Text( - '"', - style: TextStyle( - fontSize: 32, - color: colors.primary.withValues(alpha: 0.15), - fontWeight: FontWeight.bold, - height: 1, + // 想法(在内容上方,无背景) + if (excerpt.comment.isNotEmpty) ...[ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.chat_bubble_outline, size: 13, color: colors.primary.withValues(alpha: 0.5)), + const SizedBox(width: 6), + Expanded( + child: Text( + excerpt.comment, + style: TextStyle( + fontSize: 12, + color: colors.onSurface.withValues(alpha: 0.5), + height: 1.6, + ), ), ), + ], + ), + const SizedBox(height: 10), + ], + // 摘抄内容 + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '\u201C', + style: TextStyle( + fontSize: 20, + color: colors.primary.withValues(alpha: 0.35), + fontWeight: FontWeight.bold, + height: 1.2, + ), ), - Padding( - padding: const EdgeInsets.only(left: 10), + const SizedBox(width: 4), + Expanded( child: Text( excerpt.content, style: TextStyle( @@ -231,30 +332,7 @@ class _BookExcerptsPageState extends State { ), ], ), - // 感悟 - if (excerpt.comment.isNotEmpty) ...[ - const SizedBox(height: 10), - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: colors.surface, - borderRadius: BorderRadius.circular(8), - border: Border( - left: BorderSide(color: colors.primary.withValues(alpha: 0.3), width: 2), - ), - ), - child: Text( - excerpt.comment, - style: TextStyle( - fontSize: 12, - color: colors.onSurface.withValues(alpha: 0.55), - height: 1.6, - ), - ), - ), - ], - // 底部:日期 + 操作 + // 底部:日期 const SizedBox(height: 8), Row( children: [ @@ -264,22 +342,13 @@ class _BookExcerptsPageState extends State { _formatDate(excerpt.createdAt), style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3)), ), - const Spacer(), - GestureDetector( - onTap: () => _navigateToEditExcerpt(excerpt), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - child: Icon(Icons.edit_outlined, size: 16, color: colors.onSurface.withValues(alpha: 0.35)), - ), - ), ], ), ], ), ), ), - ), - ); + ); } String _formatDate(DateTime date) => '${date.year}.${date.month.toString().padLeft(2, '0')}.${date.day.toString().padLeft(2, '0')}'; @@ -300,12 +369,16 @@ class _BookExcerptsPageState extends State { return AlertDialog( backgroundColor: colors.surface, elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), - title: const Text('删除摘抄', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), - content: Text('确定删除这条摘抄吗?', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6))), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + content: Text('确定删除这条摘抄吗?', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)), actions: [ - TextButton(onPressed: () => Navigator.pop(context, false), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.4)))), TextButton( + onPressed: () => Navigator.pop(context, false), + style: TextButton.styleFrom(foregroundColor: colors.onSurface.withValues(alpha: 0.6), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8)), + child: const Text('取消'), + ), + ElevatedButton( onPressed: () async { Navigator.pop(context, true); final readerBook = await ReaderDao().getReaderBookByBookId(widget.book.id); @@ -315,15 +388,373 @@ class _BookExcerptsPageState extends State { excerpt.content, ); } - await context.read().removeBookExcerpt(excerpt.id); + await this.context.read().removeBookExcerpt(excerpt.id); _loadExcerpts(); - if (mounted) ToastUtil.show(context, '已删除'); + if (mounted) ToastUtil.show(this.context, '已删除'); }, - child: Text('删除', style: TextStyle(color: colors.error, fontWeight: FontWeight.w600)), + style: ElevatedButton.styleFrom( + backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + child: const Text('删除'), ), ], ); }, ); } + + void _showDeleteAllDialog() { + final colors = Theme.of(context).colorScheme; + showDialog( + context: context, + builder: (context) { + return AlertDialog( + backgroundColor: colors.surface, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + content: Text('确定删除全部 ${_excerpts.length} 条摘抄吗?此操作不可恢复。', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + style: TextButton.styleFrom(foregroundColor: colors.onSurface.withValues(alpha: 0.6), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8)), + child: const Text('取消'), + ), + ElevatedButton( + onPressed: () async { + Navigator.pop(context); + final provider = this.context.read(); + for (final excerpt in _excerpts) { + await provider.removeBookExcerpt(excerpt.id); + } + _loadExcerpts(); + if (mounted) ToastUtil.show(this.context, '已删除全部摘抄'); + }, + style: ElevatedButton.styleFrom( + backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + child: const Text('删除全部'), + ), + ], + ); + }, + ); + } + + // ── 导入 ── + + void _showImportSheet() { + final colors = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (context) { + return Container( + decoration: BoxDecoration( + color: colors.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(top: 12), + decoration: BoxDecoration( + color: colors.outline, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + children: [ + Text('导入摘抄', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + ], + ), + ), + const SizedBox(height: 16), + // 微信读书 + InkWell( + onTap: () { + Navigator.pop(context); + _showWechatReadImport(); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: const Color(0xFF07C160).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.asset('assets/images/imp_book/wxreader.webp', width: 44, height: 44), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('微信读书', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500, color: colors.onSurface)), + const SizedBox(height: 2), + Text('粘贴微信读书导出的笔记', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4))), + ], + ), + ), + Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25), size: 20), + ], + ), + ), + ), + const SizedBox(height: 16), + ], + ), + ), + ); + }, + ); + } + + void _showWechatReadImport() { + final colors = Theme.of(context).colorScheme; + + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (sheetContext) { + final controller = TextEditingController(); + return Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(sheetContext).viewInsets.bottom), + child: Container( + constraints: BoxConstraints(maxHeight: MediaQuery.of(sheetContext).size.height * 0.75), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + child: SafeArea( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(top: 12), + decoration: BoxDecoration( + color: colors.outline, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + children: [ + Text('微信读书导入', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + const Spacer(), + TextButton( + onPressed: () async { + final data = await Clipboard.getData('text/plain'); + if (data?.text != null) { + controller.text = data!.text!; + } + }, + child: Text('粘贴', style: TextStyle(color: colors.primary, fontSize: 14)), + ), + ], + ), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: TextField( + controller: controller, + maxLines: 8, + decoration: InputDecoration( + hintText: '粘贴微信读书导出的笔记内容...', + hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.25)), + filled: true, + fillColor: colors.surfaceContainerHighest, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), + ), + style: TextStyle(fontSize: 14, color: colors.onSurface), + ), + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + final text = controller.text.trim(); + if (text.isEmpty) { + ToastUtil.show(sheetContext, '请粘贴内容'); + return; + } + Navigator.pop(sheetContext); + _importWechatRead(text); + }, + style: ElevatedButton.styleFrom( + backgroundColor: colors.primary, + foregroundColor: colors.onPrimary, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(vertical: 14), + ), + child: const Text('导入', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), + ), + ), + ), + const SizedBox(height: 16), + ], + ), + ), + ), + ), + ); + }, + ); + } + + void _importWechatRead(String text) { + final excerpts = _parseWechatRead(text); + if (excerpts.isEmpty) { + ToastUtil.show(context, '未识别到有效摘抄'); + return; + } + _saveImportedExcerpts(excerpts); + } + + /// 解析微信读书导出格式 + List<({String content, String comment, String chapter})> _parseWechatRead(String text) { + final lines = text.split(RegExp(r'\r?\n')); + final results = <({String content, String comment, String chapter})>[]; + String? currentComment; + bool waitingForOriginal = false; + String currentChapter = ''; + + for (int i = 0; i < lines.length; i++) { + final line = lines[i].trim(); + + // 跳过空行和结尾 + if (line.isEmpty || line.startsWith('-- 来自微信读书')) continue; + + // 跳过书名行(以《开头)和笔记数量行 + if (line.startsWith('《') || RegExp(r'^\d+个笔记$').hasMatch(line)) continue; + + // 匹配 "◆ YYYY/MM/DD发表想法" + final thoughtMatch = RegExp(r'^◆\s*\d{4}/\d{2}/\d{2}发表想法').firstMatch(line); + if (thoughtMatch != null) { + // 如果之前有未匹配原文的想法,先保存 + if (currentComment != null && !waitingForOriginal) { + results.add((content: currentComment, comment: '', chapter: currentChapter)); + currentComment = null; + } + // 想法行本身可能有内容在 "发表想法" 之后 + final afterThought = line.substring(thoughtMatch.end).trim(); + currentComment = afterThought.isNotEmpty ? afterThought : ''; + waitingForOriginal = true; + continue; + } + + // 匹配 "原文:..." + if (line.startsWith('原文:') || line.startsWith('原文:')) { + final originalText = line.substring(3).trim(); + if (originalText.isNotEmpty) { + results.add((content: originalText, comment: currentComment ?? '', chapter: currentChapter)); + currentComment = null; + waitingForOriginal = false; + } + continue; + } + + // 匹配普通划线 "◆ ..." + if (line.startsWith('◆ ')) { + // 如果之前有未匹配原文的想法,先保存想法本身 + if (currentComment != null && waitingForOriginal) { + if (currentComment.isNotEmpty) { + results.add((content: currentComment, comment: '', chapter: currentChapter)); + } + currentComment = null; + waitingForOriginal = false; + } + final content = line.substring(2).trim(); + if (content.isNotEmpty) { + results.add((content: content, comment: '', chapter: currentChapter)); + } + continue; + } + + // 多行想法内容(想法紧接在 "发表想法" 行之后) + if (currentComment != null && waitingForOriginal && line.isNotEmpty) { + // 检查是否是 "原文:" 行 + if (line.startsWith('原文:') || line.startsWith('原文:')) { + final originalText = line.substring(3).trim(); + if (originalText.isNotEmpty) { + results.add((content: originalText, comment: currentComment, chapter: currentChapter)); + } + currentComment = null; + waitingForOriginal = false; + } else { + // 追加到想法内容 + currentComment = currentComment.isEmpty ? line : '$currentComment\n$line'; + } + continue; + } + + // 其他非空行视为章节标题 + if (line.isNotEmpty) { + currentChapter = line; + } + } + + // 处理末尾残留 + if (currentComment != null && currentComment.isNotEmpty) { + results.add((content: currentComment, comment: '', chapter: currentChapter)); + } + + return results; + } + + Future _saveImportedExcerpts(List<({String content, String comment, String chapter})> excerpts) async { + final provider = context.read(); + final now = DateTime.now(); + int imported = 0; + + for (int i = 0; i < excerpts.length; i++) { + final item = excerpts[i]; + final excerpt = BookExcerpt( + id: '${widget.book.id}_${now.millisecondsSinceEpoch}_$i', + bookId: widget.book.id, + chapter: item.chapter, + content: item.content, + comment: item.comment, + createdAt: now, + updatedAt: now, + ); + await provider.addBookExcerpt(excerpt); + imported++; + } + + _loadExcerpts(); + if (mounted) { + ToastUtil.show(context, '成功导入 $imported 条摘抄'); + } + } } diff --git a/lib/pages/epub_reader/epub_detail_page.dart b/lib/pages/epub_reader/epub_detail_page.dart index 1657aa7..ecf4b66 100644 --- a/lib/pages/epub_reader/epub_detail_page.dart +++ b/lib/pages/epub_reader/epub_detail_page.dart @@ -456,9 +456,9 @@ class _EpubDetailPageState extends State { Row( children: [ Icon(Icons.menu_book_outlined, size: 14, color: colors.primary.withValues(alpha: 0.6)), - const SizedBox(width: 6), + const Spacer(), if (excerpt.chapter.isNotEmpty) - Expanded( + Flexible( child: Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( @@ -469,7 +469,6 @@ class _EpubDetailPageState extends State { excerpt.chapter, maxLines: 1, overflow: TextOverflow.ellipsis, - textAlign: TextAlign.right, style: TextStyle(fontSize: 9, color: colors.primary.withValues(alpha: 0.7), fontWeight: FontWeight.w500), ), ), diff --git a/lib/pages/epub_reader/epub_highlights_page.dart b/lib/pages/epub_reader/epub_highlights_page.dart index d6727aa..63aa2fb 100644 --- a/lib/pages/epub_reader/epub_highlights_page.dart +++ b/lib/pages/epub_reader/epub_highlights_page.dart @@ -270,13 +270,25 @@ class _EpubHighlightsPageState extends State { showDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text('删除句读', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), - content: Text('确定删除这条句读?', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6))), + backgroundColor: colors.surface, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + content: Text('确定删除这条句读?', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)), actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.4)))), TextButton( + onPressed: () => Navigator.pop(ctx), + style: TextButton.styleFrom(foregroundColor: colors.onSurface.withValues(alpha: 0.6), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8)), + child: const Text('取消'), + ), + ElevatedButton( onPressed: () { Navigator.pop(ctx); _deleteHighlight(id); }, - child: Text('删除', style: TextStyle(color: colors.error)), + style: ElevatedButton.styleFrom( + backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + child: const Text('删除'), ), ], ), diff --git a/lib/pages/epub_reader/highlight_detail_sheet.dart b/lib/pages/epub_reader/highlight_detail_sheet.dart index ecf0b33..b492061 100644 --- a/lib/pages/epub_reader/highlight_detail_sheet.dart +++ b/lib/pages/epub_reader/highlight_detail_sheet.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../../utils/toast_util.dart'; +import 'highlight_share_page.dart'; /// 句读详情弹窗 —— 类似分享卡片的样式 /// 从 epub_detail_page 和 epub_highlights_page 共用 @@ -50,7 +51,7 @@ void showHighlightDetailSheet( ), ), Padding( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 24), + padding: const EdgeInsets.fromLTRB(24, 20, 24, 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -128,13 +129,30 @@ void showHighlightDetailSheet( ), ], ), - const SizedBox(height: 24), - // 分隔线 - Container(height: 0.5, color: colors.outlineVariant), - const SizedBox(height: 16), + const SizedBox(height: 20), // 操作按钮 Row( children: [ + _buildActionButton( + colors, + icon: Icons.ios_share_outlined, + label: '分享', + onTap: () { + Navigator.pop(ctx); + String chapterLabel = ''; + if (chapterNum != null) chapterLabel = '第${chapterNum + 1}章'; + String dateLabel = ''; + if (createdAt.isNotEmpty) dateLabel = _formatDate(createdAt); + Navigator.push(context, MaterialPageRoute( + builder: (_) => HighlightSharePage( + content: content, + bookTitle: bookTitle, + chapter: chapterLabel, + dateStr: dateLabel, + ), + )); + }, + ), _buildActionButton( colors, icon: Icons.content_copy_outlined, @@ -264,7 +282,7 @@ void showExcerptDetailSheet( ), Flexible( child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 24), + padding: const EdgeInsets.fromLTRB(24, 20, 24, 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -330,24 +348,22 @@ void showExcerptDetailSheet( // 感悟 if (comment.isNotEmpty) ...[ const SizedBox(height: 16), - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: colors.surfaceContainerHigh, - borderRadius: BorderRadius.circular(10), - border: Border( - left: BorderSide(color: colors.primary.withValues(alpha: 0.4), width: 2), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.chat_bubble_outline, size: 14, color: colors.primary.withValues(alpha: 0.5)), + const SizedBox(width: 6), + Expanded( + child: Text( + comment, + style: TextStyle( + fontSize: 13, + color: colors.onSurface.withValues(alpha: 0.5), + height: 1.6, + ), + ), ), - ), - child: Text( - comment, - style: TextStyle( - fontSize: 13, - color: colors.onSurface.withValues(alpha: 0.6), - height: 1.6, - ), - ), + ], ), ], const SizedBox(height: 16), @@ -362,13 +378,26 @@ void showExcerptDetailSheet( ), ], ), - const SizedBox(height: 24), - // 分隔线 - Container(height: 0.5, color: colors.outlineVariant), - const SizedBox(height: 16), + const SizedBox(height: 20), // 操作按钮 Row( children: [ + _buildActionButton( + colors, + icon: Icons.ios_share_outlined, + label: '分享', + onTap: () { + Navigator.pop(ctx); + Navigator.push(context, MaterialPageRoute( + builder: (_) => HighlightSharePage( + content: content, + bookTitle: bookTitle, + chapter: chapter, + dateStr: _formatDateFromDateTime(createdAt), + ), + )); + }, + ), _buildActionButton( colors, icon: Icons.content_copy_outlined, diff --git a/lib/pages/epub_reader/highlight_share_page.dart b/lib/pages/epub_reader/highlight_share_page.dart new file mode 100644 index 0000000..f2e80ec --- /dev/null +++ b/lib/pages/epub_reader/highlight_share_page.dart @@ -0,0 +1,209 @@ +import 'dart:io'; +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; +import '../../utils/toast_util.dart'; + +/// 句读分享海报页面 +class HighlightSharePage extends StatefulWidget { + final String content; + final String bookTitle; + final String chapter; + final String dateStr; + + const HighlightSharePage({ + super.key, + required this.content, + required this.bookTitle, + this.chapter = '', + this.dateStr = '', + }); + + @override + State createState() => _HighlightSharePageState(); +} + +class _HighlightSharePageState extends State { + final GlobalKey _posterKey = GlobalKey(); + bool _isGenerating = false; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: colors.surfaceContainerHighest, + appBar: AppBar( + backgroundColor: colors.surface, + elevation: 0, + leading: IconButton( + icon: Icon(Icons.close, color: colors.onSurface), + onPressed: () => Navigator.pop(context), + ), + title: Text( + '分享句读', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface), + ), + centerTitle: true, + actions: [ + TextButton( + onPressed: _isGenerating ? null : _generateAndShare, + child: _isGenerating + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) + : Text('分享', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)), + ), + const SizedBox(width: 8), + ], + ), + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: RepaintBoundary( + key: _posterKey, + child: _buildPosterWidget(), + ), + ), + ), + ); + } + + Widget _buildPosterWidget() { + final colors = Theme.of(context).colorScheme; + + return Container( + width: 320, + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 20, offset: const Offset(0, 10)), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 顶部:书名 + Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 0), + child: Row( + children: [ + Icon(Icons.book_outlined, size: 16, color: const Color(0xFFFFC107)), + const SizedBox(width: 8), + Expanded( + child: Text( + widget.bookTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface), + ), + ), + ], + ), + ), + + // 分隔线 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Container(height: 0.5, color: colors.outline), + ), + + // 引号 + 内容 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '\u201C', + style: TextStyle( + fontSize: 28, + color: const Color(0xFFFFC107).withValues(alpha: 0.4), + fontWeight: FontWeight.bold, + height: 1.1, + ), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + widget.content, + style: TextStyle( + fontSize: 15, + color: colors.onSurface.withValues(alpha: 0.85), + height: 1.9, + ), + ), + ), + ], + ), + ), + + // 底部:章节 + 日期 + 品牌 + Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 20), + child: Column( + children: [ + Container(height: 0.5, color: colors.outline), + const SizedBox(height: 12), + Row( + children: [ + if (widget.chapter.isNotEmpty) ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFFFEB3B).withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + widget.chapter, + style: const TextStyle(fontSize: 11, color: Color(0xFF795548), fontWeight: FontWeight.w500), + ), + ), + const SizedBox(width: 8), + ], + if (widget.dateStr.isNotEmpty) + Text(widget.dateStr, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))), + const Spacer(), + Text( + 'MookNote', + style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.25), fontWeight: FontWeight.w500), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } + + Future _generateAndShare() async { + setState(() => _isGenerating = true); + + try { + final boundary = _posterKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; + if (boundary == null) throw Exception('无法获取海报边界'); + + final image = await boundary.toImage(pixelRatio: 3.0); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + if (byteData == null) throw Exception('无法生成图片数据'); + + final bytes = byteData.buffer.asUint8List(); + final tempDir = await getTemporaryDirectory(); + final fileName = 'highlight_share_${DateTime.now().millisecondsSinceEpoch}.png'; + final file = File('${tempDir.path}/$fileName'); + await file.writeAsBytes(bytes); + + await Share.shareXFiles( + [XFile(file.path)], + text: '分享句读:${widget.bookTitle}', + ); + } catch (e) { + if (mounted) ToastUtil.show(context, '生成海报失败:$e'); + } finally { + if (mounted) setState(() => _isGenerating = false); + } + } +} diff --git a/lib/pages/sync/webdav_sync_page.dart b/lib/pages/sync/webdav_sync_page.dart index f5c4c1c..bc5a224 100644 --- a/lib/pages/sync/webdav_sync_page.dart +++ b/lib/pages/sync/webdav_sync_page.dart @@ -588,7 +588,12 @@ class _WebDAVSyncPageState extends State { Text('云端备份', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface)), const Spacer(), if (_isLoadingRemoteInfo) - SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: colors.primary)), + SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: colors.primary)) + else + GestureDetector( + onTap: _loadRemoteInfo, + child: Icon(Icons.refresh, size: 18, color: colors.onSurface.withValues(alpha: 0.4)), + ), ], ), const SizedBox(height: 12), diff --git a/pubspec.yaml b/pubspec.yaml index 99acc80..bcd6684 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -63,3 +63,4 @@ flutter: - assets/images/ - assets/icon/ - assets/images/ticket/ + - assets/images/imp_book/