From 0c18df3943631530e12ea47def1a82cfffdd8877 Mon Sep 17 00:00:00 2001 From: DelLevin-Home Date: Sun, 22 Mar 2026 15:54:28 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BC=96=E8=BE=91=E7=95=8C=E9=9D=A2=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/book/book_detail_page.dart | 207 ++-- lib/pages/book/book_form_page.dart | 745 +++++++++++---- lib/pages/book/book_share_page.dart | 319 +++++++ lib/pages/movies/douban_webview_page.dart | 2 + lib/pages/movies/movie_detail_page.dart | 215 +++-- lib/pages/movies/movie_form_page.dart | 1054 ++++++++++++++++----- lib/pages/movies/movie_share_page.dart | 320 +++++++ lib/pages/note/note_tab_page.dart | 15 +- pubspec.yaml | 2 +- 9 files changed, 2284 insertions(+), 595 deletions(-) create mode 100644 lib/pages/book/book_share_page.dart create mode 100644 lib/pages/movies/movie_share_page.dart diff --git a/lib/pages/book/book_detail_page.dart b/lib/pages/book/book_detail_page.dart index 306317f..5d3d6e9 100644 --- a/lib/pages/book/book_detail_page.dart +++ b/lib/pages/book/book_detail_page.dart @@ -8,6 +8,7 @@ import '../../models/data_models.dart'; import '../../utils/toast_util.dart'; import 'book_reviews_page.dart'; import 'book_excerpts_page.dart'; +import 'book_share_page.dart'; /// 书籍详情页 - 极简主义设计 class BookDetailPage extends StatefulWidget { @@ -42,90 +43,128 @@ class _BookDetailPageState extends State { return Scaffold( backgroundColor: Colors.white, - body: CustomScrollView( - slivers: [ - // 顶部封面区域 - _buildSliverAppBar(book), + body: Stack( + children: [ + CustomScrollView( + slivers: [ + // 顶部封面区域 + _buildSliverAppBar(book), - // 内容区域 - SliverToBoxAdapter( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 基本信息 - _buildBasicInfo(book), - - const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)), - - // 作者信息 - _buildAuthorsSection(book), + // 内容区域 + SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 基本信息 + _buildBasicInfo(book), + + const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)), + + // 作者信息 + _buildAuthorsSection(book), - // 类型 - if (book.genres.isNotEmpty) - _buildGenresSection(book), + // 类型 + if (book.genres.isNotEmpty) + _buildGenresSection(book), - // ISBN - if (book.isbn != null && book.isbn!.isNotEmpty) - _buildIsbnSection(book), + // ISBN + if (book.isbn != null && book.isbn!.isNotEmpty) + _buildIsbnSection(book), - // 出版社 - if (book.publisher != null && book.publisher!.isNotEmpty) - _buildPublisherSection(book), + // 出版社 + if (book.publisher != null && book.publisher!.isNotEmpty) + _buildPublisherSection(book), - // 出版时间 - if (book.publishDate != null) - _buildPublishDateSection(book), - - const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)), - - // 简介 - if (book.summary != null && book.summary!.isNotEmpty) - _buildSummarySection(book), + // 出版时间 + if (book.publishDate != null) + _buildPublishDateSection(book), + + const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)), + + // 简介 + if (book.summary != null && book.summary!.isNotEmpty) + _buildSummarySection(book), - const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)), + const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)), - // 书评和摘抄入口 - _buildExtraSections(book), + // 书评和摘抄入口 + _buildExtraSections(book), - const SizedBox(height: 48), - ], - ), + const SizedBox(height: 120), + ], + ), + ), + ], + ), + + // 右下角悬浮按钮组 + Positioned( + right: 16, + bottom: 24, + child: _buildFloatingActionButtons(book), ), ], ), - - // 底部无操作栏,编辑和删除在右上角 ); } - /// 构建右上角操作按钮 - Widget _buildActionButton({ + /// 构建右下角悬浮按钮组 + Widget _buildFloatingActionButtons(Book book) { + final hasCover = book.coverPath != null && book.coverPath!.isNotEmpty; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 删除按钮 + _buildFloatingButton( + icon: Icons.delete_outline, + onPressed: () => _showDeleteDialog(context), + tooltip: '删除', + backgroundColor: Colors.red, + ), + const SizedBox(height: 12), + // 清空封面按钮(仅当有封面时显示) + if (hasCover) ...[ + _buildFloatingButton( + icon: Icons.hide_image_outlined, + onPressed: () => _showClearCoverDialog(book), + tooltip: '清空封面', + ), + const SizedBox(height: 12), + ], + // 编辑按钮 + _buildFloatingButton( + icon: Icons.edit_outlined, + onPressed: () => _navigateToEdit(context), + tooltip: '编辑', + ), + const SizedBox(height: 12), + // 分享按钮 + _buildFloatingButton( + icon: Icons.share_outlined, + onPressed: () => _showSharePoster(book), + tooltip: '分享海报', + backgroundColor: const Color(0xFF4CAF50), + ), + ], + ); + } + + /// 构建单个悬浮按钮 + Widget _buildFloatingButton({ required IconData icon, required VoidCallback onPressed, required String tooltip, - Color color = const Color(0xFF666666), + Color backgroundColor = const Color(0xFF1A1A1A), }) { - return Container( - margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.9), - borderRadius: BorderRadius.circular(8), - ), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: onPressed, - borderRadius: BorderRadius.circular(8), - child: Container( - padding: const EdgeInsets.all(8), - child: Icon( - icon, - color: color, - size: 22, - ), - ), - ), - ), + return FloatingActionButton( + onPressed: onPressed, + tooltip: tooltip, + backgroundColor: backgroundColor, + foregroundColor: Colors.white, + mini: true, + elevation: 4, + child: Icon(icon, size: 20), ); } @@ -157,8 +196,6 @@ class _BookDetailPageState extends State { /// 构建顶部 AppBar Widget _buildSliverAppBar(Book book) { - final hasCover = book.coverPath != null && book.coverPath!.isNotEmpty; - return SliverAppBar( expandedHeight: 320, pinned: true, @@ -167,29 +204,7 @@ class _BookDetailPageState extends State { flexibleSpace: FlexibleSpaceBar( background: _buildCoverSection(book), ), - actions: [ - // 清空封面按钮(仅当有封面时显示) - if (hasCover) - _buildActionButton( - icon: Icons.hide_image_outlined, - onPressed: () => _showClearCoverDialog(book), - tooltip: '清空封面', - ), - // 编辑按钮 - _buildActionButton( - icon: Icons.edit_outlined, - onPressed: () => _navigateToEdit(context), - tooltip: '编辑', - ), - // 删除按钮 - _buildActionButton( - icon: Icons.delete_outline, - color: Colors.red, - onPressed: () => _showDeleteDialog(context), - tooltip: '删除', - ), - const SizedBox(width: 8), - ], + // 右上角按钮已移到右下角悬浮按钮 ); } @@ -875,4 +890,14 @@ class _BookDetailPageState extends State { ToastUtil.show(context, '下载失败: $e'); } } + + /// 显示分享海报页面 + void _showSharePoster(Book book) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => BookSharePage(book: book), + ), + ); + } } diff --git a/lib/pages/book/book_form_page.dart b/lib/pages/book/book_form_page.dart index 3d958dd..bd1baae 100644 --- a/lib/pages/book/book_form_page.dart +++ b/lib/pages/book/book_form_page.dart @@ -148,160 +148,166 @@ class _BookFormPageState extends State { body: Form( key: _formKey, child: ListView( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), children: [ // 封面选择 - 居中显示 Center(child: _buildCoverPicker()), - const SizedBox(height: 32), + const SizedBox(height: 24), - // 状态选择(靠左显示) + // 状态选择(简洁风格) _buildStatusSelector(), - const SizedBox(height: 20), + const SizedBox(height: 16), - // 评分 - 星星选择(靠左显示) + // 评分 - 星星选择(简洁风格) _buildStarRating(), const SizedBox(height: 32), - // 书名 - _buildHorizontalFormItem( - label: '书名', - required: true, - child: TextFormField( - controller: _titleController, - style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A)), - decoration: const InputDecoration( - hintText: '请输入书名', - hintStyle: TextStyle(fontSize: 14, color: Color(0xFFCCCCCC)), - border: InputBorder.none, - contentPadding: EdgeInsets.zero, + // 信息卡片网格 + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + // 第一行:书名 + 别名 + SizedBox( + width: (MediaQuery.of(context).size.width - 52) / 2, + height: 90, + child: _buildInfoCard( + label: '书名', + value: _titleController.text, + required: true, + icon: Icons.book_outlined, + onTap: () => _showTextInputDialog( + title: '书名', + initialValue: _titleController.text, + hint: '请输入书名', + onConfirm: (value) => setState(() => _titleController.text = value), + ), + ), ), - validator: (value) { - if (value == null || value.trim().isEmpty) { - return '请输入书名'; - } - return null; - }, - ), - ), - - const SizedBox(height: 16), - - // 别名 - _buildHorizontalMultiValueItem( - label: '别名', - values: _alternateTitles, - hint: '输入别名', - controllerKey: 'alternateTitles', - onAdd: (v) => setState(() => _alternateTitles.add(v)), - onRemove: (i) => setState(() => _alternateTitles.removeAt(i)), - ), - - const SizedBox(height: 16), - - // 作者 - _buildHorizontalMultiValueItem( - label: '作者', - values: _authors, - hint: '输入作者姓名', - controllerKey: 'authors', - onAdd: (v) => setState(() => _authors.add(v)), - onRemove: (i) => setState(() => _authors.removeAt(i)), - ), - - const SizedBox(height: 16), - - // 出版社 - _buildHorizontalFormItem( - label: '出版社', - child: TextFormField( - controller: _publisherController, - style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A)), - decoration: const InputDecoration( - hintText: '请输入出版社', - hintStyle: TextStyle(fontSize: 14, color: Color(0xFFCCCCCC)), - border: InputBorder.none, - contentPadding: EdgeInsets.zero, + SizedBox( + width: (MediaQuery.of(context).size.width - 52) / 2, + height: 90, + child: _buildInfoCard( + label: '别名', + value: _alternateTitles.isEmpty + ? '' + : '${_alternateTitles.length}个:${_alternateTitles.join('、')}', + icon: Icons.alternate_email_outlined, + onTap: () => _showMultiValueDialog( + title: '添加别名', + initialValues: _alternateTitles, + hint: '输入别名', + onConfirm: (values) => setState(() => _alternateTitles = values), + ), + ), ), - ), - ), - - const SizedBox(height: 16), - - // 类型 - _buildHorizontalMultiValueItem( - label: '类型', - values: _genres, - hint: '如:小说、历史、传记', - controllerKey: 'genres', - onAdd: (v) => setState(() => _genres.add(v)), - onRemove: (i) => setState(() => _genres.removeAt(i)), - ), - - const SizedBox(height: 16), - - // ISBN - _buildHorizontalFormItem( - label: 'ISBN', - child: TextFormField( - controller: _isbnController, - style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A)), - decoration: const InputDecoration( - hintText: '请输入ISBN编号', - hintStyle: TextStyle(fontSize: 14, color: Color(0xFFCCCCCC)), - border: InputBorder.none, - contentPadding: EdgeInsets.zero, + + // 第二行:作者 + 出版社 + SizedBox( + width: (MediaQuery.of(context).size.width - 52) / 2, + height: 90, + child: _buildInfoCard( + label: '作者', + value: _authors.isEmpty + ? '' + : '${_authors.length}人:${_authors.join('、')}', + icon: Icons.person_outline, + onTap: () => _showMultiValueDialog( + title: '添加作者', + initialValues: _authors, + hint: '输入作者姓名', + onConfirm: (values) => setState(() => _authors = values), + ), + ), ), - ), - ), - - const SizedBox(height: 16), - - // 出版社 - _buildHorizontalFormItem( - label: '出版社', - child: TextFormField( - controller: _publisherController, - style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A)), - decoration: const InputDecoration( - hintText: '请输入出版社名称', - hintStyle: TextStyle(fontSize: 14, color: Color(0xFFCCCCCC)), - border: InputBorder.none, - contentPadding: EdgeInsets.zero, + SizedBox( + width: (MediaQuery.of(context).size.width - 52) / 2, + height: 90, + child: _buildInfoCard( + label: '出版社', + value: _publisherController.text, + icon: Icons.business_outlined, + onTap: () => _showTextInputDialog( + title: '出版社', + initialValue: _publisherController.text, + hint: '请输入出版社', + onConfirm: (value) => setState(() => _publisherController.text = value), + ), + ), ), - ), - ), - - const SizedBox(height: 16), - - // 出版时间 - _buildVerticalDateItem( - label: '出版时间', - date: _publishDate, - onTap: _selectPublishDate, - onClear: () => setState(() => _publishDate = null), - ), - - const SizedBox(height: 16), - - // 书籍简介 - _buildFormItem( - label: '书籍简介', - child: TextFormField( - controller: _summaryController, - maxLines: 4, - style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A), height: 1.5), - decoration: const InputDecoration( - hintText: '写下书籍简介...', - hintStyle: TextStyle(fontSize: 14, color: Color(0xFFCCCCCC)), - border: InputBorder.none, - contentPadding: EdgeInsets.zero, + + // 第三行:类型 + ISBN + SizedBox( + width: (MediaQuery.of(context).size.width - 52) / 2, + height: 90, + child: _buildInfoCard( + label: '类型', + value: _genres.isEmpty + ? '' + : '${_genres.length}个:${_genres.join('、')}', + icon: Icons.category_outlined, + onTap: () => _showMultiValueDialog( + title: '添加类型', + initialValues: _genres, + hint: '如:小说、历史、传记', + onConfirm: (values) => setState(() => _genres = values), + ), + ), ), - ), + SizedBox( + width: (MediaQuery.of(context).size.width - 52) / 2, + height: 90, + child: _buildInfoCard( + label: 'ISBN', + value: _isbnController.text, + icon: Icons.qr_code_outlined, + onTap: () => _showTextInputDialog( + title: 'ISBN', + initialValue: _isbnController.text, + hint: '请输入ISBN编号', + onConfirm: (value) => setState(() => _isbnController.text = value), + ), + ), + ), + + // 第四行:出版时间(独占一行) + SizedBox( + width: double.infinity, + height: 90, + child: _buildInfoCard( + label: '出版时间', + value: _publishDate != null + ? '${_publishDate!.year}.${_publishDate!.month.toString().padLeft(2, '0')}.${_publishDate!.day.toString().padLeft(2, '0')}' + : '', + icon: Icons.date_range_outlined, + onTap: () => _selectPublishDate(), + ), + ), + + // 第五行:书籍简介(独占一行) + SizedBox( + width: double.infinity, + child: _buildInfoCard( + label: '书籍简介', + value: _summaryController.text, + icon: Icons.description_outlined, + height: 120, + scrollable: true, + onTap: () => _showTextInputDialog( + title: '书籍简介', + initialValue: _summaryController.text, + hint: '写下书籍简介...', + maxLines: 8, + onConfirm: (value) => setState(() => _summaryController.text = value), + ), + ), + ), + ], ), - + const SizedBox(height: 48), ], ), @@ -560,20 +566,111 @@ class _BookFormPageState extends State { } - /// 构建状态选择器(靠左显示,带标签) + /// 构建信息卡片(用于网格布局) + Widget _buildInfoCard({ + required String label, + required String value, + required VoidCallback onTap, + bool required = false, + IconData? icon, + double? height, + bool scrollable = false, + }) { + final hasValue = value.isNotEmpty; + + // 构建值显示部分 + Widget buildContent() { + if (scrollable && height != null) { + // 可滚动模式(仅书籍简介使用) + return Flexible( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Text( + hasValue ? value : '点击填写', + style: TextStyle( + fontSize: 15, + color: hasValue ? const Color(0xFF1A1A1A) : const Color(0xFFCCCCCC), + fontWeight: hasValue ? FontWeight.w500 : FontWeight.normal, + ), + ), + ), + ); + } else { + // 普通模式:只显示一行 + return Text( + hasValue ? value : '点击填写', + style: TextStyle( + fontSize: 15, + color: hasValue ? const Color(0xFF1A1A1A) : const Color(0xFFCCCCCC), + fontWeight: hasValue ? FontWeight.w500 : FontWeight.normal, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + } + } + + return GestureDetector( + onTap: onTap, + child: Container( + height: height, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE8E8E8)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: height != null ? MainAxisSize.max : MainAxisSize.min, + children: [ + // 标签行 + Row( + children: [ + if (icon != null) ...[ + Icon(icon, size: 14, color: const Color(0xFF999999)), + const SizedBox(width: 6), + ], + Text( + required ? '$label *' : label, + style: TextStyle( + fontSize: 12, + color: required ? const Color(0xFF1A1A1A) : const Color(0xFF999999), + fontWeight: required ? FontWeight.w500 : FontWeight.normal, + ), + ), + ], + ), + const SizedBox(height: 8), + // 值显示 + buildContent(), + ], + ), + ), + ); + } + + /// 构建状态选择器(简洁风格) Widget _buildStatusSelector() { return Row( children: [ const Text( '状态', - style: TextStyle(fontSize: 14, color: Color(0xFF666666)), + style: TextStyle(fontSize: 13, color: Color(0xFF999999)), ), const SizedBox(width: 16), Container( - padding: const EdgeInsets.all(4), + padding: const EdgeInsets.all(3), decoration: BoxDecoration( - color: const Color(0xFFF5F5F5), - borderRadius: BorderRadius.circular(8), + color: const Color(0xFFF0F0F0), + borderRadius: BorderRadius.circular(6), ), child: Row( mainAxisSize: MainAxisSize.min, @@ -588,13 +685,13 @@ class _BookFormPageState extends State { ); } - /// 构建星星评分(5星制,每星2分,支持手动输入) + /// 构建星星评分(支持手动输入) Widget _buildStarRating() { return Row( children: [ const Text( '评分', - style: TextStyle(fontSize: 14, color: Color(0xFF666666)), + style: TextStyle(fontSize: 13, color: Color(0xFF999999)), ), const SizedBox(width: 16), // 星星选择 @@ -648,52 +745,42 @@ class _BookFormPageState extends State { ); } - /// 构建评分输入框 + /// 构建评分输入框(支持0-10,保留1位小数) Widget _buildRatingInputField() { return Container( width: 56, - height: 36, + height: 32, decoration: BoxDecoration( - color: const Color(0xFFF5F5F5), - borderRadius: BorderRadius.circular(8), + color: const Color(0xFFF0F0F0), + borderRadius: BorderRadius.circular(6), ), child: TextFormField( controller: _ratingController, keyboardType: const TextInputType.numberWithOptions(decimal: true), textAlign: TextAlign.center, style: const TextStyle( - fontSize: 15, + fontSize: 14, fontWeight: FontWeight.w500, color: Color(0xFF1A1A1A), ), decoration: const InputDecoration( hintText: '-', - hintStyle: TextStyle(fontSize: 15, color: Color(0xFFCCCCCC)), + hintStyle: TextStyle(fontSize: 14, color: Color(0xFFCCCCCC)), border: InputBorder.none, - contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8), + contentPadding: EdgeInsets.symmetric(horizontal: 4, vertical: 6), ), - validator: (value) { - if (value != null && value.isNotEmpty) { - final rating = double.tryParse(value); - if (rating == null || rating < 0 || rating > 10) { - return '0-10'; - } - } - return null; - }, onChanged: (value) { - // 限制输入范围 if (value.isNotEmpty) { final rating = double.tryParse(value); if (rating != null) { if (rating > 10) { - _ratingController.text = '10'; + _ratingController.text = '10.0'; } else if (rating < 0) { - _ratingController.text = '0'; + _ratingController.text = '0.0'; } } } - setState(() {}); // 更新星星显示 + setState(() {}); }, ), ); @@ -806,16 +893,18 @@ class _BookFormPageState extends State { final hasCover = _coverPath != null && _coverPath!.isNotEmpty; return Column( + mainAxisSize: MainAxisSize.min, children: [ GestureDetector( onTap: _pickCover, child: Container( - width: 140, - height: 200, + width: 120, + height: 170, decoration: BoxDecoration( color: const Color(0xFFF5F5F5), - border: Border.all(color: const Color(0xFFE5E5E5), width: 0.5), + borderRadius: BorderRadius.circular(8), ), + clipBehavior: Clip.antiAlias, child: hasCover ? Image.file( File(_coverPath!), @@ -828,28 +917,29 @@ class _BookFormPageState extends State { // 清空封面按钮(仅当有封面时显示) if (hasCover) Padding( - padding: const EdgeInsets.only(top: 12), + padding: const EdgeInsets.only(top: 10), child: GestureDetector( onTap: () => setState(() => _coverPath = null), child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( - border: Border.all(color: const Color(0xFFE5E5E5)), + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(16), ), - child: const Row( + child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( - Icons.hide_image_outlined, - size: 16, - color: Color(0xFF666666), + Icons.delete_outline, + size: 14, + color: Colors.grey[600], ), - SizedBox(width: 4), + const SizedBox(width: 4), Text( - '清空封面', + '移除封面', style: TextStyle( - fontSize: 13, - color: Color(0xFF666666), + fontSize: 12, + color: Colors.grey[600], ), ), ], @@ -866,16 +956,16 @@ class _BookFormPageState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - Icons.add_photo_alternate_outlined, - size: 40, - color: Color(0xFF999999), + Icons.image_outlined, + size: 32, + color: Color(0xFFCCCCCC), ), - SizedBox(height: 12), + SizedBox(height: 8), Text( - '点击添加封面', + '封面', style: TextStyle( - fontSize: 14, - color: Color(0xFF999999), + fontSize: 12, + color: Color(0xFFAAAAAA), ), ), ], @@ -1256,4 +1346,293 @@ class _BookFormPageState extends State { return null; } + + /// 显示文本输入对话框 + Future _showTextInputDialog({ + required String title, + required String initialValue, + required Function(String) onConfirm, + int maxLines = 1, + String hint = '', + }) async { + final result = await showDialog( + context: context, + builder: (context) => _TextInputDialog( + title: title, + initialValue: initialValue, + hint: hint, + maxLines: maxLines, + ), + ); + + if (result != null) { + onConfirm(result); + } + } + + /// 显示多值输入对话框 + Future _showMultiValueDialog({ + required String title, + required List initialValues, + required String hint, + required Function(List) onConfirm, + }) async { + final result = await showDialog>( + context: context, + builder: (context) => _MultiValueDialog( + title: title, + initialValues: initialValues, + hint: hint, + ), + ); + + if (result != null) { + onConfirm(result); + } + } +} + +/// 文本输入对话框组件 +class _TextInputDialog extends StatefulWidget { + final String title; + final String initialValue; + final String hint; + final int maxLines; + + const _TextInputDialog({ + required this.title, + required this.initialValue, + required this.hint, + required this.maxLines, + }); + + @override + State<_TextInputDialog> createState() => _TextInputDialogState(); +} + +class _TextInputDialogState extends State<_TextInputDialog> { + late final TextEditingController controller; + + @override + void initState() { + super.initState(); + controller = TextEditingController(text: widget.initialValue); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text( + widget.title, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + content: TextField( + controller: controller, + maxLines: widget.maxLines, + autofocus: true, + style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A)), + decoration: InputDecoration( + hintText: widget.hint, + hintStyle: const TextStyle(fontSize: 14, color: Color(0xFFAAAAAA)), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFFE0E0E0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFFE0E0E0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFF1A1A1A)), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消', style: TextStyle(color: Color(0xFF999999))), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, controller.text.trim()), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF1A1A1A), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text('确定'), + ), + ], + ); + } +} + +/// 多值输入对话框组件 +class _MultiValueDialog extends StatefulWidget { + final String title; + final List initialValues; + final String hint; + + const _MultiValueDialog({ + required this.title, + required this.initialValues, + required this.hint, + }); + + @override + State<_MultiValueDialog> createState() => _MultiValueDialogState(); +} + +class _MultiValueDialogState extends State<_MultiValueDialog> { + late List values; + final controller = TextEditingController(); + + @override + void initState() { + super.initState(); + values = List.from(widget.initialValues); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + void _addValue(String value) { + final trimmed = value.trim(); + if (trimmed.isNotEmpty && !values.contains(trimmed)) { + setState(() { + values.add(trimmed); + }); + controller.clear(); + } + } + + void _removeValue(int index) { + setState(() { + values.removeAt(index); + }); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text( + widget.title, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + content: SizedBox( + width: double.maxFinite, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 已添加的值列表 + if (values.isNotEmpty) + Wrap( + spacing: 8, + runSpacing: 8, + children: values.asMap().entries.map((entry) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + entry.value, + style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)), + ), + const SizedBox(width: 4), + GestureDetector( + onTap: () => _removeValue(entry.key), + child: const Icon(Icons.close, size: 14, color: Color(0xFF999999)), + ), + ], + ), + ); + }).toList(), + ), + if (values.isNotEmpty) const SizedBox(height: 16), + // 输入框 + Row( + children: [ + Expanded( + child: TextField( + controller: controller, + autofocus: true, + style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A)), + decoration: InputDecoration( + hintText: widget.hint, + hintStyle: const TextStyle(fontSize: 14, color: Color(0xFFAAAAAA)), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFFE0E0E0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFFE0E0E0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFF1A1A1A)), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + ), + onSubmitted: _addValue, + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: () => _addValue(controller.text), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.add, size: 20, color: Colors.white), + ), + ), + ], + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消', style: TextStyle(color: Color(0xFF999999))), + ), + ElevatedButton( + onPressed: () { + // 如果输入框还有内容,先添加 + _addValue(controller.text); + Navigator.pop(context, values); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF1A1A1A), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text('确定'), + ), + ], + ); + } } diff --git a/lib/pages/book/book_share_page.dart b/lib/pages/book/book_share_page.dart new file mode 100644 index 0000000..4f0d6db --- /dev/null +++ b/lib/pages/book/book_share_page.dart @@ -0,0 +1,319 @@ +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'; + +/// 书籍分享海报页面 +class BookSharePage extends StatefulWidget { + final Book book; + + const BookSharePage({super.key, required this.book}); + + @override + State createState() => _BookSharePageState(); +} + +class _BookSharePageState extends State { + final GlobalKey _posterKey = GlobalKey(); + bool _isGenerating = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF5F5F5), + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.close, color: Color(0xFF1A1A1A)), + onPressed: () => Navigator.pop(context), + ), + title: const Text( + '分享海报', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Color(0xFF1A1A1A), + ), + ), + centerTitle: true, + actions: [ + TextButton( + onPressed: _isGenerating ? null : _generateAndShare, + child: _isGenerating + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text( + '分享', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF1A1A1A), + ), + ), + ), + const SizedBox(width: 8), + ], + ), + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: RepaintBoundary( + key: _posterKey, + child: _buildPosterWidget(), + ), + ), + ), + ); + } + + /// 构建海报 Widget + Widget _buildPosterWidget() { + final book = widget.book; + final hasCover = book.coverPath != null && book.coverPath!.isNotEmpty; + + return Container( + width: 320, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 封面图片 + if (hasCover) + ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + child: Image.file( + File(book.coverPath!), + width: 320, + height: 200, + fit: BoxFit.cover, + ), + ), + + // 内容区域 + Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 书名 + Text( + book.title, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Color(0xFF1A1A1A), + ), + ), + + // 别名 + if (book.alternateTitles.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + book.alternateTitles.join(' / '), + style: const TextStyle( + fontSize: 14, + color: Color(0xFF666666), + ), + ), + ], + + const SizedBox(height: 16), + + // 评分 + if (book.rating != null && book.rating! > 0) ...[ + Row( + children: [ + const Icon( + Icons.star, + size: 18, + color: Color(0xFFFFB800), + ), + const SizedBox(width: 4), + Text( + book.rating!.toStringAsFixed(1), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFFFFB800), + ), + ), + const SizedBox(width: 4), + const Text( + '/ 10', + style: TextStyle( + fontSize: 12, + color: Color(0xFF999999), + ), + ), + ], + ), + const SizedBox(height: 12), + ], + + // 作者 + if (book.authors.isNotEmpty) + _buildInfoRow('作者', book.authors.join(' / ')), + + // 出版社 + if (book.publisher != null && book.publisher!.isNotEmpty) + _buildInfoRow('出版社', book.publisher!), + + // 出版时间 + if (book.publishDate != null) + _buildInfoRow( + '出版', + '${book.publishDate!.year}.${book.publishDate!.month.toString().padLeft(2, '0')}.${book.publishDate!.day.toString().padLeft(2, '0')}', + ), + + // 类型 + if (book.genres.isNotEmpty) + _buildInfoRow('类型', book.genres.join(' / ')), + + // ISBN + if (book.isbn != null && book.isbn!.isNotEmpty) + _buildInfoRow('ISBN', book.isbn!), + + const SizedBox(height: 16), + + // 简介 + if (book.summary != null && book.summary!.isNotEmpty) ...[ + const Text( + '简介', + style: TextStyle( + fontSize: 12, + color: Color(0xFF999999), + ), + ), + const SizedBox(height: 8), + Text( + book.summary!, + style: const TextStyle( + fontSize: 13, + color: Color(0xFF666666), + height: 1.6, + ), + maxLines: 5, + overflow: TextOverflow.ellipsis, + ), + ], + + const SizedBox(height: 20), + + // 底部标识 + const Divider(height: 1, color: Color(0xFFE8E8E8)), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.book_outlined, + size: 14, + color: const Color(0xFF1A1A1A).withOpacity(0.5), + ), + const SizedBox(width: 6), + Text( + '来自 MookNote', + style: TextStyle( + fontSize: 12, + color: const Color(0xFF1A1A1A).withOpacity(0.5), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } + + /// 构建信息行 + Widget _buildInfoRow(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$label:', + style: const TextStyle( + fontSize: 13, + color: Color(0xFF999999), + ), + ), + Expanded( + child: Text( + value, + style: const TextStyle( + fontSize: 13, + color: Color(0xFF333333), + ), + ), + ), + ], + ), + ); + } + + /// 生成并分享海报 + 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 = 'book_poster_${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/movies/douban_webview_page.dart b/lib/pages/movies/douban_webview_page.dart index c7bcc65..e6735db 100644 --- a/lib/pages/movies/douban_webview_page.dart +++ b/lib/pages/movies/douban_webview_page.dart @@ -318,6 +318,8 @@ class _DoubanWebViewPageState extends State { if (roleEl && ( roleEl.textContent.includes('配音') || roleEl.textContent.includes('主演') || + roleEl.textContent.includes('演员') || + roleEl.textContent.includes('参演') || roleEl.textContent.includes('饰') )) { const nameEl = el.querySelector('.name'); diff --git a/lib/pages/movies/movie_detail_page.dart b/lib/pages/movies/movie_detail_page.dart index a805b32..84c49e0 100644 --- a/lib/pages/movies/movie_detail_page.dart +++ b/lib/pages/movies/movie_detail_page.dart @@ -11,6 +11,7 @@ import '../../models/data_models.dart'; import '../../utils/toast_util.dart'; import 'movie_reviews_page.dart'; import 'movie_posters_page.dart'; +import 'movie_share_page.dart'; /// 影视详情页 - 极简主义设计 class MovieDetailPage extends StatefulWidget { @@ -45,87 +46,125 @@ class _MovieDetailPageState extends State { return Scaffold( backgroundColor: Colors.white, - body: CustomScrollView( - slivers: [ - // 顶部海报区域 - _buildSliverAppBar(movie), + body: Stack( + children: [ + CustomScrollView( + slivers: [ + // 顶部海报区域 + _buildSliverAppBar(movie), + + // 内容区域 + SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 基本信息 + _buildBasicInfo(movie), + + const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)), + + // 导演 + if (movie.directors.isNotEmpty) + _buildDirectorsSection(movie), + + // 编剧 + if (movie.writers.isNotEmpty) + _buildWritersSection(movie), + + // 主演 + if (movie.actors.isNotEmpty) + _buildActorsSection(movie), + + // 类型 + if (movie.genres.isNotEmpty) + _buildGenresSection(movie), + + const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)), + + // 简介 + if (movie.summary != null && movie.summary!.isNotEmpty) + _buildSummarySection(movie), + + const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)), + + // 影评和海报墙入口 + _buildExtraSections(movie), + + const SizedBox(height: 120), + ], + ), + ), + ], + ), - // 内容区域 - SliverToBoxAdapter( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 基本信息 - _buildBasicInfo(movie), - - const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)), - - // 导演 - if (movie.directors.isNotEmpty) - _buildDirectorsSection(movie), - - // 编剧 - if (movie.writers.isNotEmpty) - _buildWritersSection(movie), - - // 主演 - if (movie.actors.isNotEmpty) - _buildActorsSection(movie), - - // 类型 - if (movie.genres.isNotEmpty) - _buildGenresSection(movie), - - const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)), - - // 简介 - if (movie.summary != null && movie.summary!.isNotEmpty) - _buildSummarySection(movie), - - const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)), - - // 影评和海报墙入口 - _buildExtraSections(movie), - - const SizedBox(height: 48), - ], - ), + // 右下角悬浮按钮组 + Positioned( + right: 16, + bottom: 24, + child: _buildFloatingActionButtons(movie), ), ], ), - - // 底部无操作栏,编辑和删除在右上角 ); } - /// 构建右上角操作按钮 - Widget _buildActionButton({ + /// 构建右下角悬浮按钮组 + Widget _buildFloatingActionButtons(Movie movie) { + final hasPoster = movie.posterPath != null && movie.posterPath!.isNotEmpty; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 删除按钮 + _buildFloatingButton( + icon: Icons.delete_outline, + onPressed: () => _showDeleteDialog(context), + tooltip: '删除', + backgroundColor: Colors.red, + ), + const SizedBox(height: 12), + // 清空海报按钮(仅当有海报时显示) + if (hasPoster) ...[ + _buildFloatingButton( + icon: Icons.hide_image_outlined, + onPressed: () => _showClearPosterDialog(movie), + tooltip: '清空海报', + ), + const SizedBox(height: 12), + ], + // 编辑按钮 + _buildFloatingButton( + icon: Icons.edit_outlined, + onPressed: () => _navigateToEdit(context), + tooltip: '编辑', + ), + const SizedBox(height: 12), + // 分享按钮 + _buildFloatingButton( + icon: Icons.share_outlined, + onPressed: () => _showSharePoster(movie), + tooltip: '分享海报', + backgroundColor: const Color(0xFF4CAF50), + ), + ], + ); + } + + /// 构建单个悬浮按钮 + Widget _buildFloatingButton({ required IconData icon, required VoidCallback onPressed, required String tooltip, - Color color = const Color(0xFF666666), + Color backgroundColor = const Color(0xFF1A1A1A), }) { - return Container( - margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.9), - borderRadius: BorderRadius.circular(8), - ), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: onPressed, - borderRadius: BorderRadius.circular(8), - child: Container( - padding: const EdgeInsets.all(8), - child: Icon( - icon, - color: color, - size: 22, - ), - ), - ), - ), + return FloatingActionButton( + onPressed: onPressed, + tooltip: tooltip, + backgroundColor: backgroundColor, + foregroundColor: Colors.white, + mini: true, + elevation: 4, + child: Icon(icon, size: 20), ); } @@ -157,8 +196,6 @@ class _MovieDetailPageState extends State { /// 构建顶部 AppBar Widget _buildSliverAppBar(Movie movie) { - final hasPoster = movie.posterPath != null && movie.posterPath!.isNotEmpty; - return SliverAppBar( expandedHeight: 320, pinned: true, @@ -167,29 +204,7 @@ class _MovieDetailPageState extends State { flexibleSpace: FlexibleSpaceBar( background: _buildPosterSection(movie), ), - actions: [ - // 清空海报按钮(仅当有海报时显示) - if (hasPoster) - _buildActionButton( - icon: Icons.hide_image_outlined, - onPressed: () => _showClearPosterDialog(movie), - tooltip: '清空海报', - ), - // 编辑按钮 - _buildActionButton( - icon: Icons.edit_outlined, - onPressed: () => _navigateToEdit(context), - tooltip: '编辑', - ), - // 删除按钮 - _buildActionButton( - icon: Icons.delete_outline, - color: Colors.red, - onPressed: () => _showDeleteDialog(context), - tooltip: '删除', - ), - const SizedBox(width: 8), - ], + // 右上角按钮已移到右下角悬浮按钮 ); } @@ -850,4 +865,14 @@ class _MovieDetailPageState extends State { // 这里默认返回较低版本,使用传统存储权限 return 30; } + + /// 显示分享海报页面 + void _showSharePoster(Movie movie) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MovieSharePage(movie: movie), + ), + ); + } } diff --git a/lib/pages/movies/movie_form_page.dart b/lib/pages/movies/movie_form_page.dart index 7bfcc64..c9af0f1 100644 --- a/lib/pages/movies/movie_form_page.dart +++ b/lib/pages/movies/movie_form_page.dart @@ -402,7 +402,7 @@ class _MovieFormPageState extends State { body: Form( key: _formKey, child: ListView( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), children: [ // 封面选择 - 居中显示 Center(child: _buildCoverPicker()), @@ -419,124 +419,163 @@ class _MovieFormPageState extends State { const SizedBox(height: 32), - // 影视名称 - _buildHorizontalFormItem( - label: '名称', - required: true, - child: TextFormField( - controller: _titleController, - style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A)), - decoration: const InputDecoration( - hintText: '请输入影视名称', - hintStyle: TextStyle(fontSize: 14, color: Color(0xFFCCCCCC)), - border: InputBorder.none, - contentPadding: EdgeInsets.zero, + // 信息卡片网格 + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + // 第一行:名称 + 别名 + SizedBox( + width: (MediaQuery.of(context).size.width - 52) / 2, + height: 90, + child: _buildInfoCard( + label: '名称', + value: _titleController.text, + required: true, + icon: Icons.movie_outlined, + onTap: () => _showTextInputDialog( + title: '影视名称', + initialValue: _titleController.text, + hint: '请输入影视名称', + onConfirm: (value) => setState(() => _titleController.text = value), + ), + ), ), - validator: (value) { - if (value == null || value.trim().isEmpty) { - return '请输入影视名称'; - } - return null; - }, - ), - ), - - const SizedBox(height: 16), - - // 别名 - _buildHorizontalMultiValueItem( - label: '别名', - values: _alternateTitles, - hint: '输入别名', - controllerKey: 'alternateTitles', - onAdd: (v) => setState(() => _alternateTitles.add(v)), - onRemove: (i) => setState(() => _alternateTitles.removeAt(i)), - ), - - const SizedBox(height: 16), - - // 导演 - _buildHorizontalMultiValueItem( - label: '导演', - values: _directors, - hint: '输入导演姓名', - controllerKey: 'directors', - onAdd: (v) => setState(() => _directors.add(v)), - onRemove: (i) => setState(() => _directors.removeAt(i)), - ), - - const SizedBox(height: 16), - - // 编剧 - _buildHorizontalMultiValueItem( - label: '编剧', - values: _writers, - hint: '输入编剧姓名', - controllerKey: 'writers', - onAdd: (v) => setState(() => _writers.add(v)), - onRemove: (i) => setState(() => _writers.removeAt(i)), - ), - - const SizedBox(height: 16), - - // 主演 - _buildHorizontalMultiValueItem( - label: '主演', - values: _actors, - hint: '输入主演姓名', - controllerKey: 'actors', - onAdd: (v) => setState(() => _actors.add(v)), - onRemove: (i) => setState(() => _actors.removeAt(i)), - ), - - const SizedBox(height: 16), - - // 类型(自定义填写,带示例) - _buildHorizontalMultiValueItem( - label: '类型', - values: _genres, - hint: '如:剧情、科幻、悬疑', - controllerKey: 'genres', - onAdd: (v) => setState(() => _genres.add(v)), - onRemove: (i) => setState(() => _genres.removeAt(i)), - ), - - const SizedBox(height: 16), - - // 上映日期 - _buildVerticalDateItem( - label: '上映日期', - date: _releaseDate, - onTap: _selectReleaseDate, - onClear: () => setState(() => _releaseDate = null), - ), - - const SizedBox(height: 16), - - // 观看日期 - _buildVerticalDateItem( - label: '观看日期', - date: _watchDate, - onTap: _selectWatchDate, - onClear: () => setState(() => _watchDate = null), - ), - - const SizedBox(height: 16), - - // 剧情简介 - _buildFormItem( - label: '剧情简介', - child: TextFormField( - controller: _summaryController, - maxLines: 4, - style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A), height: 1.5), - decoration: const InputDecoration( - hintText: '写下剧情简介...', - hintStyle: TextStyle(fontSize: 14, color: Color(0xFFCCCCCC)), - border: InputBorder.none, - contentPadding: EdgeInsets.zero, + SizedBox( + width: (MediaQuery.of(context).size.width - 52) / 2, + height: 90, + child: _buildInfoCard( + label: '别名', + value: _alternateTitles.isEmpty + ? '' + : '${_alternateTitles.length}个:${_alternateTitles.join('、')}', + icon: Icons.alternate_email_outlined, + onTap: () => _showMultiValueDialog( + title: '添加别名', + initialValues: _alternateTitles, + hint: '输入别名', + onConfirm: (values) => setState(() => _alternateTitles = values), + ), + ), ), - ), + + // 第二行:导演 + 编剧 + SizedBox( + width: (MediaQuery.of(context).size.width - 52) / 2, + height: 90, + child: _buildInfoCard( + label: '导演', + value: _directors.isEmpty + ? '' + : '${_directors.length}人:${_directors.join('、')}', + icon: Icons.videocam_outlined, + onTap: () => _showMultiValueDialog( + title: '添加导演', + initialValues: _directors, + hint: '输入导演姓名', + onConfirm: (values) => setState(() => _directors = values), + ), + ), + ), + SizedBox( + width: (MediaQuery.of(context).size.width - 52) / 2, + height: 90, + child: _buildInfoCard( + label: '编剧', + value: _writers.isEmpty + ? '' + : '${_writers.length}人:${_writers.join('、')}', + icon: Icons.edit_note_outlined, + onTap: () => _showMultiValueDialog( + title: '添加编剧', + initialValues: _writers, + hint: '输入编剧姓名', + onConfirm: (values) => setState(() => _writers = values), + ), + ), + ), + + // 第三行:主演 + 类型 + SizedBox( + width: (MediaQuery.of(context).size.width - 52) / 2, + height: 90, + child: _buildInfoCard( + label: '主演', + value: _actors.isEmpty + ? '' + : '${_actors.length}人:${_actors.join('、')}', + icon: Icons.people_outline, + onTap: () => _showMultiValueDialog( + title: '添加主演', + initialValues: _actors, + hint: '输入主演姓名', + onConfirm: (values) => setState(() => _actors = values), + ), + ), + ), + SizedBox( + width: (MediaQuery.of(context).size.width - 52) / 2, + height: 90, + child: _buildInfoCard( + label: '类型', + value: _genres.isEmpty + ? '' + : '${_genres.length}个:${_genres.join('、')}', + icon: Icons.category_outlined, + onTap: () => _showMultiValueDialog( + title: '添加类型', + initialValues: _genres, + hint: '如:剧情、科幻、悬疑', + onConfirm: (values) => setState(() => _genres = values), + ), + ), + ), + + // 第四行:上映日期 + 观看日期 + SizedBox( + width: (MediaQuery.of(context).size.width - 52) / 2, + height: 90, + child: _buildInfoCard( + label: '上映日期', + value: _releaseDate != null + ? '${_releaseDate!.year}.${_releaseDate!.month.toString().padLeft(2, '0')}.${_releaseDate!.day.toString().padLeft(2, '0')}' + : '', + icon: Icons.theaters_outlined, + onTap: () => _selectReleaseDate(), + ), + ), + SizedBox( + width: (MediaQuery.of(context).size.width - 52) / 2, + height: 90, + child: _buildInfoCard( + label: '观看日期', + value: _watchDate != null + ? '${_watchDate!.year}.${_watchDate!.month.toString().padLeft(2, '0')}.${_watchDate!.day.toString().padLeft(2, '0')}' + : '', + icon: Icons.visibility_outlined, + onTap: () => _selectWatchDate(), + ), + ), + + // 第五行:剧情简介(独占一行) + SizedBox( + width: double.infinity, + child: _buildInfoCard( + label: '剧情简介', + value: _summaryController.text, + icon: Icons.description_outlined, + height: 120, + scrollable: true, + onTap: () => _showTextInputDialog( + title: '剧情简介', + initialValue: _summaryController.text, + hint: '写下剧情简介...', + maxLines: 8, + onConfirm: (value) => setState(() => _summaryController.text = value), + ), + ), + ), + ], ), const SizedBox(height: 48), @@ -546,6 +585,141 @@ class _MovieFormPageState extends State { ); } + /// 构建信息卡片(用于网格布局) + Widget _buildInfoCard({ + required String label, + required String value, + required VoidCallback onTap, + bool required = false, + IconData? icon, + double? height, + bool scrollable = false, + }) { + final hasValue = value.isNotEmpty; + + // 构建值显示部分 + Widget buildContent() { + if (scrollable && height != null) { + // 可滚动模式(仅剧情简介使用) + return Flexible( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Text( + hasValue ? value : '点击填写', + style: TextStyle( + fontSize: 15, + color: hasValue ? const Color(0xFF1A1A1A) : const Color(0xFFCCCCCC), + fontWeight: hasValue ? FontWeight.w500 : FontWeight.normal, + ), + ), + ), + ); + } else { + // 普通模式:只显示一行 + return Text( + hasValue ? value : '点击填写', + style: TextStyle( + fontSize: 15, + color: hasValue ? const Color(0xFF1A1A1A) : const Color(0xFFCCCCCC), + fontWeight: hasValue ? FontWeight.w500 : FontWeight.normal, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + } + } + + return GestureDetector( + onTap: onTap, + child: Container( + height: height, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE8E8E8)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: height != null ? MainAxisSize.max : MainAxisSize.min, + children: [ + // 标签行 + Row( + children: [ + if (icon != null) ...[ + Icon(icon, size: 14, color: const Color(0xFF999999)), + const SizedBox(width: 6), + ], + Text( + required ? '$label *' : label, + style: TextStyle( + fontSize: 12, + color: required ? const Color(0xFF1A1A1A) : const Color(0xFF999999), + fontWeight: required ? FontWeight.w500 : FontWeight.normal, + ), + ), + ], + ), + const SizedBox(height: 8), + // 值显示 + buildContent(), + ], + ), + ), + ); + } + + /// 显示文本输入对话框 + Future _showTextInputDialog({ + required String title, + required String initialValue, + required Function(String) onConfirm, + int maxLines = 1, + String hint = '', + }) async { + final result = await showDialog( + context: context, + builder: (context) => _TextInputDialog( + title: title, + initialValue: initialValue, + hint: hint, + maxLines: maxLines, + ), + ); + + if (result != null) { + onConfirm(result); + } + } + + /// 显示多值输入对话框 + Future _showMultiValueDialog({ + required String title, + required List initialValues, + required Function(List) onConfirm, + String hint = '', + }) async { + final result = await showDialog>( + context: context, + builder: (context) => _MultiValueDialog( + title: title, + initialValues: initialValues, + hint: hint, + ), + ); + + if (result != null) { + onConfirm(result); + } + } + /// 构建纵向日期条目(标签在上,日期在下) Widget _buildVerticalDateItem({ required String label, @@ -590,6 +764,102 @@ class _MovieFormPageState extends State { ); } + /// 构建卡片式表单条目(带边框背景) + Widget _buildCardFormItem({ + required String label, + required Widget child, + bool required = false, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + required ? '$label *' : label, + style: TextStyle( + fontSize: 13, + color: required ? const Color(0xFF1A1A1A) : const Color(0xFF999999), + fontWeight: required ? FontWeight.w500 : FontWeight.normal, + ), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFD0D0D0)), + ), + child: child, + ), + ], + ); + } + + /// 构建卡片式日期选择项 + Widget _buildCardDateItem({ + required String label, + required DateTime? date, + required VoidCallback onTap, + required VoidCallback onClear, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle(fontSize: 13, color: Color(0xFF999999)), + ), + const SizedBox(height: 8), + GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFD0D0D0)), + ), + child: Row( + children: [ + Icon( + Icons.calendar_today_outlined, + size: 18, + color: date != null ? const Color(0xFF1A1A1A) : const Color(0xFFAAAAAA), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + date != null + ? '${date.year}.${date.month.toString().padLeft(2, '0')}.${date.day.toString().padLeft(2, '0')}' + : '选择日期', + style: TextStyle( + fontSize: 15, + color: date != null + ? const Color(0xFF1A1A1A) + : const Color(0xFFAAAAAA), + ), + ), + ), + if (date != null) + GestureDetector( + onTap: onClear, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEEEEEE), + borderRadius: BorderRadius.circular(4), + ), + child: const Icon(Icons.close, size: 14, color: Color(0xFF999999)), + ), + ), + ], + ), + ), + ), + ], + ); + } + /// 构建表单条目(标签 + 内容)- 剧情简介使用 Widget _buildFormItem({required String label, required Widget child}) { return Column( @@ -631,7 +901,7 @@ class _MovieFormPageState extends State { ); } - /// 构建横向多值条目(标签在左,内容在右) + /// 构建纵向多值条目(标签在上,输入框在下,带添加按钮) Widget _buildHorizontalMultiValueItem({ required String label, required List values, @@ -642,72 +912,116 @@ class _MovieFormPageState extends State { }) { final controller = _getTagController(controllerKey); - return Row( + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SizedBox( - width: 48, - child: Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( + // 标签行:标签 + 添加按钮 + Row( + children: [ + Text( label, style: const TextStyle(fontSize: 13, color: Color(0xFF999999)), ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Wrap( - spacing: 8, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - ...values.asMap().entries.map((entry) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: const Color(0xFFF5F5F5), - borderRadius: BorderRadius.circular(4), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - entry.value, - style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)), - ), - const SizedBox(width: 4), - GestureDetector( - onTap: () => onRemove(entry.key), - child: const Icon(Icons.close, size: 14, color: Color(0xFF999999)), - ), - ], + const Spacer(), + // 添加按钮 + ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, child) { + final hasText = value.text.trim().isNotEmpty; + return GestureDetector( + onTap: hasText + ? () { + final text = controller.text.trim(); + if (text.isNotEmpty && !values.contains(text)) { + onAdd(text); + controller.clear(); + } + } + : null, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: hasText ? const Color(0xFF1A1A1A) : const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.add, + size: 14, + color: hasText ? Colors.white : const Color(0xFFCCCCCC), + ), + const SizedBox(width: 4), + Text( + '添加', + style: TextStyle( + fontSize: 12, + color: hasText ? Colors.white : const Color(0xFFCCCCCC), + ), + ), + ], + ), ), ); - }), - // 输入框 - ConstrainedBox( - constraints: const BoxConstraints(minWidth: 80, maxWidth: 120), - child: TextField( - controller: controller, - style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)), - decoration: InputDecoration( - hintText: values.isEmpty ? hint : '', - hintStyle: const TextStyle(fontSize: 13, color: Color(0xFFCCCCCC)), - border: InputBorder.none, - isDense: true, - contentPadding: const EdgeInsets.symmetric(vertical: 6), - ), - onSubmitted: (value) { - final trimmed = value.trim(); - if (trimmed.isNotEmpty && !values.contains(trimmed)) { - onAdd(trimmed); - controller.clear(); - } - }, + }, + ), + ], + ), + const SizedBox(height: 8), + // 已选标签 + if (values.isNotEmpty) + Wrap( + spacing: 8, + runSpacing: 8, + children: values.asMap().entries.map((entry) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(6), ), - ), - ], + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + entry.value, + style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)), + ), + const SizedBox(width: 4), + GestureDetector( + onTap: () => onRemove(entry.key), + child: const Icon(Icons.close, size: 14, color: Color(0xFF999999)), + ), + ], + ), + ); + }).toList(), + ), + if (values.isNotEmpty) const SizedBox(height: 8), + // 输入框(带边框背景) + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFD0D0D0)), + ), + child: TextField( + controller: controller, + style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)), + decoration: InputDecoration( + hintText: hint, + hintStyle: const TextStyle(fontSize: 14, color: Color(0xFFAAAAAA)), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + ), + onSubmitted: (value) { + final trimmed = value.trim(); + if (trimmed.isNotEmpty && !values.contains(trimmed)) { + onAdd(trimmed); + controller.clear(); + } + }, ), ), ], @@ -840,20 +1154,20 @@ class _MovieFormPageState extends State { ); } - /// 构建状态选择器(靠左显示,带标签) + /// 构建状态选择器(简洁风格) Widget _buildStatusSelector() { return Row( children: [ const Text( '状态', - style: TextStyle(fontSize: 14, color: Color(0xFF666666)), + style: TextStyle(fontSize: 13, color: Color(0xFF999999)), ), const SizedBox(width: 16), Container( - padding: const EdgeInsets.all(4), + padding: const EdgeInsets.all(3), decoration: BoxDecoration( - color: const Color(0xFFF5F5F5), - borderRadius: BorderRadius.circular(8), + color: const Color(0xFFF0F0F0), + borderRadius: BorderRadius.circular(6), ), child: Row( mainAxisSize: MainAxisSize.min, @@ -868,13 +1182,13 @@ class _MovieFormPageState extends State { ); } - /// 构建星星评分(5星制,每星2分,支持手动输入) + /// 构建星星评分(支持手动输入) Widget _buildStarRating() { return Row( children: [ const Text( '评分', - style: TextStyle(fontSize: 14, color: Color(0xFF666666)), + style: TextStyle(fontSize: 13, color: Color(0xFF999999)), ), const SizedBox(width: 16), // 星星选择 @@ -928,52 +1242,42 @@ class _MovieFormPageState extends State { ); } - /// 构建评分输入框 + /// 构建评分输入框(支持0-10,保留1位小数) Widget _buildRatingInputField() { return Container( width: 56, - height: 36, + height: 32, decoration: BoxDecoration( - color: const Color(0xFFF5F5F5), - borderRadius: BorderRadius.circular(8), + color: const Color(0xFFF0F0F0), + borderRadius: BorderRadius.circular(6), ), child: TextFormField( controller: _ratingController, keyboardType: const TextInputType.numberWithOptions(decimal: true), textAlign: TextAlign.center, style: const TextStyle( - fontSize: 15, + fontSize: 14, fontWeight: FontWeight.w500, color: Color(0xFF1A1A1A), ), decoration: const InputDecoration( hintText: '-', - hintStyle: TextStyle(fontSize: 15, color: Color(0xFFCCCCCC)), + hintStyle: TextStyle(fontSize: 14, color: Color(0xFFCCCCCC)), border: InputBorder.none, - contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8), + contentPadding: EdgeInsets.symmetric(horizontal: 4, vertical: 6), ), - validator: (value) { - if (value != null && value.isNotEmpty) { - final rating = double.tryParse(value); - if (rating == null || rating < 0 || rating > 10) { - return '0-10'; - } - } - return null; - }, onChanged: (value) { - // 限制输入范围 if (value.isNotEmpty) { final rating = double.tryParse(value); if (rating != null) { if (rating > 10) { - _ratingController.text = '10'; + _ratingController.text = '10.0'; } else if (rating < 0) { - _ratingController.text = '0'; + _ratingController.text = '0.0'; } } } - setState(() {}); // 更新星星显示 + setState(() {}); }, ), ); @@ -1018,16 +1322,18 @@ class _MovieFormPageState extends State { final hasPoster = _posterPath != null && _posterPath!.isNotEmpty; return Column( + mainAxisSize: MainAxisSize.min, children: [ GestureDetector( onTap: _showCoverOptions, child: Container( - width: 140, - height: 200, + width: 120, + height: 170, decoration: BoxDecoration( color: const Color(0xFFF5F5F5), - border: Border.all(color: const Color(0xFFE5E5E5), width: 0.5), + borderRadius: BorderRadius.circular(8), ), + clipBehavior: Clip.antiAlias, child: hasPoster ? Image.file( File(_posterPath!), @@ -1040,28 +1346,29 @@ class _MovieFormPageState extends State { // 清空海报按钮(仅当有海报时显示) if (hasPoster) Padding( - padding: const EdgeInsets.only(top: 12), + padding: const EdgeInsets.only(top: 10), child: GestureDetector( onTap: () => setState(() => _posterPath = null), child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( - border: Border.all(color: const Color(0xFFE5E5E5)), + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(16), ), - child: const Row( + child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( - Icons.hide_image_outlined, - size: 16, - color: Color(0xFF666666), + Icons.delete_outline, + size: 14, + color: Colors.grey[600], ), - SizedBox(width: 4), + const SizedBox(width: 4), Text( - '清空海报', + '移除海报', style: TextStyle( - fontSize: 13, - color: Color(0xFF666666), + fontSize: 12, + color: Colors.grey[600], ), ), ], @@ -1188,16 +1495,16 @@ class _MovieFormPageState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - Icons.add_photo_alternate_outlined, - size: 40, - color: Color(0xFF999999), + Icons.image_outlined, + size: 32, + color: Color(0xFFCCCCCC), ), - SizedBox(height: 12), + SizedBox(height: 8), Text( - '点击添加海报', + '海报', style: TextStyle( - fontSize: 14, - color: Color(0xFF999999), + fontSize: 12, + color: Color(0xFFAAAAAA), ), ), ], @@ -1403,12 +1710,68 @@ class _MovieFormPageState extends State { } } + /// 收集所有多值字段输入框中的未提交内容 + void _collectUnsubmittedValues() { + // 别名 + final alternateTitlesController = _tagControllers['alternateTitles']; + if (alternateTitlesController != null) { + final value = alternateTitlesController.text.trim(); + if (value.isNotEmpty && !_alternateTitles.contains(value)) { + _alternateTitles.add(value); + alternateTitlesController.clear(); + } + } + + // 导演 + final directorsController = _tagControllers['directors']; + if (directorsController != null) { + final value = directorsController.text.trim(); + if (value.isNotEmpty && !_directors.contains(value)) { + _directors.add(value); + directorsController.clear(); + } + } + + // 编剧 + final writersController = _tagControllers['writers']; + if (writersController != null) { + final value = writersController.text.trim(); + if (value.isNotEmpty && !_writers.contains(value)) { + _writers.add(value); + writersController.clear(); + } + } + + // 主演 + final actorsController = _tagControllers['actors']; + if (actorsController != null) { + final value = actorsController.text.trim(); + if (value.isNotEmpty && !_actors.contains(value)) { + _actors.add(value); + actorsController.clear(); + } + } + + // 类型 + final genresController = _tagControllers['genres']; + if (genresController != null) { + final value = genresController.text.trim(); + if (value.isNotEmpty && !_genres.contains(value)) { + _genres.add(value); + genresController.clear(); + } + } + } + /// 保存影视 Future _saveMovie() async { if (!_formKey.currentState!.validate()) { return; } + // 收集所有多值字段输入框中的未提交内容 + _collectUnsubmittedValues(); + final rating = _ratingController.text.isNotEmpty ? double.tryParse(_ratingController.text) : null; @@ -1512,3 +1875,248 @@ class _MovieFormPageState extends State { return null; } } + +/// 多值输入对话框组件 +class _MultiValueDialog extends StatefulWidget { + final String title; + final List initialValues; + final String hint; + + const _MultiValueDialog({ + required this.title, + required this.initialValues, + required this.hint, + }); + + @override + State<_MultiValueDialog> createState() => _MultiValueDialogState(); +} + +class _MultiValueDialogState extends State<_MultiValueDialog> { + late List values; + final controller = TextEditingController(); + + @override + void initState() { + super.initState(); + values = List.from(widget.initialValues); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + void _addValue(String value) { + final trimmed = value.trim(); + if (trimmed.isNotEmpty && !values.contains(trimmed)) { + setState(() { + values.add(trimmed); + }); + controller.clear(); + } + } + + void _removeValue(int index) { + setState(() { + values.removeAt(index); + }); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text( + widget.title, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + content: SizedBox( + width: double.maxFinite, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 已添加的值列表 + if (values.isNotEmpty) + Wrap( + spacing: 8, + runSpacing: 8, + children: values.asMap().entries.map((entry) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + entry.value, + style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)), + ), + const SizedBox(width: 4), + GestureDetector( + onTap: () => _removeValue(entry.key), + child: const Icon(Icons.close, size: 14, color: Color(0xFF999999)), + ), + ], + ), + ); + }).toList(), + ), + if (values.isNotEmpty) const SizedBox(height: 16), + // 输入框 + Row( + children: [ + Expanded( + child: TextField( + controller: controller, + autofocus: true, + style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A)), + decoration: InputDecoration( + hintText: widget.hint, + hintStyle: const TextStyle(fontSize: 14, color: Color(0xFFAAAAAA)), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFFE0E0E0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFFE0E0E0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFF1A1A1A)), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + ), + onSubmitted: _addValue, + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: () => _addValue(controller.text), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.add, size: 20, color: Colors.white), + ), + ), + ], + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消', style: TextStyle(color: Color(0xFF999999))), + ), + ElevatedButton( + onPressed: () { + // 如果输入框还有内容,先添加 + _addValue(controller.text); + Navigator.pop(context, values); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF1A1A1A), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text('确定'), + ), + ], + ); + } +} + +/// 文本输入对话框组件 +class _TextInputDialog extends StatefulWidget { + final String title; + final String initialValue; + final String hint; + final int maxLines; + + const _TextInputDialog({ + required this.title, + required this.initialValue, + required this.hint, + required this.maxLines, + }); + + @override + State<_TextInputDialog> createState() => _TextInputDialogState(); +} + +class _TextInputDialogState extends State<_TextInputDialog> { + late final TextEditingController controller; + + @override + void initState() { + super.initState(); + controller = TextEditingController(text: widget.initialValue); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text( + widget.title, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + content: TextField( + controller: controller, + maxLines: widget.maxLines, + autofocus: true, + style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A)), + decoration: InputDecoration( + hintText: widget.hint, + hintStyle: const TextStyle(fontSize: 14, color: Color(0xFFAAAAAA)), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFFE0E0E0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFFE0E0E0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFF1A1A1A)), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消', style: TextStyle(color: Color(0xFF999999))), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, controller.text.trim()), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF1A1A1A), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text('确定'), + ), + ], + ); + } +} diff --git a/lib/pages/movies/movie_share_page.dart b/lib/pages/movies/movie_share_page.dart new file mode 100644 index 0000000..e08d998 --- /dev/null +++ b/lib/pages/movies/movie_share_page.dart @@ -0,0 +1,320 @@ +import 'dart:io'; +import 'dart:typed_data'; +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'; + +/// 影视分享海报页面 +class MovieSharePage extends StatefulWidget { + final Movie movie; + + const MovieSharePage({super.key, required this.movie}); + + @override + State createState() => _MovieSharePageState(); +} + +class _MovieSharePageState extends State { + final GlobalKey _posterKey = GlobalKey(); + bool _isGenerating = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF5F5F5), + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.close, color: Color(0xFF1A1A1A)), + onPressed: () => Navigator.pop(context), + ), + title: const Text( + '分享海报', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Color(0xFF1A1A1A), + ), + ), + centerTitle: true, + actions: [ + TextButton( + onPressed: _isGenerating ? null : _generateAndShare, + child: _isGenerating + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text( + '分享', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF1A1A1A), + ), + ), + ), + const SizedBox(width: 8), + ], + ), + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: RepaintBoundary( + key: _posterKey, + child: _buildPosterWidget(), + ), + ), + ), + ); + } + + /// 构建海报 Widget + Widget _buildPosterWidget() { + final movie = widget.movie; + final hasPoster = movie.posterPath != null && movie.posterPath!.isNotEmpty; + + return Container( + width: 320, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 海报图片 + if (hasPoster) + ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + child: Image.file( + File(movie.posterPath!), + width: 320, + height: 200, + fit: BoxFit.cover, + ), + ), + + // 内容区域 + Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 标题 + Text( + movie.title, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Color(0xFF1A1A1A), + ), + ), + + // 别名 + if (movie.alternateTitles.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + movie.alternateTitles.join(' / '), + style: const TextStyle( + fontSize: 14, + color: Color(0xFF666666), + ), + ), + ], + + const SizedBox(height: 16), + + // 评分 + if (movie.rating != null && movie.rating! > 0) ...[ + Row( + children: [ + const Icon( + Icons.star, + size: 18, + color: Color(0xFFFFB800), + ), + const SizedBox(width: 4), + Text( + movie.rating!.toStringAsFixed(1), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFFFFB800), + ), + ), + const SizedBox(width: 4), + const Text( + '/ 10', + style: TextStyle( + fontSize: 12, + color: Color(0xFF999999), + ), + ), + ], + ), + const SizedBox(height: 12), + ], + + // 导演 + if (movie.directors.isNotEmpty) + _buildInfoRow('导演', movie.directors.join(' / ')), + + // 编剧 + if (movie.writers.isNotEmpty) + _buildInfoRow('编剧', movie.writers.join(' / ')), + + // 主演 + if (movie.actors.isNotEmpty) + _buildInfoRow('主演', movie.actors.take(3).join(' / ')), + + // 类型 + if (movie.genres.isNotEmpty) + _buildInfoRow('类型', movie.genres.join(' / ')), + + // 上映日期 + if (movie.releaseDate != null) + _buildInfoRow( + '上映', + '${movie.releaseDate!.year}.${movie.releaseDate!.month.toString().padLeft(2, '0')}.${movie.releaseDate!.day.toString().padLeft(2, '0')}', + ), + + const SizedBox(height: 16), + + // 简介 + if (movie.summary != null && movie.summary!.isNotEmpty) ...[ + const Text( + '简介', + style: TextStyle( + fontSize: 12, + color: Color(0xFF999999), + ), + ), + const SizedBox(height: 8), + Text( + movie.summary!, + style: const TextStyle( + fontSize: 13, + color: Color(0xFF666666), + height: 1.6, + ), + maxLines: 5, + overflow: TextOverflow.ellipsis, + ), + ], + + const SizedBox(height: 20), + + // 底部标识 + const Divider(height: 1, color: Color(0xFFE8E8E8)), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.movie_outlined, + size: 14, + color: const Color(0xFF1A1A1A).withOpacity(0.5), + ), + const SizedBox(width: 6), + Text( + '来自 MookNote', + style: TextStyle( + fontSize: 12, + color: const Color(0xFF1A1A1A).withOpacity(0.5), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } + + /// 构建信息行 + Widget _buildInfoRow(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$label:', + style: const TextStyle( + fontSize: 13, + color: Color(0xFF999999), + ), + ), + Expanded( + child: Text( + value, + style: const TextStyle( + fontSize: 13, + color: Color(0xFF333333), + ), + ), + ), + ], + ), + ); + } + + /// 生成并分享海报 + 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 = 'movie_poster_${DateTime.now().millisecondsSinceEpoch}.png'; + final file = File('${tempDir.path}/$fileName'); + await file.writeAsBytes(bytes); + + // 分享 + await Share.shareXFiles( + [XFile(file.path)], + text: '分享影视:${widget.movie.title}', + ); + } catch (e) { + if (mounted) { + ToastUtil.show(context, '生成海报失败:$e'); + } + } finally { + if (mounted) { + setState(() => _isGenerating = false); + } + } + } +} diff --git a/lib/pages/note/note_tab_page.dart b/lib/pages/note/note_tab_page.dart index 82d2821..3a2ff70 100644 --- a/lib/pages/note/note_tab_page.dart +++ b/lib/pages/note/note_tab_page.dart @@ -177,8 +177,19 @@ class _NoteTabPageState extends State { final displayedIds = _displayedNotes.map((n) => n.id).toSet(); final hasNewNotes = allNotes.any((note) => !displayedIds.contains(note.id)); - // 如果有笔记被移除或有新增,更新列表 - if (_displayedNotes.length < initialLength || hasNewNotes) { + // 检查已存在的笔记是否有更新(通过比较updatedAt) + bool hasUpdates = false; + for (int i = 0; i < _displayedNotes.length; i++) { + final localNote = _displayedNotes[i]; + final providerNote = allNotes.firstWhere((n) => n.id == localNote.id); + if (localNote.updatedAt != providerNote.updatedAt) { + hasUpdates = true; + break; + } + } + + // 如果有笔记被移除、有新增、或有更新,更新列表 + if (_displayedNotes.length < initialLength || hasNewNotes || hasUpdates) { // 清空并重新加载所有数据 _displayedNotes.clear(); _displayedNotes.addAll(allNotes); diff --git a/pubspec.yaml b/pubspec.yaml index 75a4042..e224c4c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: mooknote description: "app for tracking movies, books, and notes" publish_to: 'none' -version: 0.1.7+1 +version: 0.1.7+2 environment: sdk: ^3.5.0