diff --git a/lib/pages/book/book_detail_page.dart b/lib/pages/book/book_detail_page.dart index 7387286..005c98c 100644 --- a/lib/pages/book/book_detail_page.dart +++ b/lib/pages/book/book_detail_page.dart @@ -143,14 +143,28 @@ class _BookDetailPageState extends State { required String tooltip, Color backgroundColor = const Color(0xFF1A1A1A), }) { - return FloatingActionButton( - onPressed: onPressed, - tooltip: tooltip, - backgroundColor: backgroundColor, - foregroundColor: Colors.white, - mini: true, - elevation: 4, - child: Icon(icon, size: 20), + return Material( + color: Colors.transparent, + child: Ink( + width: 40, + height: 40, + decoration: BoxDecoration( + color: backgroundColor, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: backgroundColor.withValues(alpha: 0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: IconButton( + icon: Icon(icon, size: 18, color: Colors.white), + onPressed: onPressed, + tooltip: tooltip, + ), + ), ); } diff --git a/lib/pages/book/book_tab_page.dart b/lib/pages/book/book_tab_page.dart index cd1a8fd..fbffc46 100644 --- a/lib/pages/book/book_tab_page.dart +++ b/lib/pages/book/book_tab_page.dart @@ -1,22 +1,39 @@ +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../providers/app_provider.dart'; -import '../../models/data_models.dart'; +import '../../utils/user_prefs.dart'; import '../../widgets/book_status_bar.dart'; import '../../widgets/book_list_item.dart'; +import '../../widgets/animated_star_rating.dart'; +import '../../widgets/shimmer_skeleton.dart'; /// 阅读标签页 -class BookTabPage extends StatelessWidget { +class BookTabPage extends StatefulWidget { const BookTabPage({super.key}); + @override + State createState() => _BookTabPageState(); +} + +class _BookTabPageState extends State { + int _layoutStyle = 0; // 0: 封面网格, 1: 列表 + bool _firstLoad = true; + + @override + void initState() { + super.initState(); + _layoutStyle = UserPrefs().bookLayoutStyle; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _firstLoad = false); + }); + } + @override Widget build(BuildContext context) { return Column( children: [ - // 状态选择栏(读完、在读、准备读) const BookStatusBar(), - - // 书籍列表 Expanded( child: _buildBookList(context), ), @@ -24,173 +41,181 @@ class BookTabPage extends StatelessWidget { ); } - /// 构建书籍列表 Widget _buildBookList(BuildContext context) { return Consumer( builder: (context, provider, child) { - // 根据状态筛选书籍 - final statusMap = { - 0: 'read', - 1: 'reading', - 2: 'want_to_read', - }; + final statusMap = {0: 'read', 1: 'reading', 2: 'want_to_read'}; final currentStatus = statusMap[provider.bookStatusIndex]!; final books = provider.getBooksByStatus(currentStatus); - + + if (_firstLoad) { + return _buildSkeleton(); + } + if (books.isEmpty) { return _buildEmptyState(context, provider.bookStatusIndex); } - - return RefreshIndicator( - onRefresh: () async => await provider.loadBooks(), - color: const Color(0xFF1A1A1A), - backgroundColor: Colors.white, - child: GridView.builder( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - childAspectRatio: 0.55, - crossAxisSpacing: 12, - mainAxisSpacing: 16, - ), - itemCount: books.length, - itemBuilder: (context, index) { - return BookListItem(book: books[index]); - }, - ), - ); + + if (_layoutStyle == 1) { + return _buildListView(books, provider); + } + return _buildGridView(books, provider); }, ); } - /// 构建空状态提示 + Widget _buildGridView(List books, AppProvider provider) { + return RefreshIndicator( + onRefresh: () async => await provider.loadBooks(), + color: const Color(0xFF1A1A1A), + backgroundColor: Colors.white, + child: GridView.builder( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + childAspectRatio: 0.55, + crossAxisSpacing: 12, + mainAxisSpacing: 16, + ), + itemCount: books.length, + itemBuilder: (context, index) => BookListItem(book: books[index]), + ), + ); + } + + Widget _buildListView(List books, AppProvider provider) { + return RefreshIndicator( + onRefresh: () async => await provider.loadBooks(), + color: const Color(0xFF1A1A1A), + backgroundColor: Colors.white, + child: ListView.builder( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 100), + itemCount: books.length, + itemBuilder: (context, index) => _buildListCard(books[index]), + ), + ); + } + + Widget _buildListCard(book) { + return GestureDetector( + onTap: () => Navigator.pushNamed(context, '/book-detail', arguments: book), + onLongPress: () => _showDeleteDialog(context, book), + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFAFAFA), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + // 封面缩略图 + Container( + width: 48, height: 64, + decoration: BoxDecoration( + color: const Color(0xFFF0F0F0), + borderRadius: BorderRadius.circular(6), + ), + clipBehavior: Clip.antiAlias, + child: book.coverPath != null && book.coverPath!.isNotEmpty + ? Image.file(File(book.coverPath!), fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const Icon(Icons.menu_book_outlined, size: 22, color: Color(0xFFCCCCCC))) + : const Icon(Icons.menu_book_outlined, size: 22, color: Color(0xFFCCCCCC)), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(book.title, maxLines: 1, overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), + if (book.authors.isNotEmpty) ...[ + const SizedBox(height: 3), + Text(book.authors.take(2).join('、'), maxLines: 1, overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12, color: Color(0xFFAAAAAA))), + ], + const SizedBox(height: 6), + if (book.rating != null) + AnimatedStarRating(rating: book.rating!, starSize: 12, showNumber: true) + else + const SizedBox(height: 14), + ], + ), + ), + const SizedBox(width: 8), + const Icon(Icons.chevron_right, color: Color(0xFFD0D0D0), size: 20), + ], + ), + ), + ); + } + + void _showDeleteDialog(BuildContext context, book) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: const Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), + content: Text('确定要删除《${book.title}》吗?删除后可在回收站恢复。', + style: const TextStyle(fontSize: 14, color: Color(0xFF666666), height: 1.5)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('取消', style: TextStyle(color: Color(0xFF666666))), + ), + ElevatedButton( + onPressed: () async { + await context.read().removeBook(book.id); + Navigator.pop(ctx); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, foregroundColor: Colors.white, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + child: const Text('删除'), + ), + ], + actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + ); + } + + Widget _buildSkeleton() { + return _layoutStyle == 1 + ? MovieSkeletonGrid() // reuse same grid skeleton pattern + : const BookSkeletonGrid(); + } + Widget _buildEmptyState(BuildContext context, int statusIndex) { final statusText = ['已读', '在读', '想读'][statusIndex]; - return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( - width: 80, - height: 80, - decoration: BoxDecoration( - color: const Color(0xFFF5F5F5), - borderRadius: BorderRadius.circular(20), - ), - child: const Icon( - Icons.menu_book_outlined, - size: 40, - color: Color(0xFFCCCCCC), - ), + width: 80, height: 80, + decoration: BoxDecoration(color: const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(20)), + child: const Icon(Icons.menu_book_outlined, size: 40, color: Color(0xFFCCCCCC)), ), const SizedBox(height: 20), - Text( - '暂无$statusText的书籍', - style: const TextStyle( - fontSize: 16, - color: Color(0xFF999999), - ), - ), + Text('暂无$statusText的书籍', style: const TextStyle(fontSize: 16, color: Color(0xFF999999))), const SizedBox(height: 24), InkWell( onTap: () { - final statusMap = { - 0: 'read', - 1: 'reading', - 2: 'want_to_read', - }; - final currentStatus = statusMap[statusIndex]!; - Navigator.pushNamed( - context, - '/book-form', - arguments: {'initialStatus': currentStatus}, - ); + final statusMap = {0: 'read', 1: 'reading', 2: 'want_to_read'}; + Navigator.pushNamed(context, '/book-form', arguments: {'initialStatus': statusMap[statusIndex]!}); }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - decoration: BoxDecoration( - color: const Color(0xFF1A1A1A), - borderRadius: BorderRadius.circular(8), - ), - child: const Text( - '添加记录', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Colors.white, - ), - ), + decoration: BoxDecoration(color: const Color(0xFF1A1A1A), borderRadius: BorderRadius.circular(8)), + child: const Text('添加记录', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Colors.white)), ), ), ], ), ); } - - /// 获取示例书籍数据 - List _getSampleBooks(int statusIndex) { - final statusMap = ['read', 'reading', 'want_to_read']; - final currentStatus = statusMap[statusIndex]; - final now = DateTime.now(); - - // 示例数据(实际应从数据库获取) - final allBooks = [ - Book( - id: '1', - title: '活着', - authors: ['余华'], - rating: 9.2, - status: 'read', - genres: ['小说', '文学'], - summary: '非常感人的故事,让人思考生命的意义', - createdAt: now, - updatedAt: now, - ), - Book( - id: '2', - title: '百年孤独', - authors: ['加西亚·马尔克斯'], - rating: 9.3, - status: 'read', - genres: ['小说', '魔幻现实主义'], - publisher: '南海出版公司', - createdAt: now, - updatedAt: now, - ), - Book( - id: '3', - title: '人类简史', - authors: ['尤瓦尔·赫拉利'], - rating: 9.0, - status: 'reading', - genres: ['历史', '科普'], - createdAt: now, - updatedAt: now, - ), - Book( - id: '4', - title: '三体', - authors: ['刘慈欣'], - rating: 9.5, - status: 'want_to_read', - genres: ['科幻', '小说'], - createdAt: now, - updatedAt: now, - ), - Book( - id: '5', - title: '追风筝的人', - authors: ['卡勒德·胡赛尼'], - rating: 8.9, - status: 'read', - genres: ['小说', '文学'], - summary: '关于救赎与成长的故事', - createdAt: now, - updatedAt: now, - ), - ]; - - return allBooks.where((b) => b.status == currentStatus).toList(); - } } diff --git a/lib/pages/movies/movie_detail_page.dart b/lib/pages/movies/movie_detail_page.dart index 2d5c5ae..3483c86 100644 --- a/lib/pages/movies/movie_detail_page.dart +++ b/lib/pages/movies/movie_detail_page.dart @@ -143,14 +143,28 @@ class _MovieDetailPageState extends State { required String tooltip, Color backgroundColor = const Color(0xFF1A1A1A), }) { - return FloatingActionButton( - onPressed: onPressed, - tooltip: tooltip, - backgroundColor: backgroundColor, - foregroundColor: Colors.white, - mini: true, - elevation: 4, - child: Icon(icon, size: 20), + return Material( + color: Colors.transparent, + child: Ink( + width: 40, + height: 40, + decoration: BoxDecoration( + color: backgroundColor, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: backgroundColor.withValues(alpha: 0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: IconButton( + icon: Icon(icon, size: 18, color: Colors.white), + onPressed: onPressed, + tooltip: tooltip, + ), + ), ); } diff --git a/lib/pages/movies/movie_tab_page.dart b/lib/pages/movies/movie_tab_page.dart index dfb3299..e3532e7 100644 --- a/lib/pages/movies/movie_tab_page.dart +++ b/lib/pages/movies/movie_tab_page.dart @@ -1,23 +1,40 @@ +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../providers/app_provider.dart'; +import '../../utils/user_prefs.dart'; import '../../widgets/movie_status_bar.dart'; import '../../widgets/movie_list_item.dart'; +import '../../widgets/animated_star_rating.dart'; +import '../../widgets/shimmer_skeleton.dart'; -/// 观影标签页 - 极简主义设计 -class MovieTabPage extends StatelessWidget { +/// 观影标签页 +class MovieTabPage extends StatefulWidget { const MovieTabPage({super.key}); + @override + State createState() => _MovieTabPageState(); +} + +class _MovieTabPageState extends State { + int _layoutStyle = 0; // 0: 海报网格, 1: 列表 + bool _firstLoad = true; + + @override + void initState() { + super.initState(); + _layoutStyle = UserPrefs().movieLayoutStyle; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _firstLoad = false); + }); + } + @override Widget build(BuildContext context) { return Column( children: [ - // 状态选择栏 const MovieStatusBar(), - const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)), - - // 影片列表 Expanded( child: _buildMovieList(context), ), @@ -25,102 +42,213 @@ class MovieTabPage extends StatelessWidget { ); } - /// 构建影片列表 Widget _buildMovieList(BuildContext context) { return Consumer( builder: (context, provider, child) { - final statusMap = { - 0: 'watched', - 1: 'watching', - 2: 'want_to_watch', - }; + final statusMap = {0: 'watched', 1: 'watching', 2: 'want_to_watch'}; final currentStatus = statusMap[provider.movieStatusIndex]!; + final allMovies = provider.movies.where((m) => !m.isDeleted).toList(); + // 首次加载且数据为空时才显示骨架屏 + if (_firstLoad && allMovies.isEmpty) { + return _buildSkeleton(); + } + _firstLoad = false; + final movies = provider.getMoviesByStatus(currentStatus); - + if (movies.isEmpty) { return _buildEmptyState(context, provider.movieStatusIndex); } - - return RefreshIndicator( - onRefresh: () async => await provider.loadMovies(), - color: const Color(0xFF1A1A1A), - backgroundColor: Colors.white, - child: GridView.builder( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - childAspectRatio: 0.55, - crossAxisSpacing: 12, - mainAxisSpacing: 16, - ), - itemCount: movies.length, - itemBuilder: (context, index) { - return MovieListItem(movie: movies[index]); - }, - ), - ); + + if (_layoutStyle == 1) { + return _buildListView(movies, provider); + } + return _buildGridView(movies, provider); }, ); } - /// 构建空状态 + Widget _buildGridView(List movies, AppProvider provider) { + return RefreshIndicator( + onRefresh: () async => await provider.loadMovies(), + color: const Color(0xFF1A1A1A), + backgroundColor: Colors.white, + child: GridView.builder( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + childAspectRatio: 0.55, + crossAxisSpacing: 12, + mainAxisSpacing: 16, + ), + itemCount: movies.length, + itemBuilder: (context, index) => MovieListItem(movie: movies[index]), + ), + ); + } + + Widget _buildListView(List movies, AppProvider provider) { + return RefreshIndicator( + onRefresh: () async => await provider.loadMovies(), + color: const Color(0xFF1A1A1A), + backgroundColor: Colors.white, + child: ListView.builder( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 100), + itemCount: movies.length, + itemBuilder: (context, index) => _buildListCard(movies[index]), + ), + ); + } + + Widget _buildListCard(movie) { + return GestureDetector( + onTap: () => Navigator.pushNamed(context, '/movie-detail', arguments: movie), + onLongPress: () => _showDeleteDialog(context, movie), + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFAFAFA), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + // 海报缩略图 + Container( + width: 48, height: 64, + decoration: BoxDecoration( + color: const Color(0xFFF0F0F0), + borderRadius: BorderRadius.circular(6), + ), + clipBehavior: Clip.antiAlias, + child: movie.posterPath != null && movie.posterPath!.isNotEmpty + ? Image.file(File(movie.posterPath!), fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const Icon(Icons.movie_outlined, size: 22, color: Color(0xFFCCCCCC))) + : const Icon(Icons.movie_outlined, size: 22, color: Color(0xFFCCCCCC)), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(movie.title, maxLines: 1, overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), + if (movie.alternateTitles.isNotEmpty) ...[ + const SizedBox(height: 3), + Text(movie.alternateTitles.take(2).join('、'), maxLines: 1, overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12, color: Color(0xFFAAAAAA))), + ], + const SizedBox(height: 6), + if (movie.rating != null) + AnimatedStarRating(rating: movie.rating!, starSize: 12, showNumber: true) + else + const SizedBox(height: 14), + ], + ), + ), + const SizedBox(width: 8), + const Icon(Icons.chevron_right, color: Color(0xFFD0D0D0), size: 20), + ], + ), + ), + ); + } + + void _showDeleteDialog(BuildContext context, movie) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: const Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), + content: Text('确定要删除《${movie.title}》吗?删除后可在回收站恢复。', + style: const TextStyle(fontSize: 14, color: Color(0xFF666666), height: 1.5)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('取消', style: TextStyle(color: Color(0xFF666666))), + ), + ElevatedButton( + onPressed: () async { + await context.read().removeMovie(movie.id); + Navigator.pop(ctx); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, foregroundColor: Colors.white, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + child: const Text('删除'), + ), + ], + actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + ); + } + + Widget _buildSkeleton() { + return _layoutStyle == 1 ? _buildListSkeleton() : const MovieSkeletonGrid(); + } + + Widget _buildListSkeleton() { + return ListView.builder( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 100), + itemCount: 6, + itemBuilder: (_, __) => Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF8F8F8), + borderRadius: BorderRadius.circular(12), + ), + child: const Row( + children: [ + ShimmerSkeleton(width: 48, height: 64, borderRadius: 6), + SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShimmerSkeleton(width: 160, height: 16), + SizedBox(height: 6), + ShimmerSkeleton(width: 100, height: 12), + SizedBox(height: 6), + ShimmerSkeleton(width: 70, height: 12), + ], + ), + ), + SizedBox(width: 8), + ShimmerSkeleton(width: 20, height: 20, borderRadius: 10), + ], + ), + ), + ); + } + Widget _buildEmptyState(BuildContext context, int statusIndex) { final statusText = ['已看', '在看', '想看'][statusIndex]; - return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( - width: 80, - height: 80, - decoration: BoxDecoration( - color: const Color(0xFFF5F5F5), - borderRadius: BorderRadius.circular(20), - ), - child: const Icon( - Icons.movie_outlined, - size: 40, - color: Color(0xFFCCCCCC), - ), + width: 80, height: 80, + decoration: BoxDecoration(color: const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(20)), + child: const Icon(Icons.movie_outlined, size: 40, color: Color(0xFFCCCCCC)), ), const SizedBox(height: 20), - Text( - '暂无$statusText的影片', - style: const TextStyle( - fontSize: 16, - color: Color(0xFF999999), - ), - ), + Text('暂无$statusText的影片', style: const TextStyle(fontSize: 16, color: Color(0xFF999999))), const SizedBox(height: 24), InkWell( onTap: () { - final statusMap = { - 0: 'watched', - 1: 'watching', - 2: 'want_to_watch', - }; - final currentStatus = statusMap[statusIndex]!; - Navigator.pushNamed( - context, - '/movie-form', - arguments: {'initialStatus': currentStatus}, - ); + final statusMap = {0: 'watched', 1: 'watching', 2: 'want_to_watch'}; + Navigator.pushNamed(context, '/movie-form', arguments: {'initialStatus': statusMap[statusIndex]!}); }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - decoration: BoxDecoration( - color: const Color(0xFF1A1A1A), - borderRadius: BorderRadius.circular(8), - ), - child: const Text( - '添加记录', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Colors.white, - ), - ), + decoration: BoxDecoration(color: const Color(0xFF1A1A1A), borderRadius: BorderRadius.circular(8)), + child: const Text('添加记录', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Colors.white)), ), ), ], diff --git a/lib/pages/note/note_tab_page.dart b/lib/pages/note/note_tab_page.dart index 6f7ba32..a64ecce 100644 --- a/lib/pages/note/note_tab_page.dart +++ b/lib/pages/note/note_tab_page.dart @@ -5,6 +5,7 @@ import '../../providers/app_provider.dart'; import '../../models/data_models.dart'; import '../../utils/user_prefs.dart'; import '../../widgets/note_list_item.dart'; +import '../../widgets/shimmer_skeleton.dart'; /// 笔记标签页 class NoteTabPage extends StatefulWidget { @@ -21,7 +22,8 @@ class _NoteTabPageState extends State { bool _hasMore = true; final ScrollController _scrollController = ScrollController(); - int _layoutStyle = 0; // 0: 列表, 1: 瀑布流 + int _layoutStyle = 0; // 0: 列表, 1: 瀑布流, 2: 时间线 + bool _firstLoad = true; @override void initState() { @@ -30,6 +32,7 @@ class _NoteTabPageState extends State { _layoutStyle = UserPrefs().noteLayoutStyle; WidgetsBinding.instance.addPostFrameCallback((_) { _loadMoreNotes(); + if (mounted) setState(() => _firstLoad = false); }); } @@ -113,6 +116,10 @@ class _NoteTabPageState extends State { final allNotes = provider.notes; _syncDisplayedNotes(allNotes); + if (_firstLoad) { + return _buildSkeleton(); + } + if (allNotes.isEmpty && _displayedNotes.isEmpty) { return _buildEmptyState(context); } @@ -120,6 +127,9 @@ class _NoteTabPageState extends State { if (_layoutStyle == 1) { return _buildWaterfallView(); } + if (_layoutStyle == 2) { + return _buildTimelineView(); + } return _buildListView(); }, ), @@ -128,6 +138,59 @@ class _NoteTabPageState extends State { ); } + Widget _buildSkeleton() { + switch (_layoutStyle) { + case 1: + return _buildWaterfallSkeleton(); + case 2: + return const NoteSkeletonList(); + default: + return const NoteSkeletonList(); + } + } + + Widget _buildWaterfallSkeleton() { + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 100), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: List.generate(2, (_) => Expanded( + child: Column( + children: List.generate(4, (_) => Container( + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow(color: Colors.black.withValues(alpha: 0.04), blurRadius: 6, offset: const Offset(0, 2)), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ShimmerSkeleton(width: double.infinity, height: 140, borderRadius: 10), + Padding( + padding: const EdgeInsets.all(10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ShimmerSkeleton(width: double.infinity, height: 14), + const SizedBox(height: 6), + const ShimmerSkeleton(width: double.infinity, height: 12), + const SizedBox(height: 6), + const ShimmerSkeleton(width: 60, height: 10), + ], + ), + ), + ], + ), + )), + ), + )), + ), + ); + } + // ─── 列表视图 ──────────────────────────────────────────────────────── Widget _buildListView() { @@ -157,6 +220,141 @@ class _NoteTabPageState extends State { ); } + // ─── 时间线视图 ────────────────────────────────────────────────────── + + Widget _buildTimelineView() { + return RefreshIndicator( + onRefresh: _refresh, + color: const Color(0xFF1A1A1A), + backgroundColor: Colors.white, + child: ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.fromLTRB(12, 8, 12, 100), + itemCount: _displayedNotes.length + (_hasMore ? 1 : 0), + itemBuilder: (context, index) { + if (index >= _displayedNotes.length) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center( + child: SizedBox(width: 20, height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF1A1A1A))), + ), + ); + } + return _buildTimelineItem(_displayedNotes[index]); + }, + ), + ); + } + + Widget _buildTimelineItem(Note note) { + return GestureDetector( + onTap: () { + Navigator.pushNamed(context, '/note-detail', arguments: note).then((_) async { + await context.read().loadNotes(); + }); + }, + onLongPress: () => _showDeleteDialog(context, note), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 左侧时间线 + SizedBox( + width: 40, + child: Column( + children: [ + // 圆点 + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + ), + // 连线 + Expanded( + child: Container( + width: 1, + color: const Color(0xFFE5E5E5), + ), + ), + ], + ), + ), + + // 右侧内容 + Expanded( + child: Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFFAFAFA), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // 时间 + Text( + _formatFullDate(note.updatedAt), + style: const TextStyle(fontSize: 11, color: Color(0xFF999999)), + ), + + // 标题 + if (note.title.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + note.title, + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + + // 内容预览 + const SizedBox(height: 6), + Text( + _getPreviewText(note), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 13, color: Color(0xFF888888), height: 1.5), + ), + + // 标签 + if (note.tags.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 4, + children: note.tags.map((tag) => Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(4), + ), + child: Text(tag, style: const TextStyle(fontSize: 10, color: Color(0xFF999999))), + )).toList(), + ), + ], + ], + ), + ), + ), + ], + ), + ), + ); + } + + String _formatFullDate(DateTime date) { + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')} ' + '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; + } + // ─── 瀑布流视图 ────────────────────────────────────────────────────── Widget _buildWaterfallView() { diff --git a/lib/pages/profile_page.dart b/lib/pages/profile_page.dart index 95fc3e3..985e175 100644 --- a/lib/pages/profile_page.dart +++ b/lib/pages/profile_page.dart @@ -610,13 +610,11 @@ class SettingsPage extends StatefulWidget { class _SettingsPageState extends State { final UserPrefs _userPrefs = UserPrefs(); bool _hideBottomNavOnScroll = true; - int _noteLayoutStyle = 0; @override void initState() { super.initState(); _hideBottomNavOnScroll = _userPrefs.hideBottomNavOnScroll; - _noteLayoutStyle = _userPrefs.noteLayoutStyle; } @override @@ -664,8 +662,8 @@ class _SettingsPageState extends State { const Divider(height: 0.5, indent: 24, endIndent: 24), _buildNavigationItem( icon: Icons.view_list_outlined, - title: '主界面功能显示', - subtitle: '控制观影、阅读、笔记的显示', + title: '主界面设置', + subtitle: '启动标签、模块显示开关', onTap: () { Navigator.push( context, @@ -676,13 +674,22 @@ class _SettingsPageState extends State { }, ), const Divider(height: 0.5, indent: 24, endIndent: 24), - _buildSwitchItem( - icon: Icons.grid_view_outlined, - title: '笔记瀑布流布局', - subtitle: '使用双列瀑布流样式展示笔记', - value: _noteLayoutStyle == 1, - onChanged: _toggleNoteLayoutStyle, + + // 布局设置 + _buildSectionHeader('布局设置'), + _buildNavigationItem( + icon: Icons.dashboard_outlined, + title: '布局设置', + subtitle: '笔记、影视、阅读的展示样式', + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const LayoutSettingsPage()), + ); + }, ), + const Divider(height: 0.5, indent: 24, endIndent: 24), + // 使用说明 _buildSectionHeader('帮助'), _buildLinkItem( @@ -714,12 +721,6 @@ class _SettingsPageState extends State { setState(() => _hideBottomNavOnScroll = value); } - Future _toggleNoteLayoutStyle(bool value) async { - final v = value ? 1 : 0; - await _userPrefs.setNoteLayoutStyle(v); - setState(() => _noteLayoutStyle = v); - } - /// 构建开关项 Widget _buildSwitchItem({ required IconData icon, @@ -1223,23 +1224,17 @@ class _MainContentSettingsPageState extends State { return Scaffold( backgroundColor: Colors.white, appBar: AppBar( - title: const Text('主界面功能显示'), + title: const Text('主界面设置'), ), body: ListView( children: [ - // 说明文字 - Container( - padding: const EdgeInsets.all(24), - child: const Text( - '选择要在主界面显示的功能模块,至少保留一个。', - style: TextStyle( - fontSize: 14, - color: Color(0xFF666666), - ), - ), - ), - const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)), - // 观影开关 + // 启动设置 + _buildSectionHeader('启动设置'), + _buildDefaultTabSelector(), + const Divider(height: 0.5, indent: 24, endIndent: 24), + + // 模块开关 + _buildSectionHeader('模块开关'), _buildSwitchItem( icon: Icons.movie_outlined, title: '观影', @@ -1248,7 +1243,6 @@ class _MainContentSettingsPageState extends State { onChanged: _toggleMovieTab, ), const Divider(height: 0.5, indent: 24, endIndent: 24), - // 阅读开关 _buildSwitchItem( icon: Icons.menu_book_outlined, title: '阅读', @@ -1257,7 +1251,6 @@ class _MainContentSettingsPageState extends State { onChanged: _toggleBookTab, ), const Divider(height: 0.5, indent: 24, endIndent: 24), - // 笔记开关 _buildSwitchItem( icon: Icons.note_outlined, title: '笔记', @@ -1266,95 +1259,118 @@ class _MainContentSettingsPageState extends State { onChanged: _toggleNoteTab, ), const Divider(height: 0.5, indent: 24, endIndent: 24), - - // 默认启动标签 - _buildDefaultTabSelector(), - const Divider(height: 0.5, indent: 24, endIndent: 24), + Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10), + child: const Text( + '至少保留一个模块,关闭后对应标签页将不再显示。', + style: TextStyle(fontSize: 12, color: Color(0xFFBBBBBB)), + ), + ), ], ), ); } + /// 构建区块标题 + Widget _buildSectionHeader(String title) { + return Padding( + padding: const EdgeInsets.fromLTRB(24, 28, 24, 12), + child: Text( + title, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Color(0xFF1A1A1A), + ), + ), + ); + } + /// 构建默认启动标签选择器 Widget _buildDefaultTabSelector() { - final options = [ - {'label': '影视', 'icon': Icons.movie_outlined, 'value': 0}, - {'label': '阅读', 'icon': Icons.menu_book_outlined, 'value': 1}, - {'label': '笔记', 'icon': Icons.note_outlined, 'value': 2}, - ]; + final labels = ['影视', '阅读', '笔记']; + final icons = [Icons.movie_outlined, Icons.menu_book_outlined, Icons.note_outlined]; return ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), leading: Container( - width: 48, - height: 48, + width: 44, + height: 44, decoration: BoxDecoration( color: const Color(0xFFF5F5F5), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.home_outlined, - color: Color(0xFF666666), - size: 24, + borderRadius: BorderRadius.circular(10), ), + child: const Icon(Icons.home_outlined, color: Color(0xFF666666), size: 22), ), title: const Text( '默认启动标签', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Color(0xFF1A1A1A), - ), + style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: Color(0xFF1A1A1A)), ), subtitle: const Text( '打开应用时默认显示的页面', - style: TextStyle( - fontSize: 13, - color: Color(0xFF999999), - ), + style: TextStyle(fontSize: 12, color: Color(0xFF999999)), ), - trailing: SizedBox( - width: 80, - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: _defaultTabIndex, - isDense: true, - icon: const Icon(Icons.chevron_right, color: Color(0xFFCCCCCC)), - selectedItemBuilder: (context) { - return options.map((opt) { - return Container( - alignment: Alignment.centerRight, - padding: const EdgeInsets.only(right: 8), - child: Text( - opt['label'] as String, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w500, - color: Color(0xFF1A1A1A), - ), - ), - ); - }).toList(); - }, - items: options.map((opt) { - return DropdownMenuItem( - value: opt['value'] as int, - child: Row( - children: [ - Icon(opt['icon'] as IconData, size: 20, color: const Color(0xFF666666)), - const SizedBox(width: 8), - Text(opt['label'] as String), - ], - ), - ); - }).toList(), - onChanged: (int? value) async { - if (value != null) { - await _userPrefs.setDefaultMainTabIndex(value); - setState(() => _defaultTabIndex = value); - } - }, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + labels[_defaultTabIndex], + style: const TextStyle(fontSize: 14, color: Color(0xFF999999)), ), + const SizedBox(width: 4), + const Icon(Icons.chevron_right, color: Color(0xFFCCCCCC), size: 20), + ], + ), + onTap: () => _showDefaultTabPicker(labels, icons), + ); + } + + void _showDefaultTabPicker(List labels, List icons) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (ctx) => Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + padding: const EdgeInsets.symmetric(vertical: 12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 36, height: 4, + decoration: BoxDecoration(color: const Color(0xFFDDDDDD), borderRadius: BorderRadius.circular(2)), + ), + const SizedBox(height: 20), + const Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Text('默认启动标签', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), + ), + ), + const SizedBox(height: 16), + for (int i = 0; i < labels.length; i++) + ListTile( + leading: Container( + width: 44, height: 44, + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icons[i], color: const Color(0xFF666666)), + ), + title: Text(labels[i], style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: Color(0xFF1A1A1A))), + trailing: _defaultTabIndex == i ? const Icon(Icons.check, color: Color(0xFF1A1A1A), size: 20) : null, + onTap: () async { + await _userPrefs.setDefaultMainTabIndex(i); + setState(() => _defaultTabIndex = i); + Navigator.pop(ctx); + }, + ), + const SizedBox(height: 8), + ], ), ), ); @@ -1410,6 +1426,176 @@ class _MainContentSettingsPageState extends State { } } +/// 布局设置页面 +class LayoutSettingsPage extends StatefulWidget { + const LayoutSettingsPage({super.key}); + + @override + State createState() => _LayoutSettingsPageState(); +} + +class _LayoutSettingsPageState extends State { + final UserPrefs _userPrefs = UserPrefs(); + int _noteLayout = 0; + int _movieLayout = 0; + int _bookLayout = 0; + + @override + void initState() { + super.initState(); + _noteLayout = _userPrefs.noteLayoutStyle; + _movieLayout = _userPrefs.movieLayoutStyle; + _bookLayout = _userPrefs.bookLayoutStyle; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + title: const Text('布局设置'), + ), + body: ListView( + children: [ + // 笔记布局 + _buildSectionHeader('笔记布局'), + _buildLayoutOption( + icon: Icons.view_list_outlined, + title: '列表布局', + subtitle: '单列列表,简洁清晰', + value: 0, + groupValue: _noteLayout, + onTap: () => _setLayout('note', 0), + ), + const Divider(height: 0.5, indent: 24, endIndent: 24), + _buildLayoutOption( + icon: Icons.grid_view_outlined, + title: '瀑布流布局', + subtitle: '双列卡片,图文并茂', + value: 1, + groupValue: _noteLayout, + onTap: () => _setLayout('note', 1), + ), + const Divider(height: 0.5, indent: 24, endIndent: 24), + _buildLayoutOption( + icon: Icons.timeline_outlined, + title: '时间线布局', + subtitle: '按时间排列,纵览全局', + value: 2, + groupValue: _noteLayout, + onTap: () => _setLayout('note', 2), + ), + const Divider(height: 0.5, indent: 24, endIndent: 24), + + // 影视布局 + _buildSectionHeader('影视布局'), + _buildLayoutOption( + icon: Icons.grid_view_outlined, + title: '海报网格', + subtitle: '三列海报,赏心悦目', + value: 0, + groupValue: _movieLayout, + onTap: () => _setLayout('movie', 0), + ), + const Divider(height: 0.5, indent: 24, endIndent: 24), + _buildLayoutOption( + icon: Icons.view_list_outlined, + title: '列表布局', + subtitle: '单列卡片,信息一览', + value: 1, + groupValue: _movieLayout, + onTap: () => _setLayout('movie', 1), + ), + const Divider(height: 0.5, indent: 24, endIndent: 24), + + // 阅读布局 + _buildSectionHeader('阅读布局'), + _buildLayoutOption( + icon: Icons.grid_view_outlined, + title: '封面网格', + subtitle: '三列封面,清新雅致', + value: 0, + groupValue: _bookLayout, + onTap: () => _setLayout('book', 0), + ), + const Divider(height: 0.5, indent: 24, endIndent: 24), + _buildLayoutOption( + icon: Icons.view_list_outlined, + title: '列表布局', + subtitle: '单列卡片,信息一览', + value: 1, + groupValue: _bookLayout, + onTap: () => _setLayout('book', 1), + ), + const Divider(height: 0.5, indent: 24, endIndent: 24), + ], + ), + ); + } + + void _setLayout(String type, int value) async { + switch (type) { + case 'note': + await _userPrefs.setNoteLayoutStyle(value); + setState(() => _noteLayout = value); + break; + case 'movie': + await _userPrefs.setMovieLayoutStyle(value); + setState(() => _movieLayout = value); + break; + case 'book': + await _userPrefs.setBookLayoutStyle(value); + setState(() => _bookLayout = value); + break; + } + } + + Widget _buildSectionHeader(String title) { + return Padding( + padding: const EdgeInsets.fromLTRB(24, 28, 24, 12), + child: Text( + title, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)), + ), + ); + } + + Widget _buildLayoutOption({ + required IconData icon, + required String title, + required String subtitle, + required int value, + required int groupValue, + required VoidCallback onTap, + }) { + final selected = value == groupValue; + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), + leading: Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, color: const Color(0xFF666666), size: 22), + ), + title: Text( + title, + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: Color(0xFF1A1A1A)), + ), + subtitle: Text( + subtitle, + style: const TextStyle(fontSize: 12, color: Color(0xFF999999)), + ), + trailing: selected + ? const Icon(Icons.check_circle, color: Color(0xFF1A1A1A), size: 20) + : const Icon(Icons.circle_outlined, color: Color(0xFFDDDDDD), size: 20), + onTap: onTap, + ); + } +} + /// WebView 页面 class WebViewPage extends StatefulWidget { final String url; diff --git a/lib/pages/statistics_page.dart b/lib/pages/statistics_page.dart index e623c21..cd65ebf 100644 --- a/lib/pages/statistics_page.dart +++ b/lib/pages/statistics_page.dart @@ -13,8 +13,6 @@ class StatisticsPage extends StatefulWidget { } class _StatisticsPageState extends State { - int _selectedTab = 0; - bool get _showMovies => UserPrefs().showMovieTab; bool get _showBooks => UserPrefs().showBookTab; bool get _showNotes => UserPrefs().showNoteTab; @@ -23,23 +21,38 @@ class _StatisticsPageState extends State { Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.white, - appBar: AppBar( - title: const Text('数据统计'), - ), + appBar: AppBar(title: const Text('数据统计')), body: Consumer( builder: (context, provider, child) { - final movies = provider.movies; + final movies = provider.movies.where((m) => !m.isDeleted).toList(); final books = provider.books; final notes = provider.notes; - return Column( + return ListView( + padding: const EdgeInsets.all(20), children: [ - _buildTabBar(), - Expanded( - child: _selectedTab == 0 - ? _buildOverviewTab(movies, books, notes) - : _buildTrendTab(movies, books, notes), - ), + _buildTotalCards(movies, books, notes), + const SizedBox(height: 28), + if (_showMovies) ...[ + _buildStatusSection('影视状态分布', movies, (m) => m.status, {'已看': 'watched', '在看': 'watching', '想看': 'want_to_watch'}), + const SizedBox(height: 28), + _buildGenreDistribution('影视类型分布', movies), + const SizedBox(height: 28), + ], + if (_showBooks) ...[ + _buildStatusSection('阅读状态分布', books, (b) => b.status, {'已读': 'read', '在读': 'reading', '想读': 'want_to_read'}), + const SizedBox(height: 28), + _buildGenreDistribution('书籍类型分布', books), + const SizedBox(height: 28), + ], + if (_showNotes) ...[ + _buildNoteTagDistribution('笔记标签分布', notes), + const SizedBox(height: 28), + ], + _buildRatingDistribution('评分分布', movies, books), + const SizedBox(height: 28), + _buildMonthlyTrend(movies, books, notes), + const SizedBox(height: 80), ], ); }, @@ -47,384 +60,368 @@ class _StatisticsPageState extends State { ); } - Widget _buildTabBar() { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - child: Row( - children: [ - Expanded(child: _buildTabItem('概览', 0)), - const SizedBox(width: 12), - Expanded(child: _buildTabItem('趋势', 1)), - ], - ), - ); - } + // ─── 总览卡片 ──────────────────────────────────────────────────────── - Widget _buildTabItem(String label, int index) { - final isSelected = _selectedTab == index; - return GestureDetector( - onTap: () => setState(() => _selectedTab = index), - child: Container( - padding: const EdgeInsets.symmetric(vertical: 10), - decoration: BoxDecoration( - color: isSelected ? const Color(0xFF1A1A1A) : const Color(0xFFF5F5F5), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - label, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, - color: isSelected ? Colors.white : const Color(0xFF666666), + Widget _buildTotalCards(List movies, List books, List notes) { + final items = <_CardData>[]; + if (_showMovies) items.add(_CardData('影视', movies.length, Icons.movie_outlined, const Color(0xFF4A90D9))); + if (_showBooks) items.add(_CardData('书籍', books.length, Icons.menu_book_outlined, const Color(0xFF7E57C2))); + if (_showNotes) items.add(_CardData('笔记', notes.length, Icons.note_outlined, const Color(0xFF66BB6A))); + + return Row( + children: items.map((d) => Expanded( + child: Container( + margin: EdgeInsets.only(right: d == items.last ? 0 : 10), + padding: const EdgeInsets.symmetric(vertical: 20), + decoration: BoxDecoration( + color: d.color.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(14), + ), + child: Column( + children: [ + Icon(d.icon, size: 26, color: d.color), + const SizedBox(height: 10), + Text('${d.count}', style: TextStyle(fontSize: 26, fontWeight: FontWeight.w700, color: d.color)), + const SizedBox(height: 2), + Text(d.label, style: const TextStyle(fontSize: 12, color: Color(0xFF888888))), + ], ), ), - ), + )).toList(), ); } - Widget _buildOverviewTab(List movies, List books, List notes) { - final totalMovies = movies.where((m) => !m.isDeleted).length; - final totalBooks = books.length; - final totalNotes = notes.length; + // ─── 状态分布 ──────────────────────────────────────────────────────── - final watchedMovies = movies.where((m) => m.status == 'watched' && !m.isDeleted).length; - final watchingMovies = movies.where((m) => m.status == 'watching' && !m.isDeleted).length; - final wantToWatchMovies = movies.where((m) => m.status == 'want_to_watch' && !m.isDeleted).length; + Widget _buildStatusSection(String title, List items, String Function(dynamic) getStatus, Map labels) { + final active = items.where((i) => !(i is Movie) || !i.isDeleted).toList(); + final total = active.length; - final readBooks = books.where((b) => b.status == 'read').length; - final readingBooks = books.where((b) => b.status == 'reading').length; - final wantToReadBooks = books.where((b) => b.status == 'want_to_read').length; - - final movieRatings = movies.where((m) => m.rating != null && !m.isDeleted).map((m) => m.rating!); - final avgMovieRating = movieRatings.isNotEmpty - ? movieRatings.reduce((a, b) => a + b) / movieRatings.length - : null; - - final bookRatings = books.where((b) => b.rating != null).map((b) => b.rating!); - final avgBookRating = bookRatings.isNotEmpty - ? bookRatings.reduce((a, b) => a + b) / bookRatings.length - : null; - - final children = []; - - // 数据总览 - children.add(_buildSectionTitle('数据总览')); - children.add(const SizedBox(height: 16)); - children.add(_buildTotalOverview(totalMovies, totalBooks, totalNotes)); - children.add(const SizedBox(height: 32)); - - // 影视状态分布 - if (_showMovies) { - children.add(_buildSectionTitle('影视')); - children.add(const SizedBox(height: 16)); - children.add(_buildStatusDistribution([ - _StatusData('已看', watchedMovies, const Color(0xFF1A1A1A)), - _StatusData('在看', watchingMovies, const Color(0xFF666666)), - _StatusData('想看', wantToWatchMovies, const Color(0xFF999999)), - ], avgRating: avgMovieRating)); - children.add(const SizedBox(height: 32)); - } - - // 书籍状态分布 - if (_showBooks) { - children.add(_buildSectionTitle('书籍')); - children.add(const SizedBox(height: 16)); - children.add(_buildStatusDistribution([ - _StatusData('已读', readBooks, const Color(0xFF1A1A1A)), - _StatusData('在读', readingBooks, const Color(0xFF666666)), - _StatusData('想读', wantToReadBooks, const Color(0xFF999999)), - ], avgRating: avgBookRating)); - children.add(const SizedBox(height: 32)); - } - - // 最近活动 - children.add(_buildSectionTitle('最近7天活动')); - children.add(const SizedBox(height: 16)); - children.add(_buildRecentActivity(movies, books, notes)); - - return ListView( - padding: const EdgeInsets.all(24), - children: children, - ); - } - - Widget _buildTotalOverview(int totalMovies, int totalBooks, int totalNotes) { - final items = []; - if (_showMovies) items.add(Expanded(child: _buildStatItem('影视', totalMovies, Icons.movie_outlined))); - if (_showBooks) items.add(Expanded(child: _buildStatItem('书籍', totalBooks, Icons.menu_book_outlined))); - if (_showNotes) items.add(Expanded(child: _buildStatItem('笔记', totalNotes, Icons.note_outlined))); - - return Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: const Color(0xFFF8F8F8), - borderRadius: BorderRadius.circular(12), - ), - child: Row(children: items), - ); - } - - Widget _buildTrendTab(List movies, List books, List notes) { - final now = DateTime.now(); - - final dates = List.generate(30, (index) => now.subtract(Duration(days: 29 - index))); - - final movieTrend = dates.map((date) { - return movies.where((m) { - final created = m.createdAt; - return created.year == date.year && created.month == date.month && created.day == date.day && !m.isDeleted; - }).length; - }).toList(); - - final bookTrend = dates.map((date) { - return books.where((b) { - final created = b.createdAt; - return created.year == date.year && created.month == date.month && created.day == date.day; - }).length; - }).toList(); - - final noteTrend = dates.map((date) { - return notes.where((n) { - final created = n.createdAt; - return created.year == date.year && created.month == date.month && created.day == date.day; - }).length; - }).toList(); - - final totalMovies = movieTrend.fold(0, (a, b) => a + b); - final totalBooks = bookTrend.fold(0, (a, b) => a + b); - final totalNotes = noteTrend.fold(0, (a, b) => a + b); - - final children = []; - - children.add(_buildSectionTitle('近30天新增')); - children.add(const SizedBox(height: 16)); - children.add(_buildTrendSummaryRow(totalMovies, totalBooks, totalNotes)); - children.add(const SizedBox(height: 32)); - children.add(_buildSectionTitle('数据趋势')); - children.add(const SizedBox(height: 16)); - children.add(_buildTrendChart(movieTrend, bookTrend, noteTrend)); - - return ListView( - padding: const EdgeInsets.all(24), - children: children, - ); - } - - Widget _buildTrendSummaryRow(int totalMovies, int totalBooks, int totalNotes) { - final items = []; - if (_showMovies) items.add(Expanded(child: _buildTrendSummary('影视', totalMovies))); - if (_showBooks) items.add(Expanded(child: _buildTrendSummary('书籍', totalBooks))); - if (_showNotes) items.add(Expanded(child: _buildTrendSummary('笔记', totalNotes))); - - return Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: const Color(0xFFF8F8F8), - borderRadius: BorderRadius.circular(12), - ), - child: Row(children: items), - ); - } - - Widget _buildTrendChart(List movieTrend, List bookTrend, List noteTrend) { - final rows = []; - if (_showMovies) { - rows.add(_buildTrendRow('影视', movieTrend, const Color(0xFF1A1A1A))); - } - if (_showBooks) { - if (rows.isNotEmpty) rows.add(const SizedBox(height: 16)); - rows.add(_buildTrendRow('书籍', bookTrend, const Color(0xFF666666))); - } - if (_showNotes) { - if (rows.isNotEmpty) rows.add(const SizedBox(height: 16)); - rows.add(_buildTrendRow('笔记', noteTrend, const Color(0xFF999999))); - } - - return Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: const Color(0xFFFAFAFA), - borderRadius: BorderRadius.circular(12), - ), - child: Column(children: rows), - ); - } - - Widget _buildTrendSummary(String label, int count) { - return Column( - children: [ - Text(count.toString(), style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w700, color: Color(0xFF1A1A1A))), - const SizedBox(height: 4), - Text(label, style: const TextStyle(fontSize: 13, color: Color(0xFF666666))), - ], - ); - } - - Widget _buildTrendRow(String label, List data, Color color) { - final maxValue = data.isEmpty ? 1 : data.reduce((a, b) => a > b ? a : b); - final safeMax = maxValue == 0 ? 1 : maxValue; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: color)), - const SizedBox(height: 8), - SizedBox( - height: 40, - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: data.map((value) { - final height = value / safeMax * 40; - return Expanded( - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 1), - height: height < 2 ? 2 : height, - decoration: BoxDecoration( - color: color.withOpacity(value == 0 ? 0.1 : 0.6), - borderRadius: BorderRadius.circular(2), + return _buildCard( + title: title, + child: Column( + children: labels.entries.map((e) { + final count = active.where((i) => getStatus(i) == e.value).length; + final pct = total > 0 ? count / total : 0.0; + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(e.key, style: const TextStyle(fontSize: 13, color: Color(0xFF666666))), + const Spacer(), + Text('$count', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), + const SizedBox(width: 4), + Text('${(pct * 100).toStringAsFixed(0)}%', style: const TextStyle(fontSize: 12, color: Color(0xFFBBBBBB))), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(3), + child: LinearProgressIndicator( + value: pct, + backgroundColor: const Color(0xFFF0F0F0), + color: const Color(0xFF1A1A1A), + minHeight: 6, ), ), - ); - }).toList(), - ), - ), - ], - ); - } - - Widget _buildStatItem(String title, int count, IconData icon) { - return Column( - children: [ - Icon(icon, size: 24, color: const Color(0xFF666666)), - const SizedBox(height: 8), - Text(count.toString(), style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w700, color: Color(0xFF1A1A1A))), - const SizedBox(height: 4), - Text(title, style: const TextStyle(fontSize: 13, color: Color(0xFF666666))), - ], - ); - } - - Widget _buildStatusDistribution(List<_StatusData> data, {double? avgRating}) { - final total = data.fold(0, (sum, item) => sum + item.count); - - return Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: const Color(0xFFFAFAFA), - borderRadius: BorderRadius.circular(12), - ), - child: Column( - children: [ - ...data.map((item) { - final percentage = total > 0 ? (item.count / total * 100).toStringAsFixed(1) : '0'; - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Row( - children: [ - Container( - width: 10, height: 10, - decoration: BoxDecoration(color: item.color, borderRadius: BorderRadius.circular(2)), - ), - const SizedBox(width: 12), - Text(item.label, style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A))), - const Spacer(), - Text('${item.count}', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), - const SizedBox(width: 8), - Text('($percentage%)', style: const TextStyle(fontSize: 13, color: Color(0xFF999999))), - ], - ), - ); - }), - if (avgRating != null) ...[ - const Divider(height: 1, color: Color(0xFFE8E8E8)), - const SizedBox(height: 12), - Row( - children: [ - const Icon(Icons.star, size: 16, color: Color(0xFF999999)), - const SizedBox(width: 10), - const Text('平均评分', style: TextStyle(fontSize: 14, color: Color(0xFF1A1A1A))), - const Spacer(), - Text(avgRating.toStringAsFixed(1), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), ], ), - ], + ); + }).toList(), + ), + ); + } + + // ─── 类型/标签分布 ─────────────────────────────────────────────────── + + Widget _buildGenreDistribution(String title, List items) { + final genreCounts = {}; + for (final item in items) { + for (final genre in (item.genres as List)) { + genreCounts[genre] = (genreCounts[genre] ?? 0) + 1; + } + } + final sorted = genreCounts.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + final top = sorted.take(8).toList(); + if (top.isEmpty) return const SizedBox.shrink(); + final maxCount = top.first.value; + + return _buildCard( + title: title, + child: Column( + children: top.map((e) { + final pct = maxCount > 0 ? e.value / maxCount : 0.0; + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Row( + children: [ + SizedBox( + width: 60, + child: Text(e.key, style: const TextStyle(fontSize: 12, color: Color(0xFF666666)), overflow: TextOverflow.ellipsis), + ), + const SizedBox(width: 10), + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator(value: pct, backgroundColor: const Color(0xFFF0F0F0), color: const Color(0xFF1A1A1A), minHeight: 4), + ), + ), + const SizedBox(width: 8), + Text('${e.value}', style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), + ], + ), + ); + }).toList(), + ), + ); + } + + Widget _buildNoteTagDistribution(String title, List notes) { + final tagCounts = {}; + for (final note in notes) { + for (final tag in note.tags) { + tagCounts[tag] = (tagCounts[tag] ?? 0) + 1; + } + } + final sorted = tagCounts.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + final top = sorted.take(8).toList(); + if (top.isEmpty) return const SizedBox.shrink(); + final maxCount = top.first.value; + + return _buildCard( + title: title, + child: Column( + children: top.map((e) { + final pct = maxCount > 0 ? e.value / maxCount : 0.0; + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Row( + children: [ + SizedBox( + width: 60, + child: Text(e.key, style: const TextStyle(fontSize: 12, color: Color(0xFF666666)), overflow: TextOverflow.ellipsis), + ), + const SizedBox(width: 10), + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator(value: pct, backgroundColor: const Color(0xFFF0F0F0), color: const Color(0xFF1A1A1A), minHeight: 4), + ), + ), + const SizedBox(width: 8), + Text('${e.value}', style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), + ], + ), + ); + }).toList(), + ), + ); + } + + // ─── 评分分布 ──────────────────────────────────────────────────────── + + Widget _buildRatingDistribution(String title, List movies, List books) { + final allRatings = []; + for (final m in movies) { + if (m.rating != null) allRatings.add(m.rating!); + } + for (final b in books) { + if (b.rating != null) allRatings.add(b.rating!); + } + if (allRatings.isEmpty) return const SizedBox.shrink(); + + final avg = allRatings.reduce((a, b) => a + b) / allRatings.length; + + // 按区间统计 (0-2, 2-4, 4-6, 6-8, 8-10) + final ranges = ['0-2', '2-4', '4-6', '6-8', '8-10']; + final counts = [0, 0, 0, 0, 0]; + for (final r in allRatings) { + if (r < 2) counts[0]++; + else if (r < 4) counts[1]++; + else if (r < 6) counts[2]++; + else if (r < 8) counts[3]++; + else counts[4]++; + } + final maxCount = counts.isEmpty ? 1 : counts.reduce((a, b) => a > b ? a : b); + + return _buildCard( + title: title, + child: Column( + children: [ + Row( + children: [ + const Text('平均评分', style: TextStyle(fontSize: 13, color: Color(0xFF666666))), + const Spacer(), + Text(avg.toStringAsFixed(1), style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: Color(0xFF1A1A1A))), + const Text(' / 10', style: TextStyle(fontSize: 13, color: Color(0xFFBBBBBB))), + ], + ), + const SizedBox(height: 16), + ...List.generate(5, (i) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + SizedBox( + width: 28, + child: Text(ranges[i], style: const TextStyle(fontSize: 11, color: Color(0xFF999999))), + ), + const SizedBox(width: 8), + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: maxCount > 0 ? counts[i] / maxCount : 0.0, + backgroundColor: const Color(0xFFF0F0F0), + color: const Color(0xFF1A1A1A), + minHeight: 6, + ), + ), + ), + const SizedBox(width: 8), + Text('${counts[i]}', style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), + ], + ), + )), ], ), ); } - Widget _buildRecentActivity(List movies, List books, List notes) { + // ─── 月度趋势 ──────────────────────────────────────────────────────── + + Widget _buildMonthlyTrend(List movies, List books, List notes) { final now = DateTime.now(); - final sevenDaysAgo = now.subtract(const Duration(days: 7)); + final months = List.generate(6, (i) { + final d = DateTime(now.year, now.month - (5 - i), 1); + return '${d.month}月'; + }); - final recentMovies = movies.where((m) => m.createdAt.isAfter(sevenDaysAgo) && !m.isDeleted).length; - final recentBooks = books.where((b) => b.createdAt.isAfter(sevenDaysAgo)).length; - final recentNotes = notes.where((n) => n.createdAt.isAfter(sevenDaysAgo)).length; + final counts = >{}; + if (_showMovies) counts['影视'] = List.filled(6, 0); + if (_showBooks) counts['书籍'] = List.filled(6, 0); + if (_showNotes) counts['笔记'] = List.filled(6, 0); - final items = []; - if (_showMovies) { - items.add(_buildActivityRow('新增影视', recentMovies, Icons.movie_outlined)); + for (final m in movies) { + final idx = _monthIndex(m.createdAt, now); + if (idx >= 0 && idx < 6) counts['影视']?[idx] = (counts['影视']?[idx] ?? 0) + 1; } - if (_showBooks) { - if (items.isNotEmpty) items.add(const Divider(height: 20, color: Color(0xFFE8E8E8))); - items.add(_buildActivityRow('新增书籍', recentBooks, Icons.menu_book_outlined)); + for (final b in books) { + final idx = _monthIndex(b.createdAt, now); + if (idx >= 0 && idx < 6) counts['书籍']?[idx] = (counts['书籍']?[idx] ?? 0) + 1; } - if (_showNotes) { - if (items.isNotEmpty) items.add(const Divider(height: 20, color: Color(0xFFE8E8E8))); - items.add(_buildActivityRow('新增笔记', recentNotes, Icons.note_outlined)); + for (final n in notes) { + final idx = _monthIndex(n.createdAt, now); + if (idx >= 0 && idx < 6) counts['笔记']?[idx] = (counts['笔记']?[idx] ?? 0) + 1; } + final allValues = counts.values.expand((l) => l); + final maxVal = allValues.isEmpty ? 1 : allValues.reduce((a, b) => a > b ? a : b); + final safeMax = maxVal == 0 ? 1 : maxVal; + + final colors = { '影视': const Color(0xFF4A90D9), '书籍': const Color(0xFF7E57C2), '笔记': const Color(0xFF66BB6A) }; + + return _buildCard( + title: '近6月趋势', + child: Column( + children: [ + SizedBox( + height: 120, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: List.generate(6, (i) => Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ...counts.entries.map((e) { + final h = (e.value[i] / safeMax * 80).clamp(0, 80).toDouble(); + return Container( + height: h < 2 && e.value[i] > 0 ? 2 : h, + margin: const EdgeInsets.only(top: 1), + decoration: BoxDecoration( + color: colors[e.key]!.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(2), + ), + ); + }).toList(), + ], + ), + ), + )), + ), + ), + const SizedBox(height: 8), + const Divider(height: 1, color: Color(0xFFF0F0F0)), + const SizedBox(height: 8), + // 月份标签 + Row( + children: months.map((m) => Expanded( + child: Text(m, textAlign: TextAlign.center, style: const TextStyle(fontSize: 11, color: Color(0xFFBBBBBB))), + )).toList(), + ), + const SizedBox(height: 12), + // 图例 + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: counts.keys.map((k) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: 8, height: 8, decoration: BoxDecoration(color: colors[k], borderRadius: BorderRadius.circular(2))), + const SizedBox(width: 4), + Text(k, style: const TextStyle(fontSize: 12, color: Color(0xFF888888))), + ], + ), + )).toList(), + ), + ], + ), + ); + } + + int _monthIndex(DateTime date, DateTime now) { + final diff = (now.year - date.year) * 12 + (now.month - date.month); + return 5 - diff; // index 0..5 where 0=5 months ago, 5=current month + } + + // ─── 通用卡片 ──────────────────────────────────────────────────────── + + Widget _buildCard({required String title, required Widget child}) { return Container( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.all(18), decoration: BoxDecoration( color: const Color(0xFFFAFAFA), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), ), - child: Column(children: items), - ); - } - - Widget _buildActivityRow(String label, int count, IconData icon) { - return Row( - children: [ - Container( - width: 36, height: 36, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container(width: 3, height: 14, decoration: BoxDecoration(color: const Color(0xFF1A1A1A), borderRadius: BorderRadius.circular(2))), + const SizedBox(width: 8), + Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), + ], ), - child: Icon(icon, size: 18, color: const Color(0xFF666666)), - ), - const SizedBox(width: 12), - Text(label, style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A))), - const Spacer(), - Text('$count', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), - const SizedBox(width: 4), - const Text('个', style: TextStyle(fontSize: 13, color: Color(0xFF999999))), - ], - ); - } - - Widget _buildSectionTitle(String title) { - return Row( - children: [ - Container( - width: 4, height: 16, - decoration: BoxDecoration(color: const Color(0xFF1A1A1A), borderRadius: BorderRadius.circular(2)), - ), - const SizedBox(width: 8), - Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), - ], + const SizedBox(height: 16), + child, + ], + ), ); } } -class _StatusData { +class _CardData { final String label; final int count; + final IconData icon; final Color color; - - _StatusData(this.label, this.count, this.color); + _CardData(this.label, this.count, this.icon, this.color); } diff --git a/lib/pages/stroll_page.dart b/lib/pages/stroll_page.dart index 0179f76..e0a6616 100644 --- a/lib/pages/stroll_page.dart +++ b/lib/pages/stroll_page.dart @@ -4,8 +4,9 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/app_provider.dart'; import '../models/data_models.dart'; +import '../widgets/animated_star_rating.dart'; -/// 漫步页面 - 随机发现一条内容 +/// 漫步页面 - 随机发现内容 class StrollPage extends StatefulWidget { const StrollPage({super.key}); @@ -13,25 +14,34 @@ class StrollPage extends StatefulWidget { State createState() => _StrollPageState(); } -class _StrollPageState extends State { +class _StrollPageState extends State with SingleTickerProviderStateMixin { final _random = Random(); _StrollItem? _currentItem; + late AnimationController _animController; + late Animation _fadeAnim; + bool _isLoading = false; @override void initState() { super.initState(); - _refresh(); + _animController = AnimationController(vsync: this, duration: const Duration(milliseconds: 500)); + _fadeAnim = CurvedAnimation(parent: _animController, curve: Curves.easeIn); + _animController.value = 1.0; + _loadRandom(); } - void _refresh() { - final provider = context.read(); + @override + void dispose() { + _animController.dispose(); + super.dispose(); + } - // 按类别分组 + void _loadRandom() { + final provider = context.read(); final movies = provider.movies.where((m) => !m.isDeleted).toList(); final books = provider.books.where((b) => !b.isDeleted).toList(); final notes = provider.notes.where((n) => !n.isDeleted).toList(); - // 收集非空类别 final categories = >{}; if (movies.isNotEmpty) categories['movie'] = movies; if (books.isNotEmpty) categories['book'] = books; @@ -42,7 +52,6 @@ class _StrollPageState extends State { return; } - // 先等概率选类别,再从该类中随机选一条 final categoryKeys = categories.keys.toList(); final pickedCategory = categoryKeys[_random.nextInt(categoryKeys.length)]; @@ -53,12 +62,14 @@ class _StrollPageState extends State { item = _StrollItem( type: 'movie', title: m.title, - subtitle: m.alternateTitles.isNotEmpty ? m.alternateTitles.first : '', - detail: _buildMovieDetail(m), + subtitle: m.alternateTitles.take(2).join(' / '), + detail: _movieDetail(m), imagePath: m.posterPath, icon: Icons.movie_outlined, label: '影视', + rating: m.rating, createdAt: m.createdAt, + color: const Color(0xFF4A90D9), ); break; case 'book': @@ -66,25 +77,28 @@ class _StrollPageState extends State { item = _StrollItem( type: 'book', title: b.title, - subtitle: b.authors.isNotEmpty ? b.authors.first : '', - detail: _buildBookDetail(b), + subtitle: b.authors.take(2).join(' / '), + detail: _bookDetail(b), imagePath: b.coverPath, icon: Icons.menu_book_outlined, label: '书籍', + rating: b.rating, createdAt: b.createdAt, + color: const Color(0xFF7E57C2), ); break; case 'note': final n = notes[_random.nextInt(notes.length)]; item = _StrollItem( type: 'note', - title: n.title.isNotEmpty ? n.title : '无标题', - subtitle: '${n.content.length} 字 · ${n.tags.isNotEmpty ? n.tags.take(2).join(' / ') : '无标签'}', + title: n.title.isNotEmpty ? n.title : '随手记', + subtitle: n.tags.take(3).join(' · '), detail: n.content, imagePath: n.images.isNotEmpty ? n.images.first : null, icon: Icons.note_outlined, label: '笔记', createdAt: n.createdAt, + color: const Color(0xFF66BB6A), ); break; default: @@ -95,44 +109,45 @@ class _StrollPageState extends State { setState(() => _currentItem = item); } - String _buildMovieDetail(Movie m) { + void _refresh() async { + setState(() => _isLoading = true); + await _animController.reverse(); + _loadRandom(); + setState(() => _isLoading = false); + _animController.forward(); + } + + String _movieDetail(Movie m) { final parts = []; - if (m.rating != null && m.rating! > 0) parts.add('评分 ${m.rating!.toStringAsFixed(1)}'); if (m.genres.isNotEmpty) parts.add(m.genres.take(3).join(' / ')); - if (m.status == 'watched') parts.add('已看'); - if (m.status == 'watching') parts.add('在看'); - return parts.join(' · '); + if (m.summary != null && m.summary!.isNotEmpty) parts.add(m.summary!.length > 80 ? '${m.summary!.substring(0, 80)}...' : m.summary!); + return parts.join('\n'); } - String _buildBookDetail(Book b) { + String _bookDetail(Book b) { final parts = []; - if (b.rating != null && b.rating! > 0) parts.add('评分 ${b.rating!.toStringAsFixed(1)}'); + if (b.genres.isNotEmpty) parts.add(b.genres.take(3).join(' / ')); if (b.publisher != null && b.publisher!.isNotEmpty) parts.add(b.publisher!); - if (b.status == 'read') parts.add('已读'); - if (b.status == 'reading') parts.add('在读'); - return parts.join(' · '); + if (b.summary != null && b.summary!.isNotEmpty) parts.add(b.summary!.length > 80 ? '${b.summary!.substring(0, 80)}...' : b.summary!); + return parts.join('\n'); } - String _getTimeAgoText(DateTime date) { + String _timeAgoText(DateTime date) { final diff = DateTime.now().difference(date); - if (diff.inDays >= 365) return '1年前'; - if (diff.inDays >= 180) return '6个月前'; - if (diff.inDays >= 90) return '3个月前'; - if (diff.inDays >= 30) return '1个月前'; - return '${diff.inDays}天前'; + if (diff.inDays >= 365) return '${(diff.inDays / 365).floor()}年前'; + if (diff.inDays >= 30) return '${(diff.inDays / 30).floor()}个月前'; + if (diff.inDays > 0) return '${diff.inDays}天前'; + if (diff.inHours > 0) return '${diff.inHours}小时前'; + return '刚刚'; } - String _getActionTimeAgo(_StrollItem item) { - final timeAgo = _getTimeAgoText(item.createdAt); + String _actionText(_StrollItem item) { + final ago = _timeAgoText(item.createdAt); switch (item.type) { - case 'movie': - return '${timeAgo}看过'; - case 'book': - return '${timeAgo}读过'; - case 'note': - return '${timeAgo}写下'; - default: - return timeAgo; + case 'movie': return '$ago 看过'; + case 'book': return '$ago 读过'; + case 'note': return '$ago 写下'; + default: return ago; } } @@ -146,23 +161,20 @@ class _StrollPageState extends State { Padding( padding: const EdgeInsets.only(right: 12), child: GestureDetector( - onTap: _refresh, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + onTap: _isLoading ? null : _refresh, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), decoration: BoxDecoration( - color: const Color(0xFFF5F5F5), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5), + color: const Color(0xFF1A1A1A), + borderRadius: BorderRadius.circular(18), ), - child: Row( + child: const Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.refresh, size: 14, color: Color(0xFF666666)), - const SizedBox(width: 4), - const Text( - '换一个', - style: TextStyle(fontSize: 12, color: Color(0xFF666666)), - ), + Icon(Icons.casino_outlined, size: 14, color: Colors.white), + SizedBox(width: 5), + Text('随机', style: TextStyle(fontSize: 12, color: Colors.white, fontWeight: FontWeight.w500)), ], ), ), @@ -171,211 +183,163 @@ class _StrollPageState extends State { ], ), body: _currentItem == null - ? const Center( - child: Text( - '还没有任何内容\n去添加一些吧', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 15, color: Color(0xFFBBBBBB), height: 1.6), - ), - ) - : Consumer( - builder: (context, provider, _) { - final item = _currentItem!; + ? const Center(child: Text('还没有任何内容\n去添加一些吧', textAlign: TextAlign.center, + style: TextStyle(fontSize: 15, color: Color(0xFFBBBBBB), height: 1.6))) + : Consumer(builder: (context, provider, _) { + final item = _currentItem!; + final hasImage = item.imagePath != null && item.imagePath!.isNotEmpty && File(item.imagePath!).existsSync(); - // 笔记:独立卡片样式 - if (item.type == 'note') { - return _buildNoteCard(item); - } - - // 影视/书籍:海报+信息样式 - final hasImage = item.imagePath != null && - item.imagePath!.isNotEmpty && - File(item.imagePath!).existsSync(); - - return Center( + return FadeTransition( + opacity: _fadeAnim, + child: Center( child: SingleChildScrollView( - padding: const EdgeInsets.all(32), + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 20), child: Column( mainAxisSize: MainAxisSize.min, children: [ // 类型标签 - _buildTypeChip(item), + _buildTypeBadge(item), + const SizedBox(height: 24), - const SizedBox(height: 28), - - // 海报/封面/图标 - if (hasImage) - ClipRRect( - borderRadius: BorderRadius.circular(16), - child: Image.file( - File(item.imagePath!), - width: 200, - height: 260, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => _buildPlaceholder(item), - ), - ) + // 封面/图片 + if (item.type != 'note') + _buildCoverCard(item, hasImage) else - _buildPlaceholder(item), + _buildNoteCard(item, hasImage), - const SizedBox(height: 28), + const SizedBox(height: 24), - // 标题 - Text( - item.title, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.w700, - color: Color(0xFF1A1A1A), - height: 1.3, + // 标题(笔记卡片已包含标题和内容,不需要重复) + if (item.type != 'note') ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + item.title, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: Color(0xFF1A1A1A), height: 1.3), + ), ), - ), - // 副标题 - if (item.subtitle.isNotEmpty) ...[ - const SizedBox(height: 8), - Text( - item.subtitle, - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 14, color: Color(0xFF999999)), - ), - ], + if (item.subtitle.isNotEmpty) ...[ + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text(item.subtitle, textAlign: TextAlign.center, + style: const TextStyle(fontSize: 13, color: Color(0xFF999999))), + ), + ], - // 详情 - if (item.detail.isNotEmpty) ...[ - const SizedBox(height: 6), - Text( - item.detail, - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 13, color: Color(0xFFAAAAAA)), - ), + // 评分 + if (item.rating != null) ...[ + const SizedBox(height: 10), + AnimatedStarRating(rating: item.rating!, starSize: 16, showNumber: true), + ], + + // 详情 + if (item.detail.isNotEmpty) ...[ + const SizedBox(height: 14), + Container( + margin: const EdgeInsets.symmetric(horizontal: 4), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFFAFAFA), + borderRadius: BorderRadius.circular(12), + ), + child: Text(item.detail, + style: const TextStyle(fontSize: 13, color: Color(0xFF777777), height: 1.7)), + ), + ], ], // 时间 - const SizedBox(height: 10), - Text( - _getActionTimeAgo(item), - style: const TextStyle(fontSize: 12, color: Color(0xFFCCCCCC)), - ), + const SizedBox(height: 12), + Text(_actionText(item), style: const TextStyle(fontSize: 12, color: Color(0xFFCCCCCC))), const SizedBox(height: 40), ], ), ), - ); - }, - ), + ), + ); + }), ); } - Widget _buildTypeChip(_StrollItem item) { + Widget _buildTypeBadge(_StrollItem item) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), decoration: BoxDecoration( - color: const Color(0xFFF5F5F5), - borderRadius: BorderRadius.circular(16), + color: item.color.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(14), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(item.icon, size: 14, color: const Color(0xFF999999)), - const SizedBox(width: 6), - Text(item.label, style: const TextStyle(fontSize: 12, color: Color(0xFF888888))), + Icon(item.icon, size: 14, color: item.color), + const SizedBox(width: 5), + Text(item.label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: item.color)), ], ), ); } - /// 笔记卡片样式 - Widget _buildNoteCard(_StrollItem item) { - return Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _buildTypeChip(item), - const SizedBox(height: 24), + Widget _buildCoverCard(_StrollItem item, bool hasImage) { + return Container( + width: 220, + height: 290, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow(color: item.color.withValues(alpha: 0.15), blurRadius: 20, offset: const Offset(0, 8)), + ], + ), + clipBehavior: Clip.antiAlias, + child: hasImage + ? Image.file(File(item.imagePath!), fit: BoxFit.cover, + errorBuilder: (_, __, ___) => _buildPlaceholder(item)) + : _buildPlaceholder(item), + ); + } - // 笔记卡片 - 类似分享海报 - Container( - width: double.infinity, - constraints: const BoxConstraints(maxWidth: 340), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.08), - blurRadius: 20, - offset: const Offset(0, 8), - ), - ], - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 内容 - Padding( - padding: const EdgeInsets.all(24), - child: Text( - item.detail, - style: const TextStyle( - fontSize: 15, - color: Color(0xFF333333), - height: 1.8, - ), - ), - ), - - // 底部分隔 - Container(height: 0.5, color: const Color(0xFFEEEEEE)), - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - const Icon(Icons.note_outlined, size: 13, color: Color(0xFFCCCCCC)), - const SizedBox(width: 6), - Text( - '${item.detail.length} 字', - style: const TextStyle(fontSize: 11, color: Color(0xFFBBBBBB)), - ), - const SizedBox(width: 10), - Text( - _getActionTimeAgo(item), - style: const TextStyle(fontSize: 11, color: Color(0xFFBBBBBB)), - ), - const Spacer(), - const Text( - 'Mooknote', - style: TextStyle(fontSize: 11, color: Color(0xFFDDDDDD)), - ), - ], - ), - ), - ], - ), - ), - - const SizedBox(height: 32), - ], - ), + Widget _buildNoteCard(_StrollItem item, bool hasImage) { + return Container( + width: double.infinity, + constraints: const BoxConstraints(maxWidth: 360), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 16, offset: const Offset(0, 6)), + ], + ), + clipBehavior: Clip.antiAlias, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (hasImage) + Image.file(File(item.imagePath!), fit: BoxFit.cover, height: 200, width: double.infinity, + errorBuilder: (_, __, ___) => const SizedBox.shrink()), + Padding( + padding: const EdgeInsets.all(20), + child: Text(item.detail.isEmpty ? '(无内容)' : item.detail, + style: const TextStyle(fontSize: 14, color: Color(0xFF444444), height: 1.8)), + ), + const Divider(height: 1, color: Color(0xFFF0F0F0)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Text('${item.detail.length} 字 · Mooknote', style: const TextStyle(fontSize: 11, color: Color(0xFFBBBBBB))), + ), + ], ), ); } Widget _buildPlaceholder(_StrollItem item) { return Container( - width: 120, - height: 160, - decoration: BoxDecoration( - color: const Color(0xFFFAFAFA), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: const Color(0xFFEEEEEE), width: 0.5), + color: const Color(0xFFF8F8F8), + child: Center( + child: Icon(item.icon, size: 48, color: item.color.withValues(alpha: 0.2)), ), - child: Icon(item.icon, size: 40, color: const Color(0xFFDDDDDD)), ); } } @@ -388,7 +352,9 @@ class _StrollItem { final String? imagePath; final IconData icon; final String label; + final double? rating; final DateTime createdAt; + final Color color; _StrollItem({ required this.type, @@ -398,6 +364,8 @@ class _StrollItem { this.imagePath, required this.icon, required this.label, + this.rating, required this.createdAt, + required this.color, }); } diff --git a/lib/utils/app_router.dart b/lib/utils/app_router.dart index e6be35d..06e5264 100644 --- a/lib/utils/app_router.dart +++ b/lib/utils/app_router.dart @@ -7,85 +7,62 @@ 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'; +import 'slide_up_page_route.dart'; /// 路由生成器 class AppRouter { static Route generateRoute(RouteSettings settings) { switch (settings.name) { case '/movie-form': - // 处理不同参数类型:Movie 对象或 Map(包含 initialStatus) final args = settings.arguments; Movie? movie; String? initialStatus; - if (args is Movie) { movie = args; } else if (args is Map) { initialStatus = args['initialStatus'] as String?; } - - return MaterialPageRoute( - builder: (_) => MovieFormPage( - movie: movie, - initialStatus: initialStatus, - ), + return SlideUpPageRoute( + page: MovieFormPage(movie: movie, initialStatus: initialStatus), ); - + case '/book-form': - // 处理不同参数类型:Book 对象或 Map(包含 initialStatus) final args = settings.arguments; Book? book; String? initialStatus; - if (args is Book) { book = args; } else if (args is Map) { initialStatus = args['initialStatus'] as String?; } - - return MaterialPageRoute( - builder: (_) => BookFormPage( - book: book, - initialStatus: initialStatus, - ), + return SlideUpPageRoute( + page: BookFormPage(book: book, initialStatus: initialStatus), ); - + case '/note-form': final note = settings.arguments as Note?; - return MaterialPageRoute( - builder: (_) => NoteFormPage(note: note), - ); - + return SlideUpPageRoute(page: NoteFormPage(note: note)); + case '/movie-detail': final movie = settings.arguments as Movie; - return MaterialPageRoute( - builder: (_) => MovieDetailPage(movie: movie), - ); - + return SlideUpPageRoute(page: MovieDetailPage(movie: movie)); + case '/book-detail': final book = settings.arguments as Book; - return MaterialPageRoute( - builder: (_) => BookDetailPage(book: book), - ); - + return SlideUpPageRoute(page: BookDetailPage(book: book)); + case '/note-detail': final note = settings.arguments as Note; - return MaterialPageRoute( - builder: (_) => NoteDetailPage(note: note), - ); - + return SlideUpPageRoute(page: NoteDetailPage(note: note)); + case '/douban-webview': final url = settings.arguments as String; - return MaterialPageRoute( - builder: (_) => DoubanWebViewPage(url: url), - ); - + return SlideUpPageRoute(page: DoubanWebViewPage(url: url)); + default: return MaterialPageRoute( builder: (_) => Scaffold( - body: Center( - child: Text('未找到页面:${settings.name}'), - ), + body: Center(child: Text('未找到页面:${settings.name}')), ), ); } diff --git a/lib/utils/slide_up_page_route.dart b/lib/utils/slide_up_page_route.dart new file mode 100644 index 0000000..f0f5f60 --- /dev/null +++ b/lib/utils/slide_up_page_route.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +/// 自定义页面过渡动画 — 向上滑入 + 淡入 +class SlideUpPageRoute extends PageRouteBuilder { + SlideUpPageRoute({required Widget page}) + : super( + pageBuilder: (context, animation, secondaryAnimation) => page, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + const begin = Offset(0.0, 0.08); + const end = Offset.zero; + final tween = Tween(begin: begin, end: end).chain( + CurveTween(curve: Curves.easeOutCubic), + ); + final fadeTween = Tween(begin: 0.0, end: 1.0).chain( + CurveTween(curve: const Interval(0.0, 0.3, curve: Curves.easeOut)), + ); + return SlideTransition( + position: animation.drive(tween), + child: FadeTransition( + opacity: animation.drive(fadeTween), + child: child, + ), + ); + }, + transitionDuration: const Duration(milliseconds: 350), + reverseTransitionDuration: const Duration(milliseconds: 250), + ); +} diff --git a/lib/utils/user_prefs.dart b/lib/utils/user_prefs.dart index 3af85e3..b1404c8 100644 --- a/lib/utils/user_prefs.dart +++ b/lib/utils/user_prefs.dart @@ -68,10 +68,18 @@ class UserPrefs { int get defaultMainTabIndex => prefs.getInt('defaultMainTabIndex') ?? 0; Future setDefaultMainTabIndex(int value) => prefs.setInt('defaultMainTabIndex', value); - /// 笔记布局样式 (0: 列表, 1: 瀑布流) + /// 笔记布局样式 (0: 列表, 1: 瀑布流, 2: 时间线) int get noteLayoutStyle => prefs.getInt('noteLayoutStyle') ?? 0; Future setNoteLayoutStyle(int value) => prefs.setInt('noteLayoutStyle', value); + /// 影视布局样式 (0: 海报网格, 1: 列表) + int get movieLayoutStyle => prefs.getInt('movieLayoutStyle') ?? 0; + Future setMovieLayoutStyle(int value) => prefs.setInt('movieLayoutStyle', value); + + /// 阅读布局样式 (0: 封面网格, 1: 列表) + int get bookLayoutStyle => prefs.getInt('bookLayoutStyle') ?? 0; + Future setBookLayoutStyle(int value) => prefs.setInt('bookLayoutStyle', value); + // ========== 应用图标设置 ========== /// Markdown 阅读器最近选择的目录 diff --git a/lib/widgets/animated_star_rating.dart b/lib/widgets/animated_star_rating.dart new file mode 100644 index 0000000..1823d3d --- /dev/null +++ b/lib/widgets/animated_star_rating.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; + +/// 带动画的星级评分组件 — 星星依次亮起 + 数字滚动 +class AnimatedStarRating extends StatefulWidget { + final double rating; + final double starSize; + final Color color; + final bool showNumber; + + const AnimatedStarRating({ + super.key, + required this.rating, + this.starSize = 14, + this.color = const Color(0xFFFFB800), + this.showNumber = false, + }); + + @override + State createState() => _AnimatedStarRatingState(); +} + +class _AnimatedStarRatingState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late List> _animations; + late Animation _numberAnim; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 600), + ); + _animations = List.generate(5, (i) { + return CurvedAnimation( + parent: _controller, + curve: Interval(i * 0.12, (i * 0.12) + 0.35, curve: Curves.easeOutBack), + ); + }); + _numberAnim = Tween(begin: 0, end: widget.rating).animate( + CurvedAnimation( + parent: _controller, + curve: const Interval(0.1, 0.8, curve: Curves.easeOut), + ), + ); + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final starValue = widget.rating / 2; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + ...List.generate(5, (index) { + final starIndex = index + 1; + IconData iconData; + if (starValue >= starIndex) { + iconData = Icons.star; + } else if (starValue >= starIndex - 0.5) { + iconData = Icons.star_half; + } else { + iconData = Icons.star_border; + } + return ScaleTransition( + scale: _animations[index], + child: Icon(iconData, size: widget.starSize, color: widget.color), + ); + }), + if (widget.showNumber) ...[ + const SizedBox(width: 4), + AnimatedBuilder( + animation: _numberAnim, + builder: (context, child) { + return Text( + _numberAnim.value.toStringAsFixed(1), + style: TextStyle( + fontSize: widget.starSize, + fontWeight: FontWeight.w600, + color: const Color(0xFF666666), + ), + ); + }, + ), + ], + ], + ); + } +} diff --git a/lib/widgets/app_refresh_indicator.dart b/lib/widgets/app_refresh_indicator.dart new file mode 100644 index 0000000..0eee924 --- /dev/null +++ b/lib/widgets/app_refresh_indicator.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +/// 自定义下拉刷新包装器 — 统一使用品牌色 +class AppRefreshIndicator extends StatelessWidget { + final Future Function() onRefresh; + final Widget child; + final String? semanticsLabel; + + const AppRefreshIndicator({ + super.key, + required this.onRefresh, + required this.child, + this.semanticsLabel, + }); + + @override + Widget build(BuildContext context) { + return RefreshIndicator( + onRefresh: onRefresh, + color: const Color(0xFF1A1A1A), + backgroundColor: Colors.white, + strokeWidth: 2.5, + displacement: 60, + semanticsLabel: semanticsLabel, + child: child, + ); + } +} diff --git a/lib/widgets/book_list_item.dart b/lib/widgets/book_list_item.dart index 08b2e7b..e506e9d 100644 --- a/lib/widgets/book_list_item.dart +++ b/lib/widgets/book_list_item.dart @@ -3,6 +3,8 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/data_models.dart'; import '../providers/app_provider.dart'; +import '../widgets/fade_in_local_image.dart'; +import '../widgets/animated_star_rating.dart'; import '../utils/toast_util.dart'; /// 书籍列表项组件 - 网格布局设计 @@ -42,9 +44,9 @@ class BookListItem extends StatelessWidget { const SizedBox(height: 4), - // 评分 - 5星显示 + // 评分 if (book.rating != null) - _buildStarRating(book.rating!) + AnimatedStarRating(rating: book.rating!, starSize: 12, showNumber: true) else const SizedBox(height: 16), ], @@ -57,75 +59,19 @@ class BookListItem extends StatelessWidget { return Container( width: double.infinity, decoration: BoxDecoration( - color: const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(4), border: Border.all(color: const Color(0xFFE5E5E5), width: 0.5), ), clipBehavior: Clip.antiAlias, - child: book.coverPath != null && book.coverPath!.isNotEmpty - ? Image.file( - File(book.coverPath!), - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => _buildCoverPlaceholder(), - ) - : _buildCoverPlaceholder(), - ); - } - - Widget _buildCoverPlaceholder() { - return const Center( - child: Icon( - Icons.menu_book_outlined, - size: 32, - color: Color(0xFFCCCCCC), + child: FadeInLocalImage( + path: book.coverPath, + fit: BoxFit.cover, + placeholder: const Center(child: Icon(Icons.menu_book_outlined, size: 32, color: Color(0xFFCCCCCC))), + errorWidget: const Center(child: Icon(Icons.menu_book_outlined, size: 32, color: Color(0xFFCCCCCC))), ), ); } - /// 构建5星评分显示(评分范围1-10,每星2分) - Widget _buildStarRating(double rating) { - // 将10分制转换为5星制 - final starValue = rating / 2; - - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - // 5个星星 - ...List.generate(5, (index) { - final starIndex = index + 1; - IconData iconData; - - if (starValue >= starIndex) { - // 满星 - iconData = Icons.star; - } else if (starValue >= starIndex - 0.5) { - // 半星 - iconData = Icons.star_half; - } else { - // 空星 - iconData = Icons.star_border; - } - - return Icon( - iconData, - size: 12, - color: const Color(0xFFFFB800), - ); - }), - const SizedBox(width: 4), - // 评分数字 - Text( - rating.toStringAsFixed(1), - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: Color(0xFF666666), - ), - ), - ], - ); - } - /// 显示删除确认对话框 void _showDeleteDialog(BuildContext context) { showDialog( diff --git a/lib/widgets/fade_in_local_image.dart b/lib/widgets/fade_in_local_image.dart new file mode 100644 index 0000000..92ec3d2 --- /dev/null +++ b/lib/widgets/fade_in_local_image.dart @@ -0,0 +1,111 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; + +/// 带淡入动画的本地图片组件 +class FadeInLocalImage extends StatefulWidget { + final String? path; + final double? width; + final double? height; + final BoxFit fit; + final Widget? placeholder; + final Widget? errorWidget; + final Duration duration; + + const FadeInLocalImage({ + super.key, + required this.path, + this.width, + this.height, + this.fit = BoxFit.cover, + this.placeholder, + this.errorWidget, + this.duration = const Duration(milliseconds: 400), + }); + + @override + State createState() => _FadeInLocalImageState(); +} + +class _FadeInLocalImageState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _opacity; + bool _loaded = false; + bool _error = false; + + @override + void initState() { + super.initState(); + _controller = AnimationController(vsync: this, duration: widget.duration); + _opacity = CurvedAnimation(parent: _controller, curve: Curves.easeIn); + _checkFile(); + } + + void _checkFile() { + if (widget.path == null || widget.path!.isEmpty) { + setState(() => _error = true); + return; + } + final file = File(widget.path!); + if (!file.existsSync()) { + setState(() => _error = true); + return; + } + setState(() => _loaded = true); + _controller.forward(); + } + + @override + void didUpdateWidget(FadeInLocalImage oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.path != oldWidget.path) { + _error = false; + _loaded = false; + _controller.reset(); + _checkFile(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_error) { + return widget.errorWidget ?? + Container( + width: widget.width, + height: widget.height, + color: const Color(0xFFF5F5F5), + child: const Icon(Icons.broken_image_outlined, size: 24, color: Color(0xFFCCCCCC)), + ); + } + if (!_loaded) { + return widget.placeholder ?? + Container( + width: widget.width, + height: widget.height, + color: const Color(0xFFF5F5F5), + ); + } + return FadeTransition( + opacity: _opacity, + child: Image.file( + File(widget.path!), + width: widget.width, + height: widget.height, + fit: widget.fit, + errorBuilder: (_, __, ___) => widget.errorWidget ?? + Container( + width: widget.width, + height: widget.height, + color: const Color(0xFFF5F5F5), + child: const Icon(Icons.broken_image_outlined, size: 24, color: Color(0xFFCCCCCC)), + ), + ), + ); + } +} diff --git a/lib/widgets/movie_list_item.dart b/lib/widgets/movie_list_item.dart index 2c8186d..f42591e 100644 --- a/lib/widgets/movie_list_item.dart +++ b/lib/widgets/movie_list_item.dart @@ -3,6 +3,8 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/data_models.dart'; import '../providers/app_provider.dart'; +import '../widgets/fade_in_local_image.dart'; +import '../widgets/animated_star_rating.dart'; import '../utils/toast_util.dart'; /// 观影列表项组件 - 网格布局设计 @@ -42,9 +44,9 @@ class MovieListItem extends StatelessWidget { const SizedBox(height: 4), - // 评分 - 5星显示 + // 评分 if (movie.rating != null) - _buildStarRating(movie.rating!) + AnimatedStarRating(rating: movie.rating!, starSize: 12, showNumber: true) else const SizedBox(height: 16), ], @@ -57,75 +59,19 @@ class MovieListItem extends StatelessWidget { return Container( width: double.infinity, decoration: BoxDecoration( - color: const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(8), border: Border.all(color: const Color(0xFFE5E5E5), width: 0.5), ), clipBehavior: Clip.antiAlias, - child: movie.posterPath != null && movie.posterPath!.isNotEmpty - ? Image.file( - File(movie.posterPath!), - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => _buildPosterPlaceholder(), - ) - : _buildPosterPlaceholder(), - ); - } - - Widget _buildPosterPlaceholder() { - return const Center( - child: Icon( - Icons.movie_outlined, - size: 24, - color: Color(0xFFCCCCCC), + child: FadeInLocalImage( + path: movie.posterPath, + fit: BoxFit.cover, + placeholder: const Center(child: Icon(Icons.movie_outlined, size: 24, color: Color(0xFFCCCCCC))), + errorWidget: const Center(child: Icon(Icons.movie_outlined, size: 24, color: Color(0xFFCCCCCC))), ), ); } - - /// 构建5星评分显示(评分范围1-10,每星2分) - Widget _buildStarRating(double rating) { - // 将10分制转换为5星制 - final starValue = rating / 2; - - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - // 5个星星 - ...List.generate(5, (index) { - final starIndex = index + 1; - IconData iconData; - - if (starValue >= starIndex) { - // 满星 - iconData = Icons.star; - } else if (starValue >= starIndex - 0.5) { - // 半星 - iconData = Icons.star_half; - } else { - // 空星 - iconData = Icons.star_border; - } - - return Icon( - iconData, - size: 12, - color: const Color(0xFFFFB800), - ); - }), - const SizedBox(width: 4), - // 评分数字 - Text( - rating.toStringAsFixed(1), - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: Color(0xFF666666), - ), - ), - ], - ); - } - + /// 显示删除确认对话框 void _showDeleteDialog(BuildContext context) { showDialog( diff --git a/lib/widgets/shimmer_skeleton.dart b/lib/widgets/shimmer_skeleton.dart new file mode 100644 index 0000000..59aff11 --- /dev/null +++ b/lib/widgets/shimmer_skeleton.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; + +/// 骨架屏加载组件 — 闪烁占位动画 +class ShimmerSkeleton extends StatefulWidget { + final double width; + final double height; + final double borderRadius; + + const ShimmerSkeleton({ + super.key, + required this.width, + required this.height, + this.borderRadius = 6, + }); + + @override + State createState() => _ShimmerSkeletonState(); +} + +class _ShimmerSkeletonState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1000), + )..repeat(reverse: true); + _animation = Tween(begin: 0.3, end: 1.0).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeInOut), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return Container( + width: widget.width, + height: widget.height, + decoration: BoxDecoration( + color: const Color(0xFFE0E0E0).withValues(alpha: _animation.value), + borderRadius: BorderRadius.circular(widget.borderRadius), + ), + ); + }, + ); + } +} + +/// 影视骨架屏 +class MovieSkeletonGrid extends StatelessWidget { + const MovieSkeletonGrid({super.key}); + + @override + Widget build(BuildContext context) { + return GridView.builder( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + childAspectRatio: 0.55, + crossAxisSpacing: 12, + mainAxisSpacing: 16, + ), + itemCount: 9, + itemBuilder: (_, __) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ShimmerSkeleton( + width: double.infinity, + height: double.infinity, + borderRadius: 8, + ), + ), + const SizedBox(height: 8), + const ShimmerSkeleton(width: double.infinity, height: 14), + const SizedBox(height: 4), + const ShimmerSkeleton(width: 70, height: 12), + ], + ), + ); + } +} + +/// 书籍骨架屏 +class BookSkeletonGrid extends StatelessWidget { + const BookSkeletonGrid({super.key}); + + @override + Widget build(BuildContext context) { + return GridView.builder( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + childAspectRatio: 0.55, + crossAxisSpacing: 12, + mainAxisSpacing: 16, + ), + itemCount: 9, + itemBuilder: (_, __) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ShimmerSkeleton( + width: double.infinity, + height: double.infinity, + borderRadius: 4, + ), + ), + const SizedBox(height: 8), + const ShimmerSkeleton(width: double.infinity, height: 14), + const SizedBox(height: 4), + const ShimmerSkeleton(width: 70, height: 12), + ], + ), + ); + } +} + +/// 笔记列表骨架屏 +class NoteSkeletonList extends StatelessWidget { + const NoteSkeletonList({super.key}); + + @override + Widget build(BuildContext context) { + return ListView.builder( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 100), + itemCount: 6, + itemBuilder: (_, __) => Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF8F8F8), + borderRadius: BorderRadius.circular(8), + ), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + ShimmerSkeleton(width: 60, height: 12), + SizedBox(width: 8), + ShimmerSkeleton(width: 24, height: 12), + ], + ), + SizedBox(height: 8), + ShimmerSkeleton(width: 150, height: 16), + SizedBox(height: 6), + ShimmerSkeleton(width: double.infinity, height: 13), + SizedBox(height: 4), + ShimmerSkeleton(width: 200, height: 13), + ], + ), + ), + ); + } +}