动画效果美化

This commit is contained in:
DelLevin-Home
2026-05-23 17:25:48 +08:00
parent c6ba9da0ab
commit 254ddd8970
17 changed files with 1911 additions and 1073 deletions

View File

@@ -143,14 +143,28 @@ class _BookDetailPageState extends State<BookDetailPage> {
required String tooltip, required String tooltip,
Color backgroundColor = const Color(0xFF1A1A1A), Color backgroundColor = const Color(0xFF1A1A1A),
}) { }) {
return FloatingActionButton( return Material(
onPressed: onPressed, color: Colors.transparent,
tooltip: tooltip, child: Ink(
backgroundColor: backgroundColor, width: 40,
foregroundColor: Colors.white, height: 40,
mini: true, decoration: BoxDecoration(
elevation: 4, color: backgroundColor,
child: Icon(icon, size: 20), 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,
),
),
); );
} }

View File

@@ -1,22 +1,39 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../providers/app_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_status_bar.dart';
import '../../widgets/book_list_item.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}); const BookTabPage({super.key});
@override
State<BookTabPage> createState() => _BookTabPageState();
}
class _BookTabPageState extends State<BookTabPage> {
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
children: [ children: [
// 状态选择栏(读完、在读、准备读)
const BookStatusBar(), const BookStatusBar(),
// 书籍列表
Expanded( Expanded(
child: _buildBookList(context), child: _buildBookList(context),
), ),
@@ -24,173 +41,181 @@ class BookTabPage extends StatelessWidget {
); );
} }
/// 构建书籍列表
Widget _buildBookList(BuildContext context) { Widget _buildBookList(BuildContext context) {
return Consumer<AppProvider>( return Consumer<AppProvider>(
builder: (context, provider, child) { 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 currentStatus = statusMap[provider.bookStatusIndex]!;
final books = provider.getBooksByStatus(currentStatus); final books = provider.getBooksByStatus(currentStatus);
if (_firstLoad) {
return _buildSkeleton();
}
if (books.isEmpty) { if (books.isEmpty) {
return _buildEmptyState(context, provider.bookStatusIndex); return _buildEmptyState(context, provider.bookStatusIndex);
} }
return RefreshIndicator( if (_layoutStyle == 1) {
onRefresh: () async => await provider.loadBooks(), return _buildListView(books, provider);
color: const Color(0xFF1A1A1A), }
backgroundColor: Colors.white, return _buildGridView(books, provider);
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]);
},
),
);
}, },
); );
} }
/// 构建空状态提示 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<AppProvider>().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) { Widget _buildEmptyState(BuildContext context, int statusIndex) {
final statusText = ['已读', '在读', '想读'][statusIndex]; final statusText = ['已读', '在读', '想读'][statusIndex];
return Center( return Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Container( Container(
width: 80, width: 80, height: 80,
height: 80, decoration: BoxDecoration(color: const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(20)),
decoration: BoxDecoration( child: const Icon(Icons.menu_book_outlined, size: 40, color: Color(0xFFCCCCCC)),
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(20),
),
child: const Icon(
Icons.menu_book_outlined,
size: 40,
color: Color(0xFFCCCCCC),
),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
Text( Text('暂无$statusText的书籍', style: const TextStyle(fontSize: 16, color: Color(0xFF999999))),
'暂无$statusText的书籍',
style: const TextStyle(
fontSize: 16,
color: Color(0xFF999999),
),
),
const SizedBox(height: 24), const SizedBox(height: 24),
InkWell( InkWell(
onTap: () { onTap: () {
final statusMap = { final statusMap = {0: 'read', 1: 'reading', 2: 'want_to_read'};
0: 'read', Navigator.pushNamed(context, '/book-form', arguments: {'initialStatus': statusMap[statusIndex]!});
1: 'reading',
2: 'want_to_read',
};
final currentStatus = statusMap[statusIndex]!;
Navigator.pushNamed(
context,
'/book-form',
arguments: {'initialStatus': currentStatus},
);
}, },
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(color: const Color(0xFF1A1A1A), borderRadius: BorderRadius.circular(8)),
color: const Color(0xFF1A1A1A), child: const Text('添加记录', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Colors.white)),
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'添加记录',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
), ),
), ),
], ],
), ),
); );
} }
/// 获取示例书籍数据
List<Book> _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();
}
} }

View File

@@ -143,14 +143,28 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
required String tooltip, required String tooltip,
Color backgroundColor = const Color(0xFF1A1A1A), Color backgroundColor = const Color(0xFF1A1A1A),
}) { }) {
return FloatingActionButton( return Material(
onPressed: onPressed, color: Colors.transparent,
tooltip: tooltip, child: Ink(
backgroundColor: backgroundColor, width: 40,
foregroundColor: Colors.white, height: 40,
mini: true, decoration: BoxDecoration(
elevation: 4, color: backgroundColor,
child: Icon(icon, size: 20), 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,
),
),
); );
} }

View File

@@ -1,23 +1,40 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../providers/app_provider.dart'; import '../../providers/app_provider.dart';
import '../../utils/user_prefs.dart';
import '../../widgets/movie_status_bar.dart'; import '../../widgets/movie_status_bar.dart';
import '../../widgets/movie_list_item.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}); const MovieTabPage({super.key});
@override
State<MovieTabPage> createState() => _MovieTabPageState();
}
class _MovieTabPageState extends State<MovieTabPage> {
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
children: [ children: [
// 状态选择栏
const MovieStatusBar(), const MovieStatusBar(),
const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)), const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)),
// 影片列表
Expanded( Expanded(
child: _buildMovieList(context), child: _buildMovieList(context),
), ),
@@ -25,102 +42,213 @@ class MovieTabPage extends StatelessWidget {
); );
} }
/// 构建影片列表
Widget _buildMovieList(BuildContext context) { Widget _buildMovieList(BuildContext context) {
return Consumer<AppProvider>( return Consumer<AppProvider>(
builder: (context, provider, child) { builder: (context, provider, child) {
final statusMap = { final statusMap = {0: 'watched', 1: 'watching', 2: 'want_to_watch'};
0: 'watched',
1: 'watching',
2: 'want_to_watch',
};
final currentStatus = statusMap[provider.movieStatusIndex]!; 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); final movies = provider.getMoviesByStatus(currentStatus);
if (movies.isEmpty) { if (movies.isEmpty) {
return _buildEmptyState(context, provider.movieStatusIndex); return _buildEmptyState(context, provider.movieStatusIndex);
} }
return RefreshIndicator( if (_layoutStyle == 1) {
onRefresh: () async => await provider.loadMovies(), return _buildListView(movies, provider);
color: const Color(0xFF1A1A1A), }
backgroundColor: Colors.white, return _buildGridView(movies, provider);
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]);
},
),
);
}, },
); );
} }
/// 构建空状态 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<AppProvider>().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) { Widget _buildEmptyState(BuildContext context, int statusIndex) {
final statusText = ['已看', '在看', '想看'][statusIndex]; final statusText = ['已看', '在看', '想看'][statusIndex];
return Center( return Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Container( Container(
width: 80, width: 80, height: 80,
height: 80, decoration: BoxDecoration(color: const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(20)),
decoration: BoxDecoration( child: const Icon(Icons.movie_outlined, size: 40, color: Color(0xFFCCCCCC)),
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(20),
),
child: const Icon(
Icons.movie_outlined,
size: 40,
color: Color(0xFFCCCCCC),
),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
Text( Text('暂无$statusText的影片', style: const TextStyle(fontSize: 16, color: Color(0xFF999999))),
'暂无$statusText的影片',
style: const TextStyle(
fontSize: 16,
color: Color(0xFF999999),
),
),
const SizedBox(height: 24), const SizedBox(height: 24),
InkWell( InkWell(
onTap: () { onTap: () {
final statusMap = { final statusMap = {0: 'watched', 1: 'watching', 2: 'want_to_watch'};
0: 'watched', Navigator.pushNamed(context, '/movie-form', arguments: {'initialStatus': statusMap[statusIndex]!});
1: 'watching',
2: 'want_to_watch',
};
final currentStatus = statusMap[statusIndex]!;
Navigator.pushNamed(
context,
'/movie-form',
arguments: {'initialStatus': currentStatus},
);
}, },
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(color: const Color(0xFF1A1A1A), borderRadius: BorderRadius.circular(8)),
color: const Color(0xFF1A1A1A), child: const Text('添加记录', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Colors.white)),
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'添加记录',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
), ),
), ),
], ],

View File

@@ -5,6 +5,7 @@ import '../../providers/app_provider.dart';
import '../../models/data_models.dart'; import '../../models/data_models.dart';
import '../../utils/user_prefs.dart'; import '../../utils/user_prefs.dart';
import '../../widgets/note_list_item.dart'; import '../../widgets/note_list_item.dart';
import '../../widgets/shimmer_skeleton.dart';
/// 笔记标签页 /// 笔记标签页
class NoteTabPage extends StatefulWidget { class NoteTabPage extends StatefulWidget {
@@ -21,7 +22,8 @@ class _NoteTabPageState extends State<NoteTabPage> {
bool _hasMore = true; bool _hasMore = true;
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
int _layoutStyle = 0; // 0: 列表, 1: 瀑布流 int _layoutStyle = 0; // 0: 列表, 1: 瀑布流, 2: 时间线
bool _firstLoad = true;
@override @override
void initState() { void initState() {
@@ -30,6 +32,7 @@ class _NoteTabPageState extends State<NoteTabPage> {
_layoutStyle = UserPrefs().noteLayoutStyle; _layoutStyle = UserPrefs().noteLayoutStyle;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_loadMoreNotes(); _loadMoreNotes();
if (mounted) setState(() => _firstLoad = false);
}); });
} }
@@ -113,6 +116,10 @@ class _NoteTabPageState extends State<NoteTabPage> {
final allNotes = provider.notes; final allNotes = provider.notes;
_syncDisplayedNotes(allNotes); _syncDisplayedNotes(allNotes);
if (_firstLoad) {
return _buildSkeleton();
}
if (allNotes.isEmpty && _displayedNotes.isEmpty) { if (allNotes.isEmpty && _displayedNotes.isEmpty) {
return _buildEmptyState(context); return _buildEmptyState(context);
} }
@@ -120,6 +127,9 @@ class _NoteTabPageState extends State<NoteTabPage> {
if (_layoutStyle == 1) { if (_layoutStyle == 1) {
return _buildWaterfallView(); return _buildWaterfallView();
} }
if (_layoutStyle == 2) {
return _buildTimelineView();
}
return _buildListView(); return _buildListView();
}, },
), ),
@@ -128,6 +138,59 @@ class _NoteTabPageState extends State<NoteTabPage> {
); );
} }
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() { Widget _buildListView() {
@@ -157,6 +220,141 @@ class _NoteTabPageState extends State<NoteTabPage> {
); );
} }
// ─── 时间线视图 ──────────────────────────────────────────────────────
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<AppProvider>().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() { Widget _buildWaterfallView() {

View File

@@ -610,13 +610,11 @@ class SettingsPage extends StatefulWidget {
class _SettingsPageState extends State<SettingsPage> { class _SettingsPageState extends State<SettingsPage> {
final UserPrefs _userPrefs = UserPrefs(); final UserPrefs _userPrefs = UserPrefs();
bool _hideBottomNavOnScroll = true; bool _hideBottomNavOnScroll = true;
int _noteLayoutStyle = 0;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_hideBottomNavOnScroll = _userPrefs.hideBottomNavOnScroll; _hideBottomNavOnScroll = _userPrefs.hideBottomNavOnScroll;
_noteLayoutStyle = _userPrefs.noteLayoutStyle;
} }
@override @override
@@ -664,8 +662,8 @@ class _SettingsPageState extends State<SettingsPage> {
const Divider(height: 0.5, indent: 24, endIndent: 24), const Divider(height: 0.5, indent: 24, endIndent: 24),
_buildNavigationItem( _buildNavigationItem(
icon: Icons.view_list_outlined, icon: Icons.view_list_outlined,
title: '主界面功能显示', title: '主界面设置',
subtitle: '控制观影、阅读、笔记的显示', subtitle: '启动标签、模块显示开关',
onTap: () { onTap: () {
Navigator.push( Navigator.push(
context, context,
@@ -676,13 +674,22 @@ class _SettingsPageState extends State<SettingsPage> {
}, },
), ),
const Divider(height: 0.5, indent: 24, endIndent: 24), const Divider(height: 0.5, indent: 24, endIndent: 24),
_buildSwitchItem(
icon: Icons.grid_view_outlined, // 布局设置
title: '笔记瀑布流布局', _buildSectionHeader('布局设置'),
subtitle: '使用双列瀑布流样式展示笔记', _buildNavigationItem(
value: _noteLayoutStyle == 1, icon: Icons.dashboard_outlined,
onChanged: _toggleNoteLayoutStyle, title: '布局设置',
subtitle: '笔记、影视、阅读的展示样式',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const LayoutSettingsPage()),
);
},
), ),
const Divider(height: 0.5, indent: 24, endIndent: 24),
// 使用说明 // 使用说明
_buildSectionHeader('帮助'), _buildSectionHeader('帮助'),
_buildLinkItem( _buildLinkItem(
@@ -714,12 +721,6 @@ class _SettingsPageState extends State<SettingsPage> {
setState(() => _hideBottomNavOnScroll = value); setState(() => _hideBottomNavOnScroll = value);
} }
Future<void> _toggleNoteLayoutStyle(bool value) async {
final v = value ? 1 : 0;
await _userPrefs.setNoteLayoutStyle(v);
setState(() => _noteLayoutStyle = v);
}
/// 构建开关项 /// 构建开关项
Widget _buildSwitchItem({ Widget _buildSwitchItem({
required IconData icon, required IconData icon,
@@ -1223,23 +1224,17 @@ class _MainContentSettingsPageState extends State<MainContentSettingsPage> {
return Scaffold( return Scaffold(
backgroundColor: Colors.white, backgroundColor: Colors.white,
appBar: AppBar( appBar: AppBar(
title: const Text('主界面功能显示'), title: const Text('主界面设置'),
), ),
body: ListView( body: ListView(
children: [ children: [
// 说明文字 // 启动设置
Container( _buildSectionHeader('启动设置'),
padding: const EdgeInsets.all(24), _buildDefaultTabSelector(),
child: const Text( const Divider(height: 0.5, indent: 24, endIndent: 24),
'选择要在主界面显示的功能模块,至少保留一个。',
style: TextStyle( // 模块开关
fontSize: 14, _buildSectionHeader('模块开关'),
color: Color(0xFF666666),
),
),
),
const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)),
// 观影开关
_buildSwitchItem( _buildSwitchItem(
icon: Icons.movie_outlined, icon: Icons.movie_outlined,
title: '观影', title: '观影',
@@ -1248,7 +1243,6 @@ class _MainContentSettingsPageState extends State<MainContentSettingsPage> {
onChanged: _toggleMovieTab, onChanged: _toggleMovieTab,
), ),
const Divider(height: 0.5, indent: 24, endIndent: 24), const Divider(height: 0.5, indent: 24, endIndent: 24),
// 阅读开关
_buildSwitchItem( _buildSwitchItem(
icon: Icons.menu_book_outlined, icon: Icons.menu_book_outlined,
title: '阅读', title: '阅读',
@@ -1257,7 +1251,6 @@ class _MainContentSettingsPageState extends State<MainContentSettingsPage> {
onChanged: _toggleBookTab, onChanged: _toggleBookTab,
), ),
const Divider(height: 0.5, indent: 24, endIndent: 24), const Divider(height: 0.5, indent: 24, endIndent: 24),
// 笔记开关
_buildSwitchItem( _buildSwitchItem(
icon: Icons.note_outlined, icon: Icons.note_outlined,
title: '笔记', title: '笔记',
@@ -1266,95 +1259,118 @@ class _MainContentSettingsPageState extends State<MainContentSettingsPage> {
onChanged: _toggleNoteTab, onChanged: _toggleNoteTab,
), ),
const Divider(height: 0.5, indent: 24, endIndent: 24), const Divider(height: 0.5, indent: 24, endIndent: 24),
Container(
// 默认启动标签 padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10),
_buildDefaultTabSelector(), child: const Text(
const Divider(height: 0.5, indent: 24, endIndent: 24), '至少保留一个模块,关闭后对应标签页将不再显示。',
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() { Widget _buildDefaultTabSelector() {
final options = [ final labels = ['影视', '阅读', '笔记'];
{'label': '影视', 'icon': Icons.movie_outlined, 'value': 0}, final icons = [Icons.movie_outlined, Icons.menu_book_outlined, Icons.note_outlined];
{'label': '阅读', 'icon': Icons.menu_book_outlined, 'value': 1},
{'label': '笔记', 'icon': Icons.note_outlined, 'value': 2},
];
return ListTile( return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
leading: Container( leading: Container(
width: 48, width: 44,
height: 48, height: 44,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFF5F5F5), color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.home_outlined,
color: Color(0xFF666666),
size: 24,
), ),
child: const Icon(Icons.home_outlined, color: Color(0xFF666666), size: 22),
), ),
title: const Text( title: const Text(
'默认启动标签', '默认启动标签',
style: TextStyle( style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: Color(0xFF1A1A1A)),
fontSize: 16,
fontWeight: FontWeight.w500,
color: Color(0xFF1A1A1A),
),
), ),
subtitle: const Text( subtitle: const Text(
'打开应用时默认显示的页面', '打开应用时默认显示的页面',
style: TextStyle( style: TextStyle(fontSize: 12, color: Color(0xFF999999)),
fontSize: 13,
color: Color(0xFF999999),
),
), ),
trailing: SizedBox( trailing: Row(
width: 80, mainAxisSize: MainAxisSize.min,
child: DropdownButtonHideUnderline( children: [
child: DropdownButton<int>( Text(
value: _defaultTabIndex, labels[_defaultTabIndex],
isDense: true, style: const TextStyle(fontSize: 14, color: Color(0xFF999999)),
icon: const Icon(Icons.chevron_right, color: Color(0xFFCCCCCC)),
selectedItemBuilder: (context) {
return options.map<Widget>((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<int>(
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);
}
},
), ),
const SizedBox(width: 4),
const Icon(Icons.chevron_right, color: Color(0xFFCCCCCC), size: 20),
],
),
onTap: () => _showDefaultTabPicker(labels, icons),
);
}
void _showDefaultTabPicker(List<String> labels, List<IconData> 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<MainContentSettingsPage> {
} }
} }
/// 布局设置页面
class LayoutSettingsPage extends StatefulWidget {
const LayoutSettingsPage({super.key});
@override
State<LayoutSettingsPage> createState() => _LayoutSettingsPageState();
}
class _LayoutSettingsPageState extends State<LayoutSettingsPage> {
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 页面 /// WebView 页面
class WebViewPage extends StatefulWidget { class WebViewPage extends StatefulWidget {
final String url; final String url;

View File

@@ -13,8 +13,6 @@ class StatisticsPage extends StatefulWidget {
} }
class _StatisticsPageState extends State<StatisticsPage> { class _StatisticsPageState extends State<StatisticsPage> {
int _selectedTab = 0;
bool get _showMovies => UserPrefs().showMovieTab; bool get _showMovies => UserPrefs().showMovieTab;
bool get _showBooks => UserPrefs().showBookTab; bool get _showBooks => UserPrefs().showBookTab;
bool get _showNotes => UserPrefs().showNoteTab; bool get _showNotes => UserPrefs().showNoteTab;
@@ -23,23 +21,38 @@ class _StatisticsPageState extends State<StatisticsPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: Colors.white, backgroundColor: Colors.white,
appBar: AppBar( appBar: AppBar(title: const Text('数据统计')),
title: const Text('数据统计'),
),
body: Consumer<AppProvider>( body: Consumer<AppProvider>(
builder: (context, provider, child) { builder: (context, provider, child) {
final movies = provider.movies; final movies = provider.movies.where((m) => !m.isDeleted).toList();
final books = provider.books; final books = provider.books;
final notes = provider.notes; final notes = provider.notes;
return Column( return ListView(
padding: const EdgeInsets.all(20),
children: [ children: [
_buildTabBar(), _buildTotalCards(movies, books, notes),
Expanded( const SizedBox(height: 28),
child: _selectedTab == 0 if (_showMovies) ...[
? _buildOverviewTab(movies, books, notes) _buildStatusSection('影视状态分布', movies, (m) => m.status, {'已看': 'watched', '在看': 'watching', '想看': 'want_to_watch'}),
: _buildTrendTab(movies, books, notes), 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<StatisticsPage> {
); );
} }
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) { Widget _buildTotalCards(List<Movie> movies, List<Book> books, List<Note> notes) {
final isSelected = _selectedTab == index; final items = <_CardData>[];
return GestureDetector( if (_showMovies) items.add(_CardData('影视', movies.length, Icons.movie_outlined, const Color(0xFF4A90D9)));
onTap: () => setState(() => _selectedTab = index), if (_showBooks) items.add(_CardData('书籍', books.length, Icons.menu_book_outlined, const Color(0xFF7E57C2)));
child: Container( if (_showNotes) items.add(_CardData('笔记', notes.length, Icons.note_outlined, const Color(0xFF66BB6A)));
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration( return Row(
color: isSelected ? const Color(0xFF1A1A1A) : const Color(0xFFF5F5F5), children: items.map((d) => Expanded(
borderRadius: BorderRadius.circular(8), child: Container(
), margin: EdgeInsets.only(right: d == items.last ? 0 : 10),
child: Text( padding: const EdgeInsets.symmetric(vertical: 20),
label, decoration: BoxDecoration(
textAlign: TextAlign.center, color: d.color.withValues(alpha: 0.06),
style: TextStyle( borderRadius: BorderRadius.circular(14),
fontSize: 14, ),
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, child: Column(
color: isSelected ? Colors.white : const Color(0xFF666666), 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<Movie> movies, List<Book> books, List<Note> 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; Widget _buildStatusSection(String title, List items, String Function(dynamic) getStatus, Map<String, String> labels) {
final watchingMovies = movies.where((m) => m.status == 'watching' && !m.isDeleted).length; final active = items.where((i) => !(i is Movie) || !i.isDeleted).toList();
final wantToWatchMovies = movies.where((m) => m.status == 'want_to_watch' && !m.isDeleted).length; final total = active.length;
final readBooks = books.where((b) => b.status == 'read').length; return _buildCard(
final readingBooks = books.where((b) => b.status == 'reading').length; title: title,
final wantToReadBooks = books.where((b) => b.status == 'want_to_read').length; child: Column(
children: labels.entries.map((e) {
final movieRatings = movies.where((m) => m.rating != null && !m.isDeleted).map((m) => m.rating!); final count = active.where((i) => getStatus(i) == e.value).length;
final avgMovieRating = movieRatings.isNotEmpty final pct = total > 0 ? count / total : 0.0;
? movieRatings.reduce((a, b) => a + b) / movieRatings.length return Padding(
: null; padding: const EdgeInsets.only(bottom: 14),
child: Column(
final bookRatings = books.where((b) => b.rating != null).map((b) => b.rating!); crossAxisAlignment: CrossAxisAlignment.start,
final avgBookRating = bookRatings.isNotEmpty children: [
? bookRatings.reduce((a, b) => a + b) / bookRatings.length Row(
: null; children: [
Text(e.key, style: const TextStyle(fontSize: 13, color: Color(0xFF666666))),
final children = <Widget>[]; const Spacer(),
Text('$count', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
// 数据总览 const SizedBox(width: 4),
children.add(_buildSectionTitle('数据总览')); Text('${(pct * 100).toStringAsFixed(0)}%', style: const TextStyle(fontSize: 12, color: Color(0xFFBBBBBB))),
children.add(const SizedBox(height: 16)); ],
children.add(_buildTotalOverview(totalMovies, totalBooks, totalNotes)); ),
children.add(const SizedBox(height: 32)); const SizedBox(height: 6),
ClipRRect(
// 影视状态分布 borderRadius: BorderRadius.circular(3),
if (_showMovies) { child: LinearProgressIndicator(
children.add(_buildSectionTitle('影视')); value: pct,
children.add(const SizedBox(height: 16)); backgroundColor: const Color(0xFFF0F0F0),
children.add(_buildStatusDistribution([ color: const Color(0xFF1A1A1A),
_StatusData('已看', watchedMovies, const Color(0xFF1A1A1A)), minHeight: 6,
_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 = <Widget>[];
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<Movie> movies, List<Book> books, List<Note> 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 = <Widget>[];
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 = <Widget>[];
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<int> movieTrend, List<int> bookTrend, List<int> noteTrend) {
final rows = <Widget>[];
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<int> 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),
), ),
), ),
);
}).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 = <String, int>{};
for (final item in items) {
for (final genre in (item.genres as List<String>)) {
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<Note> notes) {
final tagCounts = <String, int>{};
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<Movie> movies, List<Book> books) {
final allRatings = <double>[];
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<Movie> movies, List<Book> books, List<Note> notes) { // ─── 月度趋势 ────────────────────────────────────────────────────────
Widget _buildMonthlyTrend(List<Movie> movies, List<Book> books, List<Note> notes) {
final now = DateTime.now(); 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 counts = <String, List<int>>{};
final recentBooks = books.where((b) => b.createdAt.isAfter(sevenDaysAgo)).length; if (_showMovies) counts['影视'] = List.filled(6, 0);
final recentNotes = notes.where((n) => n.createdAt.isAfter(sevenDaysAgo)).length; if (_showBooks) counts['书籍'] = List.filled(6, 0);
if (_showNotes) counts['笔记'] = List.filled(6, 0);
final items = <Widget>[]; for (final m in movies) {
if (_showMovies) { final idx = _monthIndex(m.createdAt, now);
items.add(_buildActivityRow('新增影视', recentMovies, Icons.movie_outlined)); if (idx >= 0 && idx < 6) counts['影视']?[idx] = (counts['影视']?[idx] ?? 0) + 1;
} }
if (_showBooks) { for (final b in books) {
if (items.isNotEmpty) items.add(const Divider(height: 20, color: Color(0xFFE8E8E8))); final idx = _monthIndex(b.createdAt, now);
items.add(_buildActivityRow('新增书籍', recentBooks, Icons.menu_book_outlined)); if (idx >= 0 && idx < 6) counts['书籍']?[idx] = (counts['书籍']?[idx] ?? 0) + 1;
} }
if (_showNotes) { for (final n in notes) {
if (items.isNotEmpty) items.add(const Divider(height: 20, color: Color(0xFFE8E8E8))); final idx = _monthIndex(n.createdAt, now);
items.add(_buildActivityRow('新增笔记', recentNotes, Icons.note_outlined)); 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( return Container(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(18),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFFAFAFA), color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(14),
), ),
child: Column(children: items), child: Column(
); crossAxisAlignment: CrossAxisAlignment.start,
} children: [
Row(
Widget _buildActivityRow(String label, int count, IconData icon) { children: [
return Row( Container(width: 3, height: 14, decoration: BoxDecoration(color: const Color(0xFF1A1A1A), borderRadius: BorderRadius.circular(2))),
children: [ const SizedBox(width: 8),
Container( Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
width: 36, height: 36, ],
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
), ),
child: Icon(icon, size: 18, color: const Color(0xFF666666)), const SizedBox(height: 16),
), child,
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))),
],
); );
} }
} }
class _StatusData { class _CardData {
final String label; final String label;
final int count; final int count;
final IconData icon;
final Color color; final Color color;
_CardData(this.label, this.count, this.icon, this.color);
_StatusData(this.label, this.count, this.color);
} }

View File

@@ -4,8 +4,9 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/app_provider.dart'; import '../providers/app_provider.dart';
import '../models/data_models.dart'; import '../models/data_models.dart';
import '../widgets/animated_star_rating.dart';
/// 漫步页面 - 随机发现一条内容 /// 漫步页面 - 随机发现内容
class StrollPage extends StatefulWidget { class StrollPage extends StatefulWidget {
const StrollPage({super.key}); const StrollPage({super.key});
@@ -13,25 +14,34 @@ class StrollPage extends StatefulWidget {
State<StrollPage> createState() => _StrollPageState(); State<StrollPage> createState() => _StrollPageState();
} }
class _StrollPageState extends State<StrollPage> { class _StrollPageState extends State<StrollPage> with SingleTickerProviderStateMixin {
final _random = Random(); final _random = Random();
_StrollItem? _currentItem; _StrollItem? _currentItem;
late AnimationController _animController;
late Animation<double> _fadeAnim;
bool _isLoading = false;
@override @override
void initState() { void initState() {
super.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() { @override
final provider = context.read<AppProvider>(); void dispose() {
_animController.dispose();
super.dispose();
}
// 按类别分组 void _loadRandom() {
final provider = context.read<AppProvider>();
final movies = provider.movies.where((m) => !m.isDeleted).toList(); final movies = provider.movies.where((m) => !m.isDeleted).toList();
final books = provider.books.where((b) => !b.isDeleted).toList(); final books = provider.books.where((b) => !b.isDeleted).toList();
final notes = provider.notes.where((n) => !n.isDeleted).toList(); final notes = provider.notes.where((n) => !n.isDeleted).toList();
// 收集非空类别
final categories = <String, List<dynamic>>{}; final categories = <String, List<dynamic>>{};
if (movies.isNotEmpty) categories['movie'] = movies; if (movies.isNotEmpty) categories['movie'] = movies;
if (books.isNotEmpty) categories['book'] = books; if (books.isNotEmpty) categories['book'] = books;
@@ -42,7 +52,6 @@ class _StrollPageState extends State<StrollPage> {
return; return;
} }
// 先等概率选类别,再从该类中随机选一条
final categoryKeys = categories.keys.toList(); final categoryKeys = categories.keys.toList();
final pickedCategory = categoryKeys[_random.nextInt(categoryKeys.length)]; final pickedCategory = categoryKeys[_random.nextInt(categoryKeys.length)];
@@ -53,12 +62,14 @@ class _StrollPageState extends State<StrollPage> {
item = _StrollItem( item = _StrollItem(
type: 'movie', type: 'movie',
title: m.title, title: m.title,
subtitle: m.alternateTitles.isNotEmpty ? m.alternateTitles.first : '', subtitle: m.alternateTitles.take(2).join(' / '),
detail: _buildMovieDetail(m), detail: _movieDetail(m),
imagePath: m.posterPath, imagePath: m.posterPath,
icon: Icons.movie_outlined, icon: Icons.movie_outlined,
label: '影视', label: '影视',
rating: m.rating,
createdAt: m.createdAt, createdAt: m.createdAt,
color: const Color(0xFF4A90D9),
); );
break; break;
case 'book': case 'book':
@@ -66,25 +77,28 @@ class _StrollPageState extends State<StrollPage> {
item = _StrollItem( item = _StrollItem(
type: 'book', type: 'book',
title: b.title, title: b.title,
subtitle: b.authors.isNotEmpty ? b.authors.first : '', subtitle: b.authors.take(2).join(' / '),
detail: _buildBookDetail(b), detail: _bookDetail(b),
imagePath: b.coverPath, imagePath: b.coverPath,
icon: Icons.menu_book_outlined, icon: Icons.menu_book_outlined,
label: '书籍', label: '书籍',
rating: b.rating,
createdAt: b.createdAt, createdAt: b.createdAt,
color: const Color(0xFF7E57C2),
); );
break; break;
case 'note': case 'note':
final n = notes[_random.nextInt(notes.length)]; final n = notes[_random.nextInt(notes.length)];
item = _StrollItem( item = _StrollItem(
type: 'note', type: 'note',
title: n.title.isNotEmpty ? n.title : '无标题', title: n.title.isNotEmpty ? n.title : '随手记',
subtitle: '${n.content.length} 字 · ${n.tags.isNotEmpty ? n.tags.take(2).join(' / ') : '无标签'}', subtitle: n.tags.take(3).join(' · '),
detail: n.content, detail: n.content,
imagePath: n.images.isNotEmpty ? n.images.first : null, imagePath: n.images.isNotEmpty ? n.images.first : null,
icon: Icons.note_outlined, icon: Icons.note_outlined,
label: '笔记', label: '笔记',
createdAt: n.createdAt, createdAt: n.createdAt,
color: const Color(0xFF66BB6A),
); );
break; break;
default: default:
@@ -95,44 +109,45 @@ class _StrollPageState extends State<StrollPage> {
setState(() => _currentItem = item); 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 = <String>[]; final parts = <String>[];
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.genres.isNotEmpty) parts.add(m.genres.take(3).join(' / '));
if (m.status == 'watched') parts.add('已看'); if (m.summary != null && m.summary!.isNotEmpty) parts.add(m.summary!.length > 80 ? '${m.summary!.substring(0, 80)}...' : m.summary!);
if (m.status == 'watching') parts.add('在看'); return parts.join('\n');
return parts.join(' · ');
} }
String _buildBookDetail(Book b) { String _bookDetail(Book b) {
final parts = <String>[]; final parts = <String>[];
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.publisher != null && b.publisher!.isNotEmpty) parts.add(b.publisher!);
if (b.status == 'read') parts.add('已读'); if (b.summary != null && b.summary!.isNotEmpty) parts.add(b.summary!.length > 80 ? '${b.summary!.substring(0, 80)}...' : b.summary!);
if (b.status == 'reading') parts.add('在读'); return parts.join('\n');
return parts.join(' · ');
} }
String _getTimeAgoText(DateTime date) { String _timeAgoText(DateTime date) {
final diff = DateTime.now().difference(date); final diff = DateTime.now().difference(date);
if (diff.inDays >= 365) return '1年前'; if (diff.inDays >= 365) return '${(diff.inDays / 365).floor()}年前';
if (diff.inDays >= 180) return '6个月前'; if (diff.inDays >= 30) return '${(diff.inDays / 30).floor()}个月前';
if (diff.inDays >= 90) return '3个月'; if (diff.inDays > 0) return '${diff.inDays}';
if (diff.inDays >= 30) return '1个月'; if (diff.inHours > 0) return '${diff.inHours}小时';
return '${diff.inDays}天前'; return '刚刚';
} }
String _getActionTimeAgo(_StrollItem item) { String _actionText(_StrollItem item) {
final timeAgo = _getTimeAgoText(item.createdAt); final ago = _timeAgoText(item.createdAt);
switch (item.type) { switch (item.type) {
case 'movie': case 'movie': return '$ago 看过';
return '${timeAgo}'; case 'book': return '$ago';
case 'book': case 'note': return '$ago 写下';
return '${timeAgo}读过'; default: return ago;
case 'note':
return '${timeAgo}写下';
default:
return timeAgo;
} }
} }
@@ -146,23 +161,20 @@ class _StrollPageState extends State<StrollPage> {
Padding( Padding(
padding: const EdgeInsets.only(right: 12), padding: const EdgeInsets.only(right: 12),
child: GestureDetector( child: GestureDetector(
onTap: _refresh, onTap: _isLoading ? null : _refresh,
child: Container( child: AnimatedContainer(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFF5F5F5), color: const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(18),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
), ),
child: Row( child: const Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.refresh, size: 14, color: Color(0xFF666666)), Icon(Icons.casino_outlined, size: 14, color: Colors.white),
const SizedBox(width: 4), SizedBox(width: 5),
const Text( Text('随机', style: TextStyle(fontSize: 12, color: Colors.white, fontWeight: FontWeight.w500)),
'换一个',
style: TextStyle(fontSize: 12, color: Color(0xFF666666)),
),
], ],
), ),
), ),
@@ -171,211 +183,163 @@ class _StrollPageState extends State<StrollPage> {
], ],
), ),
body: _currentItem == null body: _currentItem == null
? const Center( ? const Center(child: Text('还没有任何内容\n去添加一些吧', textAlign: TextAlign.center,
child: Text( style: TextStyle(fontSize: 15, color: Color(0xFFBBBBBB), height: 1.6)))
'还没有任何内容\n去添加一些吧', : Consumer<AppProvider>(builder: (context, provider, _) {
textAlign: TextAlign.center, final item = _currentItem!;
style: TextStyle(fontSize: 15, color: Color(0xFFBBBBBB), height: 1.6), final hasImage = item.imagePath != null && item.imagePath!.isNotEmpty && File(item.imagePath!).existsSync();
),
)
: Consumer<AppProvider>(
builder: (context, provider, _) {
final item = _currentItem!;
// 笔记:独立卡片样式 return FadeTransition(
if (item.type == 'note') { opacity: _fadeAnim,
return _buildNoteCard(item); child: Center(
}
// 影视/书籍:海报+信息样式
final hasImage = item.imagePath != null &&
item.imagePath!.isNotEmpty &&
File(item.imagePath!).existsSync();
return Center(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.all(32), padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 20),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// 类型标签 // 类型标签
_buildTypeChip(item), _buildTypeBadge(item),
const SizedBox(height: 24),
const SizedBox(height: 28), // 封面/图片
if (item.type != 'note')
// 海报/封面/图标 _buildCoverCard(item, hasImage)
if (hasImage)
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.file(
File(item.imagePath!),
width: 200,
height: 260,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _buildPlaceholder(item),
),
)
else else
_buildPlaceholder(item), _buildNoteCard(item, hasImage),
const SizedBox(height: 28), const SizedBox(height: 24),
// 标题 // 标题(笔记卡片已包含标题和内容,不需要重复)
Text( if (item.type != 'note') ...[
item.title, Padding(
textAlign: TextAlign.center, padding: const EdgeInsets.symmetric(horizontal: 8),
style: const TextStyle( child: Text(
fontSize: 22, item.title,
fontWeight: FontWeight.w700, textAlign: TextAlign.center,
color: Color(0xFF1A1A1A), style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: Color(0xFF1A1A1A), height: 1.3),
height: 1.3, ),
), ),
),
// 副标题 if (item.subtitle.isNotEmpty) ...[
if (item.subtitle.isNotEmpty) ...[ const SizedBox(height: 8),
const SizedBox(height: 8), Padding(
Text( padding: const EdgeInsets.symmetric(horizontal: 16),
item.subtitle, child: Text(item.subtitle, textAlign: TextAlign.center,
textAlign: TextAlign.center, style: const TextStyle(fontSize: 13, color: Color(0xFF999999))),
style: const TextStyle(fontSize: 14, color: Color(0xFF999999)), ),
), ],
],
// 详情 // 评分
if (item.detail.isNotEmpty) ...[ if (item.rating != null) ...[
const SizedBox(height: 6), const SizedBox(height: 10),
Text( AnimatedStarRating(rating: item.rating!, starSize: 16, showNumber: true),
item.detail, ],
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 13, color: Color(0xFFAAAAAA)), // 详情
), 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), const SizedBox(height: 12),
Text( Text(_actionText(item), style: const TextStyle(fontSize: 12, color: Color(0xFFCCCCCC))),
_getActionTimeAgo(item),
style: const TextStyle(fontSize: 12, color: Color(0xFFCCCCCC)),
),
const SizedBox(height: 40), const SizedBox(height: 40),
], ],
), ),
), ),
); ),
}, );
), }),
); );
} }
Widget _buildTypeChip(_StrollItem item) { Widget _buildTypeBadge(_StrollItem item) {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFF5F5F5), color: item.color.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(14),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(item.icon, size: 14, color: const Color(0xFF999999)), Icon(item.icon, size: 14, color: item.color),
const SizedBox(width: 6), const SizedBox(width: 5),
Text(item.label, style: const TextStyle(fontSize: 12, color: Color(0xFF888888))), Text(item.label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: item.color)),
], ],
), ),
); );
} }
/// 笔记卡片样式 Widget _buildCoverCard(_StrollItem item, bool hasImage) {
Widget _buildNoteCard(_StrollItem item) { return Container(
return Center( width: 220,
child: SingleChildScrollView( height: 290,
padding: const EdgeInsets.all(24), decoration: BoxDecoration(
child: Column( borderRadius: BorderRadius.circular(16),
mainAxisSize: MainAxisSize.min, boxShadow: [
children: [ BoxShadow(color: item.color.withValues(alpha: 0.15), blurRadius: 20, offset: const Offset(0, 8)),
_buildTypeChip(item), ],
const SizedBox(height: 24), ),
clipBehavior: Clip.antiAlias,
child: hasImage
? Image.file(File(item.imagePath!), fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _buildPlaceholder(item))
: _buildPlaceholder(item),
);
}
// 笔记卡片 - 类似分享海报 Widget _buildNoteCard(_StrollItem item, bool hasImage) {
Container( return Container(
width: double.infinity, width: double.infinity,
constraints: const BoxConstraints(maxWidth: 340), constraints: const BoxConstraints(maxWidth: 360),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 16, offset: const Offset(0, 6)),
color: Colors.black.withOpacity(0.08), ],
blurRadius: 20, ),
offset: const Offset(0, 8), clipBehavior: Clip.antiAlias,
), child: Column(
], mainAxisSize: MainAxisSize.min,
), children: [
child: Column( if (hasImage)
mainAxisSize: MainAxisSize.min, Image.file(File(item.imagePath!), fit: BoxFit.cover, height: 200, width: double.infinity,
crossAxisAlignment: CrossAxisAlignment.start, errorBuilder: (_, __, ___) => const SizedBox.shrink()),
children: [ Padding(
// 内容 padding: const EdgeInsets.all(20),
Padding( child: Text(item.detail.isEmpty ? '(无内容)' : item.detail,
padding: const EdgeInsets.all(24), style: const TextStyle(fontSize: 14, color: Color(0xFF444444), height: 1.8)),
child: Text( ),
item.detail, const Divider(height: 1, color: Color(0xFFF0F0F0)),
style: const TextStyle( Padding(
fontSize: 15, padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
color: Color(0xFF333333), child: Text('${item.detail.length} 字 · Mooknote', style: const TextStyle(fontSize: 11, color: Color(0xFFBBBBBB))),
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 _buildPlaceholder(_StrollItem item) { Widget _buildPlaceholder(_StrollItem item) {
return Container( return Container(
width: 120, color: const Color(0xFFF8F8F8),
height: 160, child: Center(
decoration: BoxDecoration( child: Icon(item.icon, size: 48, color: item.color.withValues(alpha: 0.2)),
color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFEEEEEE), width: 0.5),
), ),
child: Icon(item.icon, size: 40, color: const Color(0xFFDDDDDD)),
); );
} }
} }
@@ -388,7 +352,9 @@ class _StrollItem {
final String? imagePath; final String? imagePath;
final IconData icon; final IconData icon;
final String label; final String label;
final double? rating;
final DateTime createdAt; final DateTime createdAt;
final Color color;
_StrollItem({ _StrollItem({
required this.type, required this.type,
@@ -398,6 +364,8 @@ class _StrollItem {
this.imagePath, this.imagePath,
required this.icon, required this.icon,
required this.label, required this.label,
this.rating,
required this.createdAt, required this.createdAt,
required this.color,
}); });
} }

View File

@@ -7,85 +7,62 @@ import '../pages/movies/movie_detail_page.dart';
import '../pages/book/book_detail_page.dart'; import '../pages/book/book_detail_page.dart';
import '../pages/note/note_detail_page.dart'; import '../pages/note/note_detail_page.dart';
import '../pages/movies/douban_webview_page.dart'; import '../pages/movies/douban_webview_page.dart';
import 'slide_up_page_route.dart';
/// 路由生成器 /// 路由生成器
class AppRouter { class AppRouter {
static Route<dynamic> generateRoute(RouteSettings settings) { static Route<dynamic> generateRoute(RouteSettings settings) {
switch (settings.name) { switch (settings.name) {
case '/movie-form': case '/movie-form':
// 处理不同参数类型Movie 对象或 Map包含 initialStatus
final args = settings.arguments; final args = settings.arguments;
Movie? movie; Movie? movie;
String? initialStatus; String? initialStatus;
if (args is Movie) { if (args is Movie) {
movie = args; movie = args;
} else if (args is Map<String, dynamic>) { } else if (args is Map<String, dynamic>) {
initialStatus = args['initialStatus'] as String?; initialStatus = args['initialStatus'] as String?;
} }
return SlideUpPageRoute(
return MaterialPageRoute( page: MovieFormPage(movie: movie, initialStatus: initialStatus),
builder: (_) => MovieFormPage(
movie: movie,
initialStatus: initialStatus,
),
); );
case '/book-form': case '/book-form':
// 处理不同参数类型Book 对象或 Map包含 initialStatus
final args = settings.arguments; final args = settings.arguments;
Book? book; Book? book;
String? initialStatus; String? initialStatus;
if (args is Book) { if (args is Book) {
book = args; book = args;
} else if (args is Map<String, dynamic>) { } else if (args is Map<String, dynamic>) {
initialStatus = args['initialStatus'] as String?; initialStatus = args['initialStatus'] as String?;
} }
return SlideUpPageRoute(
return MaterialPageRoute( page: BookFormPage(book: book, initialStatus: initialStatus),
builder: (_) => BookFormPage(
book: book,
initialStatus: initialStatus,
),
); );
case '/note-form': case '/note-form':
final note = settings.arguments as Note?; final note = settings.arguments as Note?;
return MaterialPageRoute( return SlideUpPageRoute(page: NoteFormPage(note: note));
builder: (_) => NoteFormPage(note: note),
);
case '/movie-detail': case '/movie-detail':
final movie = settings.arguments as Movie; final movie = settings.arguments as Movie;
return MaterialPageRoute( return SlideUpPageRoute(page: MovieDetailPage(movie: movie));
builder: (_) => MovieDetailPage(movie: movie),
);
case '/book-detail': case '/book-detail':
final book = settings.arguments as Book; final book = settings.arguments as Book;
return MaterialPageRoute( return SlideUpPageRoute(page: BookDetailPage(book: book));
builder: (_) => BookDetailPage(book: book),
);
case '/note-detail': case '/note-detail':
final note = settings.arguments as Note; final note = settings.arguments as Note;
return MaterialPageRoute( return SlideUpPageRoute(page: NoteDetailPage(note: note));
builder: (_) => NoteDetailPage(note: note),
);
case '/douban-webview': case '/douban-webview':
final url = settings.arguments as String; final url = settings.arguments as String;
return MaterialPageRoute( return SlideUpPageRoute(page: DoubanWebViewPage(url: url));
builder: (_) => DoubanWebViewPage(url: url),
);
default: default:
return MaterialPageRoute( return MaterialPageRoute(
builder: (_) => Scaffold( builder: (_) => Scaffold(
body: Center( body: Center(child: Text('未找到页面:${settings.name}')),
child: Text('未找到页面:${settings.name}'),
),
), ),
); );
} }

View File

@@ -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<double>(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),
);
}

View File

@@ -68,10 +68,18 @@ class UserPrefs {
int get defaultMainTabIndex => prefs.getInt('defaultMainTabIndex') ?? 0; int get defaultMainTabIndex => prefs.getInt('defaultMainTabIndex') ?? 0;
Future<bool> setDefaultMainTabIndex(int value) => prefs.setInt('defaultMainTabIndex', value); Future<bool> setDefaultMainTabIndex(int value) => prefs.setInt('defaultMainTabIndex', value);
/// 笔记布局样式 (0: 列表, 1: 瀑布流) /// 笔记布局样式 (0: 列表, 1: 瀑布流, 2: 时间线)
int get noteLayoutStyle => prefs.getInt('noteLayoutStyle') ?? 0; int get noteLayoutStyle => prefs.getInt('noteLayoutStyle') ?? 0;
Future<bool> setNoteLayoutStyle(int value) => prefs.setInt('noteLayoutStyle', value); Future<bool> setNoteLayoutStyle(int value) => prefs.setInt('noteLayoutStyle', value);
/// 影视布局样式 (0: 海报网格, 1: 列表)
int get movieLayoutStyle => prefs.getInt('movieLayoutStyle') ?? 0;
Future<bool> setMovieLayoutStyle(int value) => prefs.setInt('movieLayoutStyle', value);
/// 阅读布局样式 (0: 封面网格, 1: 列表)
int get bookLayoutStyle => prefs.getInt('bookLayoutStyle') ?? 0;
Future<bool> setBookLayoutStyle(int value) => prefs.setInt('bookLayoutStyle', value);
// ========== 应用图标设置 ========== // ========== 应用图标设置 ==========
/// Markdown 阅读器最近选择的目录 /// Markdown 阅读器最近选择的目录

View File

@@ -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<AnimatedStarRating> createState() => _AnimatedStarRatingState();
}
class _AnimatedStarRatingState extends State<AnimatedStarRating>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late List<Animation<double>> _animations;
late Animation<double> _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<double>(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),
),
);
},
),
],
],
);
}
}

View File

@@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
/// 自定义下拉刷新包装器 — 统一使用品牌色
class AppRefreshIndicator extends StatelessWidget {
final Future<void> 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,
);
}
}

View File

@@ -3,6 +3,8 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../models/data_models.dart'; import '../models/data_models.dart';
import '../providers/app_provider.dart'; import '../providers/app_provider.dart';
import '../widgets/fade_in_local_image.dart';
import '../widgets/animated_star_rating.dart';
import '../utils/toast_util.dart'; import '../utils/toast_util.dart';
/// 书籍列表项组件 - 网格布局设计 /// 书籍列表项组件 - 网格布局设计
@@ -42,9 +44,9 @@ class BookListItem extends StatelessWidget {
const SizedBox(height: 4), const SizedBox(height: 4),
// 评分 - 5星显示 // 评分
if (book.rating != null) if (book.rating != null)
_buildStarRating(book.rating!) AnimatedStarRating(rating: book.rating!, starSize: 12, showNumber: true)
else else
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
@@ -57,75 +59,19 @@ class BookListItem extends StatelessWidget {
return Container( return Container(
width: double.infinity, width: double.infinity,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
border: Border.all(color: const Color(0xFFE5E5E5), width: 0.5), border: Border.all(color: const Color(0xFFE5E5E5), width: 0.5),
), ),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: book.coverPath != null && book.coverPath!.isNotEmpty child: FadeInLocalImage(
? Image.file( path: book.coverPath,
File(book.coverPath!), fit: BoxFit.cover,
fit: BoxFit.cover, placeholder: const Center(child: Icon(Icons.menu_book_outlined, size: 32, color: Color(0xFFCCCCCC))),
errorBuilder: (_, __, ___) => _buildCoverPlaceholder(), errorWidget: const Center(child: Icon(Icons.menu_book_outlined, size: 32, color: Color(0xFFCCCCCC))),
)
: _buildCoverPlaceholder(),
);
}
Widget _buildCoverPlaceholder() {
return 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) { void _showDeleteDialog(BuildContext context) {
showDialog( showDialog(

View File

@@ -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<FadeInLocalImage> createState() => _FadeInLocalImageState();
}
class _FadeInLocalImageState extends State<FadeInLocalImage>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _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)),
),
),
);
}
}

View File

@@ -3,6 +3,8 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../models/data_models.dart'; import '../models/data_models.dart';
import '../providers/app_provider.dart'; import '../providers/app_provider.dart';
import '../widgets/fade_in_local_image.dart';
import '../widgets/animated_star_rating.dart';
import '../utils/toast_util.dart'; import '../utils/toast_util.dart';
/// 观影列表项组件 - 网格布局设计 /// 观影列表项组件 - 网格布局设计
@@ -42,9 +44,9 @@ class MovieListItem extends StatelessWidget {
const SizedBox(height: 4), const SizedBox(height: 4),
// 评分 - 5星显示 // 评分
if (movie.rating != null) if (movie.rating != null)
_buildStarRating(movie.rating!) AnimatedStarRating(rating: movie.rating!, starSize: 12, showNumber: true)
else else
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
@@ -57,75 +59,19 @@ class MovieListItem extends StatelessWidget {
return Container( return Container(
width: double.infinity, width: double.infinity,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE5E5E5), width: 0.5), border: Border.all(color: const Color(0xFFE5E5E5), width: 0.5),
), ),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: movie.posterPath != null && movie.posterPath!.isNotEmpty child: FadeInLocalImage(
? Image.file( path: movie.posterPath,
File(movie.posterPath!), fit: BoxFit.cover,
fit: BoxFit.cover, placeholder: const Center(child: Icon(Icons.movie_outlined, size: 24, color: Color(0xFFCCCCCC))),
errorBuilder: (_, __, ___) => _buildPosterPlaceholder(), errorWidget: const Center(child: Icon(Icons.movie_outlined, size: 24, color: Color(0xFFCCCCCC))),
)
: _buildPosterPlaceholder(),
);
}
Widget _buildPosterPlaceholder() {
return 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) { void _showDeleteDialog(BuildContext context) {
showDialog( showDialog(

View File

@@ -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<ShimmerSkeleton> createState() => _ShimmerSkeletonState();
}
class _ShimmerSkeletonState extends State<ShimmerSkeleton>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1000),
)..repeat(reverse: true);
_animation = Tween<double>(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),
],
),
),
);
}
}