diff --git a/assets/images/imp_book/wxreader.webp b/assets/images/imp_book/wxreader.webp new file mode 100644 index 0000000..8c2f64b Binary files /dev/null and b/assets/images/imp_book/wxreader.webp differ 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/