diff --git a/README.md b/README.md index 8bbac73..e810a36 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,12 @@ flutter run flutter build apk --release # 或构建 App Bundle(推荐用于 Google Play) flutter build appbundle --release + +# 使用国内镜像构建运行 +$env:PUB_HOSTED_URL="https://pub.flutter-io.cn" +$env:FLUTTER_STORAGE_BASE_URL="https://storage.flutter-io.cn" +flutter pub get +flutter run ``` ### 3. 连接设备或启动模拟器 diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html index ff901e1..2d58dd2 100644 --- a/android/build/reports/problems/problems-report.html +++ b/android/build/reports/problems/problems-report.html @@ -650,7 +650,7 @@ code + .copy-button { diff --git a/lib/pages/book/book_tab_page.dart b/lib/pages/book/book_tab_page.dart index 898bf42..cd1a8fd 100644 --- a/lib/pages/book/book_tab_page.dart +++ b/lib/pages/book/book_tab_page.dart @@ -46,7 +46,7 @@ class BookTabPage extends StatelessWidget { color: const Color(0xFF1A1A1A), backgroundColor: Colors.white, child: GridView.builder( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, childAspectRatio: 0.55, diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index f0be66a..f56fd0f 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -23,11 +23,21 @@ class _HomePageState extends State { ? CustomDrawer() : null, - // 主体内容 - 根据底部导航切换 - body: _buildBody(), - - // 底部导航栏 - bottomNavigationBar: const CustomBottomNavBar(), + // 主体内容 - 使用 Stack 让 dock 栏悬浮在内容上方 + body: Stack( + children: [ + // 底层:主体内容 + _buildBody(), + + // 顶层:悬浮 dock 栏 + const Positioned( + left: 0, + right: 0, + bottom: 0, + child: CustomBottomNavBar(), + ), + ], + ), ); } diff --git a/lib/pages/movies/douban_webview_page.dart b/lib/pages/movies/douban_webview_page.dart new file mode 100644 index 0000000..44b8fe5 --- /dev/null +++ b/lib/pages/movies/douban_webview_page.dart @@ -0,0 +1,387 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +/// 豆瓣影视WebView页面 - 用于抓取影视信息 +class DoubanWebViewPage extends StatefulWidget { + final String url; + + const DoubanWebViewPage({super.key, required this.url}); + + @override + State createState() => _DoubanWebViewPageState(); +} + +class _DoubanWebViewPageState extends State { + late WebViewController _controller; + bool _isLoading = true; + bool _canExtract = false; + bool _isExtracting = false; // 防止重复提取 + + @override + void initState() { + super.initState(); + _initWebView(); + } + + @override + void dispose() { + // 清理 WebView 资源 + _controller.loadRequest(Uri.parse('about:blank')); + super.dispose(); + } + + void _initWebView() { + _controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setNavigationDelegate( + NavigationDelegate( + onPageStarted: (String url) { + if (mounted) { + setState(() { + _isLoading = true; + }); + } + }, + onPageFinished: (String url) { + if (mounted) { + setState(() { + _isLoading = false; + _canExtract = url.contains('douban.com/subject'); + }); + } + }, + ), + ) + ..loadRequest(Uri.parse(widget.url)); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + title: const Text('豆瓣影视'), + leading: _buildBackButton(), + actions: [ + // 提取按钮 - 始终显示 + _buildActionButton( + icon: Icons.auto_fix_high_outlined, + onPressed: _showExtractedInfo, + tooltip: '提取信息', + ), + // 刷新按钮 + _buildActionButton( + icon: Icons.refresh, + onPressed: () => _controller.reload(), + tooltip: '刷新', + ), + const SizedBox(width: 8), + ], + ), + body: Stack( + children: [ + WebViewWidget(controller: _controller), + // 加载指示器 + if (_isLoading) + const Center( + child: CircularProgressIndicator(), + ), + ], + ), + + ); + } + + /// 构建返回按钮 + Widget _buildBackButton() { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.3), + borderRadius: BorderRadius.circular(8), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + // 停止加载并返回 + _controller.loadRequest(Uri.parse('about:blank')); + Navigator.pop(context); + }, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.all(8), + child: const Icon(Icons.arrow_back, color: Colors.white, size: 22), + ), + ), + ), + ); + } + + /// 构建右上角操作按钮 + Widget _buildActionButton({ + required IconData icon, + required VoidCallback onPressed, + required String tooltip, + }) { + 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: const Color(0xFF1A1A1A), size: 22), + ), + ), + ), + ); + } + + /// 显示提取的信息对话框 + Future _showExtractedInfo() async { + // 先提取信息 + final movieInfo = await _extractMovieInfo(); + if (movieInfo == null) return; + + // 显示提取的信息 + if (mounted) { + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: const Text( + '提取的影视信息', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Color(0xFF1A1A1A), + ), + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInfoRow('标题', movieInfo['title']?.toString() ?? '未提取到'), + _buildInfoRow('年份', movieInfo['year']?.toString() ?? '未提取到'), + _buildInfoRow('评分', movieInfo['rating']?.toString() ?? '未提取到'), + _buildInfoRow('导演', movieInfo['director']?.toString() ?? '未提取到'), + _buildInfoRow('类型', movieInfo['genres']?.toString() ?? '未提取到'), + _buildInfoRow('上映日期', movieInfo['releaseDate']?.toString() ?? '未提取到'), + if (movieInfo['summary'] != null) + _buildInfoRow('简介', movieInfo['summary'].toString().substring(0, + movieInfo['summary'].toString().length > 100 ? 100 : movieInfo['summary'].toString().length) + '...'), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text( + '取消', + style: TextStyle(color: Color(0xFF999999)), + ), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + Navigator.pop(context, movieInfo); + }, + child: const Text( + '使用此信息', + style: TextStyle(color: Color(0xFF1A1A1A), fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } + } + + /// 构建信息行 + Widget _buildInfoRow(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 64, + child: Text( + label, + style: const TextStyle( + fontSize: 14, + color: Color(0xFF999999), + ), + ), + ), + Expanded( + child: Text( + value, + style: const TextStyle( + fontSize: 14, + color: Color(0xFF1A1A1A), + ), + ), + ), + ], + ), + ); + } + + /// 提取影视信息 + Future?> _extractMovieInfo() async { + // 检查是否已提取过,避免重复点击 + if (_isExtracting) return null; + + try { + _isExtracting = true; + + // 显示加载提示 + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => const Center( + child: CircularProgressIndicator(), + ), + ); + + // 执行JavaScript代码提取页面信息 + final result = await _controller.runJavaScriptReturningResult(r''' + (function() { + const info = {}; + + // 获取标题 - 移动版页面 + const titleEl = document.querySelector('.sub-title'); + info.title = titleEl ? titleEl.textContent.trim() : ''; + + // 获取年份 - 从 original-title 中提取 + const originalTitleEl = document.querySelector('.sub-original-title'); + if (originalTitleEl) { + const yearMatch = originalTitleEl.textContent.match(/\((\d{4})\)/); + info.year = yearMatch ? yearMatch[1] : ''; + } else { + info.year = ''; + } + + + // 获取评分 - 移动版可能在 mark-item 中 + const ratingEl = document.querySelector('.rating-num') || document.querySelector('.score'); + info.rating = ratingEl ? ratingEl.textContent.trim() : ''; + + // 获取导演 - 从演职员列表中找 + const directorEl = document.querySelector('.movie-celebrities .item__celebrity .role'); + if (directorEl && directorEl.textContent.includes('导演')) { + const nameEl = directorEl.closest('.item__celebrity').querySelector('.name'); + info.director = nameEl ? nameEl.textContent.trim() : ''; + } else { + info.director = ''; + } + + // 获取编剧 - 从演职员列表中找(匹配"编剧"或"剧本") + const writerEls = document.querySelectorAll('.movie-celebrities .item__celebrity'); + const writers = []; + writerEls.forEach(el => { + const roleEl = el.querySelector('.role'); + if (roleEl && (roleEl.textContent.includes('编剧') || roleEl.textContent.includes('剧本'))) { + const nameEl = el.querySelector('.name'); + if (nameEl) writers.push(nameEl.textContent.trim()); + } + }); + info.writers = writers; + + // 获取主演- 从演职员列表中找前5个 + const actorEls = document.querySelectorAll('.movie-celebrities .item__celebrity'); + const actors = []; + actorEls.forEach(el => { + const roleEl = el + .querySelector('.role'); + if (roleEl && ( + roleEl.textContent.includes('配音') || + roleEl.textContent.includes('主演') || + roleEl.textContent.includes('饰') + )) { + const nameEl = el.querySelector('.name'); + if (nameEl) actors.push(nameEl.textContent.trim()); + } + }); + info.actors = actors; + + // 获取类型 - 从 sub-meta 或标签中提取 + const metaEl = document.querySelector('.sub-meta'); + if (metaEl) { + const metaText = metaEl.textContent; + const parts = metaText.split('/').map(s => s.trim()); + // 过滤出类型(通常是中文,不是日期,不是时长) + info.genres = parts.filter(p => + p && !p.match(/^\d{4}/) && !p.includes('分钟') && !p.includes('上映') + ).join(','); + } else { + info.genres = ''; + } + + // 获取上映日期 + if (metaEl) { + const dateMatch = metaEl.textContent.match(/(\d{4}-\d{2}-\d{2})/); + info.releaseDate = dateMatch ? dateMatch[1] : ''; + } else { + info.releaseDate = ''; + } + + // 获取简介 + const summaryEl = document.querySelector('.subject-intro p'); + if (summaryEl) { + info.summary = summaryEl.textContent.trim().substring(0, 500); + } else { + info.summary = ''; + } + + // 获取别名 - 从 original-title 中提取(去掉年份) + if (originalTitleEl) { + const fullText = originalTitleEl.textContent.trim(); + info.alternateTitles = [fullText.replace(/\s*\(\d{4}\)\s*$/, '')]; + } else { + info.alternateTitles = []; + } + + return JSON.stringify(info); + })() + '''); + + // 关闭加载提示 + if (mounted) Navigator.pop(context); + + // 解析提取的信息 + // result 是 JavaScript 执行结果,已经是 JSON 字符串(带引号的) + final String jsonStr = result.toString(); + // 去除 Dart 字符串转义后外层可能多余的引号 + final String cleanJson = jsonStr.startsWith('"') && jsonStr.endsWith('"') + ? jsonDecode(jsonStr) as String + : jsonStr; + final Map movieInfo = jsonDecode(cleanJson) as Map; + + return movieInfo; + } catch (e) { + // 关闭加载提示 + if (mounted) Navigator.pop(context); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('提取信息失败: $e')), + ); + } + return null; + } finally { + _isExtracting = false; + } + } +} diff --git a/lib/pages/movies/movie_detail_page.dart b/lib/pages/movies/movie_detail_page.dart index 4eea7be..a805b32 100644 --- a/lib/pages/movies/movie_detail_page.dart +++ b/lib/pages/movies/movie_detail_page.dart @@ -168,13 +168,6 @@ class _MovieDetailPageState extends State { background: _buildPosterSection(movie), ), actions: [ - // 下载海报按钮(仅当有海报时显示) - if (hasPoster) - _buildActionButton( - icon: Icons.download_outlined, - onPressed: () => _downloadPoster(movie), - tooltip: '下载海报', - ), // 清空海报按钮(仅当有海报时显示) if (hasPoster) _buildActionButton( @@ -829,40 +822,6 @@ class _MovieDetailPageState extends State { ); } - /// 下载海报到本地 - Future _downloadPoster(Movie movie) async { - if (movie.posterPath == null || movie.posterPath!.isEmpty) { - ToastUtil.show(context, '没有可下载的海报'); - return; - } - - try { - final sourceFile = File(movie.posterPath!); - if (!await sourceFile.exists()) { - ToastUtil.show(context, '海报文件不存在'); - return; - } - - // 生成文件名:影视名称_时间戳_海报.扩展名 - final timestamp = DateTime.now().millisecondsSinceEpoch; - final fileName = '${movie.title}_${timestamp}_海报${path.extension(movie.posterPath!)}'; - - // 复制到临时目录 - final tempDir = await getTemporaryDirectory(); - final tempFile = File(path.join(tempDir.path, fileName)); - await sourceFile.copy(tempFile.path); - - // 使用分享功能让用户选择保存位置 - await Share.shareXFiles( - [XFile(tempFile.path)], - subject: '${movie.title} 海报', - text: '下载自 MookNote', - ); - } catch (e) { - ToastUtil.show(context, '下载失败: $e'); - } - } - /// 请求存储权限 Future _requestStoragePermission() async { // Android 13+ 使用新的权限 diff --git a/lib/pages/movies/movie_form_page.dart b/lib/pages/movies/movie_form_page.dart index 172e2da..3995bd0 100644 --- a/lib/pages/movies/movie_form_page.dart +++ b/lib/pages/movies/movie_form_page.dart @@ -84,10 +84,12 @@ class _MovieFormPageState extends State { @override void dispose() { + // 先 dispose controllers _titleController.dispose(); _summaryController.dispose(); _ratingController.dispose(); _tagControllers.values.forEach((c) => c.dispose()); + // 最后调用 super.dispose super.dispose(); } @@ -126,6 +128,195 @@ class _MovieFormPageState extends State { ); } + /// 显示快捷添加对话框 + void _showQuickAddDialog() { + final textController = TextEditingController(); + + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: const Text( + '快捷添加', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Color(0xFF1A1A1A), + ), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '请输入豆瓣影视链接:', + style: TextStyle( + fontSize: 14, + color: Color(0xFF666666), + ), + ), + const SizedBox(height: 12), + TextField( + controller: textController, + autofocus: true, + decoration: InputDecoration( + hintText: 'https://m.douban.com/subject/...', + hintStyle: const TextStyle(fontSize: 14, color: Color(0xFFCCCCCC)), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFFE5E5E5)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFFE5E5E5)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFF1A1A1A)), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + ), + onSubmitted: (url) async { + if (url.trim().isNotEmpty) { + // 先移除焦点 + FocusManager.instance.primaryFocus?.unfocus(); + await Future.delayed(const Duration(milliseconds: 50)); + if (dialogContext.mounted) { + Navigator.of(dialogContext).pop(url); + } + } + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () async { + // 先移除焦点,等待一帧确保焦点已释放 + FocusManager.instance.primaryFocus?.unfocus(); + await Future.delayed(const Duration(milliseconds: 50)); + if (dialogContext.mounted) { + Navigator.of(dialogContext).pop(null); + } + }, + child: const Text( + '取消', + style: TextStyle(color: Color(0xFF999999)), + ), + ), + TextButton( + onPressed: () async { + final url = textController.text.trim(); + if (url.isNotEmpty) { + // 先移除焦点 + FocusManager.instance.primaryFocus?.unfocus(); + await Future.delayed(const Duration(milliseconds: 50)); + if (dialogContext.mounted) { + Navigator.of(dialogContext).pop(url); + } + } + }, + child: const Text( + '确定', + style: TextStyle(color: Color(0xFF1A1A1A), fontWeight: FontWeight.w600), + ), + ), + ], + ), + ).then((result) { + // 延迟 dispose,确保 widget tree 已释放 controller + WidgetsBinding.instance.addPostFrameCallback((_) { + textController.dispose(); + }); + // 处理结果 + if (result != null && result is String && result.isNotEmpty) { + _openDoubanWebView(result); + } + }); + } + + /// 打开豆瓣WebView页面 + Future _openDoubanWebView(String url) async { + // 导航到WebView页面并等待返回结果 + final result = await Navigator.pushNamed( + context, + '/douban-webview', + arguments: url, + ); + + // 处理返回的影视信息 + if (result != null && result is Map) { + _fillMovieInfo(result); + } + } + + /// 填充影视信息到表单 + void _fillMovieInfo(Map info) { + setState(() { + // 填充标题 + if (info['title'] != null && info['title'].toString().isNotEmpty) { + _titleController.text = info['title'].toString(); + } + + // 填充评分 + if (info['rating'] != null && info['rating'].toString().isNotEmpty) { + _ratingController.text = info['rating'].toString(); + } + + // 填充导演 + if (info['director'] != null && info['director'].toString().isNotEmpty) { + _directors = [info['director'].toString()]; + } + + // 填充编剧 + if (info['writers'] != null && info['writers'] is List) { + _writers = (info['writers'] as List).map((w) => w.toString()).toList(); + } + + // 填充演员 + if (info['actors'] != null && info['actors'] is List) { + _actors = (info['actors'] as List).map((a) => a.toString()).toList(); + } + + // 填充类型 + if (info['genres'] != null && info['genres'].toString().isNotEmpty) { + _genres = info['genres'].toString().split(',').map((g) => g.trim()).toList(); + } + + // 填充别名 + if (info['alternateTitles'] != null && info['alternateTitles'] is List) { + _alternateTitles = (info['alternateTitles'] as List).map((t) => t.toString()).toList(); + } + + // 填充简介 + if (info['summary'] != null && info['summary'].toString().isNotEmpty) { + _summaryController.text = info['summary'].toString(); + } + + // 填充上映日期 + if (info['releaseDate'] != null && info['releaseDate'].toString().isNotEmpty) { + final dateStr = info['releaseDate'].toString(); + // 尝试解析日期 + try { + // 处理格式如 "2023-01-01(中国大陆)" + final cleanDate = dateStr.split('(')[0].trim(); + _releaseDate = DateTime.parse(cleanDate); + } catch (e) { + // 解析失败则忽略 + } + } + + }); + + // 显示成功提示 + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('已自动填充影视信息')), + ); + } + } + @override Widget build(BuildContext context) { final isEdit = widget.movie != null; @@ -135,6 +326,13 @@ class _MovieFormPageState extends State { appBar: AppBar( title: Text(isEdit ? '编辑影视' : '添加影视'), actions: [ + // 快捷添加按钮(仅添加模式显示) + if (!isEdit) + _buildActionButton( + icon: Icons.auto_fix_high_outlined, + onPressed: _showQuickAddDialog, + tooltip: '快捷添加', + ), // 保存按钮 _buildActionButton( icon: Icons.save_outlined, @@ -765,7 +963,7 @@ class _MovieFormPageState extends State { return Column( children: [ GestureDetector( - onTap: _pickCover, + onTap: _showCoverOptions, child: Container( width: 140, height: 200, @@ -818,6 +1016,107 @@ class _MovieFormPageState extends State { ); } + /// 显示封面选择选项 + void _showCoverOptions() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 顶部指示条 + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFFE0E0E0), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 20), + // 标题 + const Padding( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + '添加海报', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Color(0xFF1A1A1A), + ), + ), + ), + ), + const SizedBox(height: 16), + // 本地图片选项 + _buildCoverOption( + icon: Icons.photo_library_outlined, + title: '从相册选择', + onTap: () { + Navigator.pop(context); + _pickCover(); + }, + ), + ], + ), + ), + ), + ); + } + + /// 构建封面选项 + Widget _buildCoverOption({ + required IconData icon, + required String title, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + icon, + size: 22, + color: const Color(0xFF666666), + ), + ), + const SizedBox(width: 16), + Text( + title, + style: const TextStyle( + fontSize: 16, + color: Color(0xFF1A1A1A), + ), + ), + const Spacer(), + const Icon( + Icons.chevron_right, + color: Color(0xFFCCCCCC), + size: 20, + ), + ], + ), + ), + ); + } + Widget _buildCoverPlaceholder() { return const Column( mainAxisAlignment: MainAxisAlignment.center, diff --git a/lib/pages/movies/movie_tab_page.dart b/lib/pages/movies/movie_tab_page.dart index fd6cac4..dfb3299 100644 --- a/lib/pages/movies/movie_tab_page.dart +++ b/lib/pages/movies/movie_tab_page.dart @@ -46,7 +46,7 @@ class MovieTabPage extends StatelessWidget { color: const Color(0xFF1A1A1A), backgroundColor: Colors.white, child: GridView.builder( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, childAspectRatio: 0.55, diff --git a/lib/pages/note/note_tab_page.dart b/lib/pages/note/note_tab_page.dart index 5ea3378..82d2821 100644 --- a/lib/pages/note/note_tab_page.dart +++ b/lib/pages/note/note_tab_page.dart @@ -137,7 +137,7 @@ class _NoteTabPageState extends State { backgroundColor: Colors.white, child: ListView.builder( controller: _scrollController, - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), itemCount: _displayedNotes.length + (_hasMore ? 1 : 0), itemBuilder: (context, index) { if (index >= _displayedNotes.length) { diff --git a/lib/pages/profile_page.dart b/lib/pages/profile_page.dart index 2c40c9f..ca6fae4 100644 --- a/lib/pages/profile_page.dart +++ b/lib/pages/profile_page.dart @@ -129,7 +129,8 @@ class _ProfilePageState extends State { ), ), - const SizedBox(height: 32), + // 底部留白,避免被 dock 栏遮挡 + const SizedBox(height: 100), ], ), ), diff --git a/lib/utils/app_router.dart b/lib/utils/app_router.dart index ad93f80..e6be35d 100644 --- a/lib/utils/app_router.dart +++ b/lib/utils/app_router.dart @@ -6,6 +6,7 @@ import '../pages/note/note_form_page.dart'; import '../pages/movies/movie_detail_page.dart'; import '../pages/book/book_detail_page.dart'; import '../pages/note/note_detail_page.dart'; +import '../pages/movies/douban_webview_page.dart'; /// 路由生成器 class AppRouter { @@ -73,6 +74,12 @@ class AppRouter { builder: (_) => NoteDetailPage(note: note), ); + case '/douban-webview': + final url = settings.arguments as String; + return MaterialPageRoute( + builder: (_) => DoubanWebViewPage(url: url), + ); + default: return MaterialPageRoute( builder: (_) => Scaffold( diff --git a/lib/widgets/bottom_nav_bar.dart b/lib/widgets/bottom_nav_bar.dart index 162af40..947d17a 100644 --- a/lib/widgets/bottom_nav_bar.dart +++ b/lib/widgets/bottom_nav_bar.dart @@ -2,43 +2,72 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/app_provider.dart'; -/// 自定义底部导航栏 - 极简主义设计 +/// 自定义底部导航栏 - Dock栏悬浮设计 class CustomBottomNavBar extends StatelessWidget { const CustomBottomNavBar({super.key}); @override Widget build(BuildContext context) { + // 获取底部安全区域高度 + final bottomPadding = MediaQuery.of(context).padding.bottom; + return Consumer( builder: (context, provider, child) { return Container( - height: 64, - decoration: const BoxDecoration( - color: Colors.white, - border: Border( - top: BorderSide(color: Color(0xFFE5E5E5), width: 0.5), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, + // 高度:导航栏本身高度 + 底部安全距离 + 上下边距 + height: 64 + bottomPadding + 16, + color: Colors.transparent, + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - // 主页按钮 - _buildNavItem( - icon: Icons.home_outlined, - activeIcon: Icons.home, - isActive: provider.bottomNavIndex == 0, - onTap: () => provider.setBottomNavIndex(0), - ), - - // 中间新增按钮 - _buildAddButton(context, provider), - - // 我的按钮 - _buildNavItem( - icon: Icons.person_outline, - activeIcon: Icons.person, - isActive: provider.bottomNavIndex == 2, - onTap: () => provider.setBottomNavIndex(2), + // Dock栏主体 - 悬浮效果 + Container( + height: 56, + margin: const EdgeInsets.symmetric(horizontal: 40), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(28), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 20, + offset: const Offset(0, 4), + spreadRadius: 0, + ), + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 8, + offset: const Offset(0, 2), + spreadRadius: -2, + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + // 主页按钮 + _buildNavItem( + icon: Icons.home_outlined, + activeIcon: Icons.home, + isActive: provider.bottomNavIndex == 0, + onTap: () => provider.setBottomNavIndex(0), + ), + + // 中间新增按钮 + _buildAddButton(context, provider), + + // 我的按钮 + _buildNavItem( + icon: Icons.person_outline, + activeIcon: Icons.person, + isActive: provider.bottomNavIndex == 2, + onTap: () => provider.setBottomNavIndex(2), + ), + ], + ), ), + // 底部安全距离占位 + SizedBox(height: bottomPadding + 8), ], ), ); @@ -53,18 +82,18 @@ class CustomBottomNavBar extends StatelessWidget { required bool isActive, required VoidCallback onTap, }) { - return Expanded( - child: InkWell( - onTap: onTap, - child: Container( - color: Colors.transparent, - padding: const EdgeInsets.symmetric(vertical: 10), - child: Center( - child: Icon( - isActive ? activeIcon : icon, - color: isActive ? const Color(0xFF1A1A1A) : const Color(0xFF999999), - size: 28, - ), + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(28), + child: Container( + width: 56, + height: 56, + color: Colors.transparent, + child: Center( + child: Icon( + isActive ? activeIcon : icon, + color: isActive ? const Color(0xFF1A1A1A) : const Color(0xFF999999), + size: 26, ), ), ), diff --git a/lib/widgets/custom_drawer.dart b/lib/widgets/custom_drawer.dart index a04faee..35f190e 100644 --- a/lib/widgets/custom_drawer.dart +++ b/lib/widgets/custom_drawer.dart @@ -37,43 +37,51 @@ class _CustomDrawerState extends State { Widget build(BuildContext context) { return Drawer( backgroundColor: Colors.white, - child: Column( - children: [ - // 顶部用户信息区域 - _buildHeader(context), - - const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)), - - // 回顾功能区域 - _buildMemorySection(context), - - const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)), - - // 日历热力图区域 - _buildCalendarSection(context), - - const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)), - - // 菜单项列表 - Expanded( - child: ListView( - padding: EdgeInsets.zero, - children: [], - ), - ), - - // 底部版本信息 - Container( - padding: const EdgeInsets.all(24), - child: Text( - 'MookNote v$_version', - style: const TextStyle( - fontSize: 12, - color: Color(0xFF999999), + child: SafeArea( + child: Column( + children: [ + // 顶部用户信息区域 + _buildHeader(context), + + const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)), + + // 可滚动区域 - 包含回顾、日历和菜单 + Expanded( + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 回顾功能区域 + _buildMemorySection(context), + + const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)), + + // 日历热力图区域 + _buildCalendarSection(context), + + const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)), + + // 底部留白,避免内容被遮挡 + const SizedBox(height: 16), + ], + ), ), ), - ), - ], + + // 底部版本信息 - 固定在底部 + Container( + padding: const EdgeInsets.all(16), + child: Text( + 'MookNote v$_version', + style: const TextStyle( + fontSize: 12, + color: Color(0xFF999999), + ), + ), + ), + ], + ), ), ); }