generated from dellevin/template
动画效果美化
This commit is contained in:
@@ -143,14 +143,28 @@ class _BookDetailPageState extends State<BookDetailPage> {
|
||||
required String tooltip,
|
||||
Color backgroundColor = const Color(0xFF1A1A1A),
|
||||
}) {
|
||||
return FloatingActionButton(
|
||||
onPressed: onPressed,
|
||||
tooltip: tooltip,
|
||||
backgroundColor: backgroundColor,
|
||||
foregroundColor: Colors.white,
|
||||
mini: true,
|
||||
elevation: 4,
|
||||
child: Icon(icon, size: 20),
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: Ink(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: backgroundColor.withValues(alpha: 0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: IconButton(
|
||||
icon: Icon(icon, size: 18, color: Colors.white),
|
||||
onPressed: onPressed,
|
||||
tooltip: tooltip,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,39 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../providers/app_provider.dart';
|
||||
import '../../models/data_models.dart';
|
||||
import '../../utils/user_prefs.dart';
|
||||
import '../../widgets/book_status_bar.dart';
|
||||
import '../../widgets/book_list_item.dart';
|
||||
import '../../widgets/animated_star_rating.dart';
|
||||
import '../../widgets/shimmer_skeleton.dart';
|
||||
|
||||
/// 阅读标签页
|
||||
class BookTabPage extends StatelessWidget {
|
||||
class BookTabPage extends StatefulWidget {
|
||||
const BookTabPage({super.key});
|
||||
|
||||
@override
|
||||
State<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
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
// 状态选择栏(读完、在读、准备读)
|
||||
const BookStatusBar(),
|
||||
|
||||
// 书籍列表
|
||||
Expanded(
|
||||
child: _buildBookList(context),
|
||||
),
|
||||
@@ -24,173 +41,181 @@ class BookTabPage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建书籍列表
|
||||
Widget _buildBookList(BuildContext context) {
|
||||
return Consumer<AppProvider>(
|
||||
builder: (context, provider, child) {
|
||||
// 根据状态筛选书籍
|
||||
final statusMap = {
|
||||
0: 'read',
|
||||
1: 'reading',
|
||||
2: 'want_to_read',
|
||||
};
|
||||
final statusMap = {0: 'read', 1: 'reading', 2: 'want_to_read'};
|
||||
final currentStatus = statusMap[provider.bookStatusIndex]!;
|
||||
final books = provider.getBooksByStatus(currentStatus);
|
||||
|
||||
if (_firstLoad) {
|
||||
return _buildSkeleton();
|
||||
}
|
||||
|
||||
if (books.isEmpty) {
|
||||
return _buildEmptyState(context, provider.bookStatusIndex);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => await provider.loadBooks(),
|
||||
color: const Color(0xFF1A1A1A),
|
||||
backgroundColor: Colors.white,
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 100),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
childAspectRatio: 0.55,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 16,
|
||||
),
|
||||
itemCount: books.length,
|
||||
itemBuilder: (context, index) {
|
||||
return BookListItem(book: books[index]);
|
||||
},
|
||||
),
|
||||
);
|
||||
if (_layoutStyle == 1) {
|
||||
return _buildListView(books, provider);
|
||||
}
|
||||
return _buildGridView(books, provider);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建空状态提示
|
||||
Widget _buildGridView(List books, AppProvider provider) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => await provider.loadBooks(),
|
||||
color: const Color(0xFF1A1A1A),
|
||||
backgroundColor: Colors.white,
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 100),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
childAspectRatio: 0.55,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 16,
|
||||
),
|
||||
itemCount: books.length,
|
||||
itemBuilder: (context, index) => BookListItem(book: books[index]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildListView(List books, AppProvider provider) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => await provider.loadBooks(),
|
||||
color: const Color(0xFF1A1A1A),
|
||||
backgroundColor: Colors.white,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 100),
|
||||
itemCount: books.length,
|
||||
itemBuilder: (context, index) => _buildListCard(books[index]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildListCard(book) {
|
||||
return GestureDetector(
|
||||
onTap: () => Navigator.pushNamed(context, '/book-detail', arguments: book),
|
||||
onLongPress: () => _showDeleteDialog(context, book),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFAFAFA),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 封面缩略图
|
||||
Container(
|
||||
width: 48, height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF0F0F0),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: book.coverPath != null && book.coverPath!.isNotEmpty
|
||||
? Image.file(File(book.coverPath!), fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => const Icon(Icons.menu_book_outlined, size: 22, color: Color(0xFFCCCCCC)))
|
||||
: const Icon(Icons.menu_book_outlined, size: 22, color: Color(0xFFCCCCCC)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(book.title, maxLines: 1, overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||||
if (book.authors.isNotEmpty) ...[
|
||||
const SizedBox(height: 3),
|
||||
Text(book.authors.take(2).join('、'), maxLines: 1, overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFFAAAAAA))),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
if (book.rating != null)
|
||||
AnimatedStarRating(rating: book.rating!, starSize: 12, showNumber: true)
|
||||
else
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(Icons.chevron_right, color: Color(0xFFD0D0D0), size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showDeleteDialog(BuildContext context, book) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
title: const Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
||||
content: Text('确定要删除《${book.title}》吗?删除后可在回收站恢复。',
|
||||
style: const TextStyle(fontSize: 14, color: Color(0xFF666666), height: 1.5)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('取消', style: TextStyle(color: Color(0xFF666666))),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
await context.read<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) {
|
||||
final statusText = ['已读', '在读', '想读'][statusIndex];
|
||||
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F5F5),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.menu_book_outlined,
|
||||
size: 40,
|
||||
color: Color(0xFFCCCCCC),
|
||||
),
|
||||
width: 80, height: 80,
|
||||
decoration: BoxDecoration(color: const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(20)),
|
||||
child: const Icon(Icons.menu_book_outlined, size: 40, color: Color(0xFFCCCCCC)),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'暂无$statusText的书籍',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
Text('暂无$statusText的书籍', style: const TextStyle(fontSize: 16, color: Color(0xFF999999))),
|
||||
const SizedBox(height: 24),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
final statusMap = {
|
||||
0: 'read',
|
||||
1: 'reading',
|
||||
2: 'want_to_read',
|
||||
};
|
||||
final currentStatus = statusMap[statusIndex]!;
|
||||
Navigator.pushNamed(
|
||||
context,
|
||||
'/book-form',
|
||||
arguments: {'initialStatus': currentStatus},
|
||||
);
|
||||
final statusMap = {0: 'read', 1: 'reading', 2: 'want_to_read'};
|
||||
Navigator.pushNamed(context, '/book-form', arguments: {'initialStatus': statusMap[statusIndex]!});
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Text(
|
||||
'添加记录',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
decoration: BoxDecoration(color: const Color(0xFF1A1A1A), borderRadius: BorderRadius.circular(8)),
|
||||
child: const Text('添加记录', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Colors.white)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取示例书籍数据
|
||||
List<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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,14 +143,28 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
||||
required String tooltip,
|
||||
Color backgroundColor = const Color(0xFF1A1A1A),
|
||||
}) {
|
||||
return FloatingActionButton(
|
||||
onPressed: onPressed,
|
||||
tooltip: tooltip,
|
||||
backgroundColor: backgroundColor,
|
||||
foregroundColor: Colors.white,
|
||||
mini: true,
|
||||
elevation: 4,
|
||||
child: Icon(icon, size: 20),
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: Ink(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: backgroundColor.withValues(alpha: 0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: IconButton(
|
||||
icon: Icon(icon, size: 18, color: Colors.white),
|
||||
onPressed: onPressed,
|
||||
tooltip: tooltip,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,40 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../providers/app_provider.dart';
|
||||
import '../../utils/user_prefs.dart';
|
||||
import '../../widgets/movie_status_bar.dart';
|
||||
import '../../widgets/movie_list_item.dart';
|
||||
import '../../widgets/animated_star_rating.dart';
|
||||
import '../../widgets/shimmer_skeleton.dart';
|
||||
|
||||
/// 观影标签页 - 极简主义设计
|
||||
class MovieTabPage extends StatelessWidget {
|
||||
/// 观影标签页
|
||||
class MovieTabPage extends StatefulWidget {
|
||||
const MovieTabPage({super.key});
|
||||
|
||||
@override
|
||||
State<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
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
// 状态选择栏
|
||||
const MovieStatusBar(),
|
||||
|
||||
const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)),
|
||||
|
||||
// 影片列表
|
||||
Expanded(
|
||||
child: _buildMovieList(context),
|
||||
),
|
||||
@@ -25,102 +42,213 @@ class MovieTabPage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建影片列表
|
||||
Widget _buildMovieList(BuildContext context) {
|
||||
return Consumer<AppProvider>(
|
||||
builder: (context, provider, child) {
|
||||
final statusMap = {
|
||||
0: 'watched',
|
||||
1: 'watching',
|
||||
2: 'want_to_watch',
|
||||
};
|
||||
final statusMap = {0: 'watched', 1: 'watching', 2: 'want_to_watch'};
|
||||
final currentStatus = statusMap[provider.movieStatusIndex]!;
|
||||
final allMovies = provider.movies.where((m) => !m.isDeleted).toList();
|
||||
// 首次加载且数据为空时才显示骨架屏
|
||||
if (_firstLoad && allMovies.isEmpty) {
|
||||
return _buildSkeleton();
|
||||
}
|
||||
_firstLoad = false;
|
||||
|
||||
final movies = provider.getMoviesByStatus(currentStatus);
|
||||
|
||||
if (movies.isEmpty) {
|
||||
return _buildEmptyState(context, provider.movieStatusIndex);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => await provider.loadMovies(),
|
||||
color: const Color(0xFF1A1A1A),
|
||||
backgroundColor: Colors.white,
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 100),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
childAspectRatio: 0.55,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 16,
|
||||
),
|
||||
itemCount: movies.length,
|
||||
itemBuilder: (context, index) {
|
||||
return MovieListItem(movie: movies[index]);
|
||||
},
|
||||
),
|
||||
);
|
||||
if (_layoutStyle == 1) {
|
||||
return _buildListView(movies, provider);
|
||||
}
|
||||
return _buildGridView(movies, provider);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建空状态
|
||||
Widget _buildGridView(List movies, AppProvider provider) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => await provider.loadMovies(),
|
||||
color: const Color(0xFF1A1A1A),
|
||||
backgroundColor: Colors.white,
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 100),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
childAspectRatio: 0.55,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 16,
|
||||
),
|
||||
itemCount: movies.length,
|
||||
itemBuilder: (context, index) => MovieListItem(movie: movies[index]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildListView(List movies, AppProvider provider) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => await provider.loadMovies(),
|
||||
color: const Color(0xFF1A1A1A),
|
||||
backgroundColor: Colors.white,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 100),
|
||||
itemCount: movies.length,
|
||||
itemBuilder: (context, index) => _buildListCard(movies[index]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildListCard(movie) {
|
||||
return GestureDetector(
|
||||
onTap: () => Navigator.pushNamed(context, '/movie-detail', arguments: movie),
|
||||
onLongPress: () => _showDeleteDialog(context, movie),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFAFAFA),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 海报缩略图
|
||||
Container(
|
||||
width: 48, height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF0F0F0),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: movie.posterPath != null && movie.posterPath!.isNotEmpty
|
||||
? Image.file(File(movie.posterPath!), fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => const Icon(Icons.movie_outlined, size: 22, color: Color(0xFFCCCCCC)))
|
||||
: const Icon(Icons.movie_outlined, size: 22, color: Color(0xFFCCCCCC)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(movie.title, maxLines: 1, overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||||
if (movie.alternateTitles.isNotEmpty) ...[
|
||||
const SizedBox(height: 3),
|
||||
Text(movie.alternateTitles.take(2).join('、'), maxLines: 1, overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFFAAAAAA))),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
if (movie.rating != null)
|
||||
AnimatedStarRating(rating: movie.rating!, starSize: 12, showNumber: true)
|
||||
else
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(Icons.chevron_right, color: Color(0xFFD0D0D0), size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showDeleteDialog(BuildContext context, movie) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
title: const Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
||||
content: Text('确定要删除《${movie.title}》吗?删除后可在回收站恢复。',
|
||||
style: const TextStyle(fontSize: 14, color: Color(0xFF666666), height: 1.5)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('取消', style: TextStyle(color: Color(0xFF666666))),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
await context.read<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) {
|
||||
final statusText = ['已看', '在看', '想看'][statusIndex];
|
||||
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F5F5),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.movie_outlined,
|
||||
size: 40,
|
||||
color: Color(0xFFCCCCCC),
|
||||
),
|
||||
width: 80, height: 80,
|
||||
decoration: BoxDecoration(color: const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(20)),
|
||||
child: const Icon(Icons.movie_outlined, size: 40, color: Color(0xFFCCCCCC)),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'暂无$statusText的影片',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
Text('暂无$statusText的影片', style: const TextStyle(fontSize: 16, color: Color(0xFF999999))),
|
||||
const SizedBox(height: 24),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
final statusMap = {
|
||||
0: 'watched',
|
||||
1: 'watching',
|
||||
2: 'want_to_watch',
|
||||
};
|
||||
final currentStatus = statusMap[statusIndex]!;
|
||||
Navigator.pushNamed(
|
||||
context,
|
||||
'/movie-form',
|
||||
arguments: {'initialStatus': currentStatus},
|
||||
);
|
||||
final statusMap = {0: 'watched', 1: 'watching', 2: 'want_to_watch'};
|
||||
Navigator.pushNamed(context, '/movie-form', arguments: {'initialStatus': statusMap[statusIndex]!});
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Text(
|
||||
'添加记录',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
decoration: BoxDecoration(color: const Color(0xFF1A1A1A), borderRadius: BorderRadius.circular(8)),
|
||||
child: const Text('添加记录', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Colors.white)),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../../providers/app_provider.dart';
|
||||
import '../../models/data_models.dart';
|
||||
import '../../utils/user_prefs.dart';
|
||||
import '../../widgets/note_list_item.dart';
|
||||
import '../../widgets/shimmer_skeleton.dart';
|
||||
|
||||
/// 笔记标签页
|
||||
class NoteTabPage extends StatefulWidget {
|
||||
@@ -21,7 +22,8 @@ class _NoteTabPageState extends State<NoteTabPage> {
|
||||
bool _hasMore = true;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
int _layoutStyle = 0; // 0: 列表, 1: 瀑布流
|
||||
int _layoutStyle = 0; // 0: 列表, 1: 瀑布流, 2: 时间线
|
||||
bool _firstLoad = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -30,6 +32,7 @@ class _NoteTabPageState extends State<NoteTabPage> {
|
||||
_layoutStyle = UserPrefs().noteLayoutStyle;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_loadMoreNotes();
|
||||
if (mounted) setState(() => _firstLoad = false);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -113,6 +116,10 @@ class _NoteTabPageState extends State<NoteTabPage> {
|
||||
final allNotes = provider.notes;
|
||||
_syncDisplayedNotes(allNotes);
|
||||
|
||||
if (_firstLoad) {
|
||||
return _buildSkeleton();
|
||||
}
|
||||
|
||||
if (allNotes.isEmpty && _displayedNotes.isEmpty) {
|
||||
return _buildEmptyState(context);
|
||||
}
|
||||
@@ -120,6 +127,9 @@ class _NoteTabPageState extends State<NoteTabPage> {
|
||||
if (_layoutStyle == 1) {
|
||||
return _buildWaterfallView();
|
||||
}
|
||||
if (_layoutStyle == 2) {
|
||||
return _buildTimelineView();
|
||||
}
|
||||
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() {
|
||||
@@ -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() {
|
||||
|
||||
@@ -610,13 +610,11 @@ class SettingsPage extends StatefulWidget {
|
||||
class _SettingsPageState extends State<SettingsPage> {
|
||||
final UserPrefs _userPrefs = UserPrefs();
|
||||
bool _hideBottomNavOnScroll = true;
|
||||
int _noteLayoutStyle = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_hideBottomNavOnScroll = _userPrefs.hideBottomNavOnScroll;
|
||||
_noteLayoutStyle = _userPrefs.noteLayoutStyle;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -664,8 +662,8 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
const Divider(height: 0.5, indent: 24, endIndent: 24),
|
||||
_buildNavigationItem(
|
||||
icon: Icons.view_list_outlined,
|
||||
title: '主界面功能显示',
|
||||
subtitle: '控制观影、阅读、笔记的显示',
|
||||
title: '主界面设置',
|
||||
subtitle: '启动标签、模块显示开关',
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
@@ -676,13 +674,22 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
},
|
||||
),
|
||||
const Divider(height: 0.5, indent: 24, endIndent: 24),
|
||||
_buildSwitchItem(
|
||||
icon: Icons.grid_view_outlined,
|
||||
title: '笔记瀑布流布局',
|
||||
subtitle: '使用双列瀑布流样式展示笔记',
|
||||
value: _noteLayoutStyle == 1,
|
||||
onChanged: _toggleNoteLayoutStyle,
|
||||
|
||||
// 布局设置
|
||||
_buildSectionHeader('布局设置'),
|
||||
_buildNavigationItem(
|
||||
icon: Icons.dashboard_outlined,
|
||||
title: '布局设置',
|
||||
subtitle: '笔记、影视、阅读的展示样式',
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const LayoutSettingsPage()),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Divider(height: 0.5, indent: 24, endIndent: 24),
|
||||
|
||||
// 使用说明
|
||||
_buildSectionHeader('帮助'),
|
||||
_buildLinkItem(
|
||||
@@ -714,12 +721,6 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
setState(() => _hideBottomNavOnScroll = value);
|
||||
}
|
||||
|
||||
Future<void> _toggleNoteLayoutStyle(bool value) async {
|
||||
final v = value ? 1 : 0;
|
||||
await _userPrefs.setNoteLayoutStyle(v);
|
||||
setState(() => _noteLayoutStyle = v);
|
||||
}
|
||||
|
||||
/// 构建开关项
|
||||
Widget _buildSwitchItem({
|
||||
required IconData icon,
|
||||
@@ -1223,23 +1224,17 @@ class _MainContentSettingsPageState extends State<MainContentSettingsPage> {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(
|
||||
title: const Text('主界面功能显示'),
|
||||
title: const Text('主界面设置'),
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
// 说明文字
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: const Text(
|
||||
'选择要在主界面显示的功能模块,至少保留一个。',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF666666),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)),
|
||||
// 观影开关
|
||||
// 启动设置
|
||||
_buildSectionHeader('启动设置'),
|
||||
_buildDefaultTabSelector(),
|
||||
const Divider(height: 0.5, indent: 24, endIndent: 24),
|
||||
|
||||
// 模块开关
|
||||
_buildSectionHeader('模块开关'),
|
||||
_buildSwitchItem(
|
||||
icon: Icons.movie_outlined,
|
||||
title: '观影',
|
||||
@@ -1248,7 +1243,6 @@ class _MainContentSettingsPageState extends State<MainContentSettingsPage> {
|
||||
onChanged: _toggleMovieTab,
|
||||
),
|
||||
const Divider(height: 0.5, indent: 24, endIndent: 24),
|
||||
// 阅读开关
|
||||
_buildSwitchItem(
|
||||
icon: Icons.menu_book_outlined,
|
||||
title: '阅读',
|
||||
@@ -1257,7 +1251,6 @@ class _MainContentSettingsPageState extends State<MainContentSettingsPage> {
|
||||
onChanged: _toggleBookTab,
|
||||
),
|
||||
const Divider(height: 0.5, indent: 24, endIndent: 24),
|
||||
// 笔记开关
|
||||
_buildSwitchItem(
|
||||
icon: Icons.note_outlined,
|
||||
title: '笔记',
|
||||
@@ -1266,95 +1259,118 @@ class _MainContentSettingsPageState extends State<MainContentSettingsPage> {
|
||||
onChanged: _toggleNoteTab,
|
||||
),
|
||||
const Divider(height: 0.5, indent: 24, endIndent: 24),
|
||||
|
||||
// 默认启动标签
|
||||
_buildDefaultTabSelector(),
|
||||
const Divider(height: 0.5, indent: 24, endIndent: 24),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10),
|
||||
child: const Text(
|
||||
'至少保留一个模块,关闭后对应标签页将不再显示。',
|
||||
style: TextStyle(fontSize: 12, color: Color(0xFFBBBBBB)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建区块标题
|
||||
Widget _buildSectionHeader(String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 28, 24, 12),
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF1A1A1A),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建默认启动标签选择器
|
||||
Widget _buildDefaultTabSelector() {
|
||||
final options = [
|
||||
{'label': '影视', 'icon': Icons.movie_outlined, 'value': 0},
|
||||
{'label': '阅读', 'icon': Icons.menu_book_outlined, 'value': 1},
|
||||
{'label': '笔记', 'icon': Icons.note_outlined, 'value': 2},
|
||||
];
|
||||
final labels = ['影视', '阅读', '笔记'];
|
||||
final icons = [Icons.movie_outlined, Icons.menu_book_outlined, Icons.note_outlined];
|
||||
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||
leading: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F5F5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.home_outlined,
|
||||
color: Color(0xFF666666),
|
||||
size: 24,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(Icons.home_outlined, color: Color(0xFF666666), size: 22),
|
||||
),
|
||||
title: const Text(
|
||||
'默认启动标签',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF1A1A1A),
|
||||
),
|
||||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: Color(0xFF1A1A1A)),
|
||||
),
|
||||
subtitle: const Text(
|
||||
'打开应用时默认显示的页面',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
style: TextStyle(fontSize: 12, color: Color(0xFF999999)),
|
||||
),
|
||||
trailing: SizedBox(
|
||||
width: 80,
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<int>(
|
||||
value: _defaultTabIndex,
|
||||
isDense: true,
|
||||
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);
|
||||
}
|
||||
},
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
labels[_defaultTabIndex],
|
||||
style: const TextStyle(fontSize: 14, color: Color(0xFF999999)),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.chevron_right, color: Color(0xFFCCCCCC), size: 20),
|
||||
],
|
||||
),
|
||||
onTap: () => _showDefaultTabPicker(labels, icons),
|
||||
);
|
||||
}
|
||||
|
||||
void _showDefaultTabPicker(List<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 页面
|
||||
class WebViewPage extends StatefulWidget {
|
||||
final String url;
|
||||
|
||||
@@ -13,8 +13,6 @@ class StatisticsPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _StatisticsPageState extends State<StatisticsPage> {
|
||||
int _selectedTab = 0;
|
||||
|
||||
bool get _showMovies => UserPrefs().showMovieTab;
|
||||
bool get _showBooks => UserPrefs().showBookTab;
|
||||
bool get _showNotes => UserPrefs().showNoteTab;
|
||||
@@ -23,23 +21,38 @@ class _StatisticsPageState extends State<StatisticsPage> {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(
|
||||
title: const Text('数据统计'),
|
||||
),
|
||||
appBar: AppBar(title: const Text('数据统计')),
|
||||
body: Consumer<AppProvider>(
|
||||
builder: (context, provider, child) {
|
||||
final movies = provider.movies;
|
||||
final movies = provider.movies.where((m) => !m.isDeleted).toList();
|
||||
final books = provider.books;
|
||||
final notes = provider.notes;
|
||||
|
||||
return Column(
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
_buildTabBar(),
|
||||
Expanded(
|
||||
child: _selectedTab == 0
|
||||
? _buildOverviewTab(movies, books, notes)
|
||||
: _buildTrendTab(movies, books, notes),
|
||||
),
|
||||
_buildTotalCards(movies, books, notes),
|
||||
const SizedBox(height: 28),
|
||||
if (_showMovies) ...[
|
||||
_buildStatusSection('影视状态分布', movies, (m) => m.status, {'已看': 'watched', '在看': 'watching', '想看': 'want_to_watch'}),
|
||||
const SizedBox(height: 28),
|
||||
_buildGenreDistribution('影视类型分布', movies),
|
||||
const SizedBox(height: 28),
|
||||
],
|
||||
if (_showBooks) ...[
|
||||
_buildStatusSection('阅读状态分布', books, (b) => b.status, {'已读': 'read', '在读': 'reading', '想读': 'want_to_read'}),
|
||||
const SizedBox(height: 28),
|
||||
_buildGenreDistribution('书籍类型分布', books),
|
||||
const SizedBox(height: 28),
|
||||
],
|
||||
if (_showNotes) ...[
|
||||
_buildNoteTagDistribution('笔记标签分布', notes),
|
||||
const SizedBox(height: 28),
|
||||
],
|
||||
_buildRatingDistribution('评分分布', movies, books),
|
||||
const SizedBox(height: 28),
|
||||
_buildMonthlyTrend(movies, books, notes),
|
||||
const SizedBox(height: 80),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -47,384 +60,368 @@ class _StatisticsPageState extends State<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) {
|
||||
final isSelected = _selectedTab == index;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _selectedTab = index),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? const Color(0xFF1A1A1A) : const Color(0xFFF5F5F5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
color: isSelected ? Colors.white : const Color(0xFF666666),
|
||||
Widget _buildTotalCards(List<Movie> movies, List<Book> books, List<Note> notes) {
|
||||
final items = <_CardData>[];
|
||||
if (_showMovies) items.add(_CardData('影视', movies.length, Icons.movie_outlined, const Color(0xFF4A90D9)));
|
||||
if (_showBooks) items.add(_CardData('书籍', books.length, Icons.menu_book_outlined, const Color(0xFF7E57C2)));
|
||||
if (_showNotes) items.add(_CardData('笔记', notes.length, Icons.note_outlined, const Color(0xFF66BB6A)));
|
||||
|
||||
return Row(
|
||||
children: items.map((d) => Expanded(
|
||||
child: Container(
|
||||
margin: EdgeInsets.only(right: d == items.last ? 0 : 10),
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: d.color.withValues(alpha: 0.06),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(d.icon, size: 26, color: d.color),
|
||||
const SizedBox(height: 10),
|
||||
Text('${d.count}', style: TextStyle(fontSize: 26, fontWeight: FontWeight.w700, color: d.color)),
|
||||
const SizedBox(height: 2),
|
||||
Text(d.label, style: const TextStyle(fontSize: 12, color: Color(0xFF888888))),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOverviewTab(List<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;
|
||||
final watchingMovies = movies.where((m) => m.status == 'watching' && !m.isDeleted).length;
|
||||
final wantToWatchMovies = movies.where((m) => m.status == 'want_to_watch' && !m.isDeleted).length;
|
||||
Widget _buildStatusSection(String title, List items, String Function(dynamic) getStatus, Map<String, String> labels) {
|
||||
final active = items.where((i) => !(i is Movie) || !i.isDeleted).toList();
|
||||
final total = active.length;
|
||||
|
||||
final readBooks = books.where((b) => b.status == 'read').length;
|
||||
final readingBooks = books.where((b) => b.status == 'reading').length;
|
||||
final wantToReadBooks = books.where((b) => b.status == 'want_to_read').length;
|
||||
|
||||
final movieRatings = movies.where((m) => m.rating != null && !m.isDeleted).map((m) => m.rating!);
|
||||
final avgMovieRating = movieRatings.isNotEmpty
|
||||
? movieRatings.reduce((a, b) => a + b) / movieRatings.length
|
||||
: null;
|
||||
|
||||
final bookRatings = books.where((b) => b.rating != null).map((b) => b.rating!);
|
||||
final avgBookRating = bookRatings.isNotEmpty
|
||||
? bookRatings.reduce((a, b) => a + b) / bookRatings.length
|
||||
: null;
|
||||
|
||||
final children = <Widget>[];
|
||||
|
||||
// 数据总览
|
||||
children.add(_buildSectionTitle('数据总览'));
|
||||
children.add(const SizedBox(height: 16));
|
||||
children.add(_buildTotalOverview(totalMovies, totalBooks, totalNotes));
|
||||
children.add(const SizedBox(height: 32));
|
||||
|
||||
// 影视状态分布
|
||||
if (_showMovies) {
|
||||
children.add(_buildSectionTitle('影视'));
|
||||
children.add(const SizedBox(height: 16));
|
||||
children.add(_buildStatusDistribution([
|
||||
_StatusData('已看', watchedMovies, const Color(0xFF1A1A1A)),
|
||||
_StatusData('在看', watchingMovies, const Color(0xFF666666)),
|
||||
_StatusData('想看', wantToWatchMovies, const Color(0xFF999999)),
|
||||
], avgRating: avgMovieRating));
|
||||
children.add(const SizedBox(height: 32));
|
||||
}
|
||||
|
||||
// 书籍状态分布
|
||||
if (_showBooks) {
|
||||
children.add(_buildSectionTitle('书籍'));
|
||||
children.add(const SizedBox(height: 16));
|
||||
children.add(_buildStatusDistribution([
|
||||
_StatusData('已读', readBooks, const Color(0xFF1A1A1A)),
|
||||
_StatusData('在读', readingBooks, const Color(0xFF666666)),
|
||||
_StatusData('想读', wantToReadBooks, const Color(0xFF999999)),
|
||||
], avgRating: avgBookRating));
|
||||
children.add(const SizedBox(height: 32));
|
||||
}
|
||||
|
||||
// 最近活动
|
||||
children.add(_buildSectionTitle('最近7天活动'));
|
||||
children.add(const SizedBox(height: 16));
|
||||
children.add(_buildRecentActivity(movies, books, notes));
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTotalOverview(int totalMovies, int totalBooks, int totalNotes) {
|
||||
final items = <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),
|
||||
return _buildCard(
|
||||
title: title,
|
||||
child: Column(
|
||||
children: labels.entries.map((e) {
|
||||
final count = active.where((i) => getStatus(i) == e.value).length;
|
||||
final pct = total > 0 ? count / total : 0.0;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(e.key, style: const TextStyle(fontSize: 13, color: Color(0xFF666666))),
|
||||
const Spacer(),
|
||||
Text('$count', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||||
const SizedBox(width: 4),
|
||||
Text('${(pct * 100).toStringAsFixed(0)}%', style: const TextStyle(fontSize: 12, color: Color(0xFFBBBBBB))),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
child: LinearProgressIndicator(
|
||||
value: pct,
|
||||
backgroundColor: const Color(0xFFF0F0F0),
|
||||
color: const Color(0xFF1A1A1A),
|
||||
minHeight: 6,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatItem(String title, int count, IconData icon) {
|
||||
return Column(
|
||||
children: [
|
||||
Icon(icon, size: 24, color: const Color(0xFF666666)),
|
||||
const SizedBox(height: 8),
|
||||
Text(count.toString(), style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w700, color: Color(0xFF1A1A1A))),
|
||||
const SizedBox(height: 4),
|
||||
Text(title, style: const TextStyle(fontSize: 13, color: Color(0xFF666666))),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusDistribution(List<_StatusData> data, {double? avgRating}) {
|
||||
final total = data.fold(0, (sum, item) => sum + item.count);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFAFAFA),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
...data.map((item) {
|
||||
final percentage = total > 0 ? (item.count / total * 100).toStringAsFixed(1) : '0';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 10, height: 10,
|
||||
decoration: BoxDecoration(color: item.color, borderRadius: BorderRadius.circular(2)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(item.label, style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A))),
|
||||
const Spacer(),
|
||||
Text('${item.count}', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||||
const SizedBox(width: 8),
|
||||
Text('($percentage%)', style: const TextStyle(fontSize: 13, color: Color(0xFF999999))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
if (avgRating != null) ...[
|
||||
const Divider(height: 1, color: Color(0xFFE8E8E8)),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.star, size: 16, color: Color(0xFF999999)),
|
||||
const SizedBox(width: 10),
|
||||
const Text('平均评分', style: TextStyle(fontSize: 14, color: Color(0xFF1A1A1A))),
|
||||
const Spacer(),
|
||||
Text(avgRating.toStringAsFixed(1), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 类型/标签分布 ───────────────────────────────────────────────────
|
||||
|
||||
Widget _buildGenreDistribution(String title, List items) {
|
||||
final genreCounts = <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 sevenDaysAgo = now.subtract(const Duration(days: 7));
|
||||
final months = List.generate(6, (i) {
|
||||
final d = DateTime(now.year, now.month - (5 - i), 1);
|
||||
return '${d.month}月';
|
||||
});
|
||||
|
||||
final recentMovies = movies.where((m) => m.createdAt.isAfter(sevenDaysAgo) && !m.isDeleted).length;
|
||||
final recentBooks = books.where((b) => b.createdAt.isAfter(sevenDaysAgo)).length;
|
||||
final recentNotes = notes.where((n) => n.createdAt.isAfter(sevenDaysAgo)).length;
|
||||
final counts = <String, List<int>>{};
|
||||
if (_showMovies) counts['影视'] = List.filled(6, 0);
|
||||
if (_showBooks) counts['书籍'] = List.filled(6, 0);
|
||||
if (_showNotes) counts['笔记'] = List.filled(6, 0);
|
||||
|
||||
final items = <Widget>[];
|
||||
if (_showMovies) {
|
||||
items.add(_buildActivityRow('新增影视', recentMovies, Icons.movie_outlined));
|
||||
for (final m in movies) {
|
||||
final idx = _monthIndex(m.createdAt, now);
|
||||
if (idx >= 0 && idx < 6) counts['影视']?[idx] = (counts['影视']?[idx] ?? 0) + 1;
|
||||
}
|
||||
if (_showBooks) {
|
||||
if (items.isNotEmpty) items.add(const Divider(height: 20, color: Color(0xFFE8E8E8)));
|
||||
items.add(_buildActivityRow('新增书籍', recentBooks, Icons.menu_book_outlined));
|
||||
for (final b in books) {
|
||||
final idx = _monthIndex(b.createdAt, now);
|
||||
if (idx >= 0 && idx < 6) counts['书籍']?[idx] = (counts['书籍']?[idx] ?? 0) + 1;
|
||||
}
|
||||
if (_showNotes) {
|
||||
if (items.isNotEmpty) items.add(const Divider(height: 20, color: Color(0xFFE8E8E8)));
|
||||
items.add(_buildActivityRow('新增笔记', recentNotes, Icons.note_outlined));
|
||||
for (final n in notes) {
|
||||
final idx = _monthIndex(n.createdAt, now);
|
||||
if (idx >= 0 && idx < 6) counts['笔记']?[idx] = (counts['笔记']?[idx] ?? 0) + 1;
|
||||
}
|
||||
|
||||
final allValues = counts.values.expand((l) => l);
|
||||
final maxVal = allValues.isEmpty ? 1 : allValues.reduce((a, b) => a > b ? a : b);
|
||||
final safeMax = maxVal == 0 ? 1 : maxVal;
|
||||
|
||||
final colors = { '影视': const Color(0xFF4A90D9), '书籍': const Color(0xFF7E57C2), '笔记': const Color(0xFF66BB6A) };
|
||||
|
||||
return _buildCard(
|
||||
title: '近6月趋势',
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 120,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: List.generate(6, (i) => Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
...counts.entries.map((e) {
|
||||
final h = (e.value[i] / safeMax * 80).clamp(0, 80).toDouble();
|
||||
return Container(
|
||||
height: h < 2 && e.value[i] > 0 ? 2 : h,
|
||||
margin: const EdgeInsets.only(top: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: colors[e.key]!.withValues(alpha: 0.7),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Divider(height: 1, color: Color(0xFFF0F0F0)),
|
||||
const SizedBox(height: 8),
|
||||
// 月份标签
|
||||
Row(
|
||||
children: months.map((m) => Expanded(
|
||||
child: Text(m, textAlign: TextAlign.center, style: const TextStyle(fontSize: 11, color: Color(0xFFBBBBBB))),
|
||||
)).toList(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 图例
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: counts.keys.map((k) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(width: 8, height: 8, decoration: BoxDecoration(color: colors[k], borderRadius: BorderRadius.circular(2))),
|
||||
const SizedBox(width: 4),
|
||||
Text(k, style: const TextStyle(fontSize: 12, color: Color(0xFF888888))),
|
||||
],
|
||||
),
|
||||
)).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
int _monthIndex(DateTime date, DateTime now) {
|
||||
final diff = (now.year - date.year) * 12 + (now.month - date.month);
|
||||
return 5 - diff; // index 0..5 where 0=5 months ago, 5=current month
|
||||
}
|
||||
|
||||
// ─── 通用卡片 ────────────────────────────────────────────────────────
|
||||
|
||||
Widget _buildCard({required String title, required Widget child}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFAFAFA),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Column(children: items),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActivityRow(String label, int count, IconData icon) {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36, height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(width: 3, height: 14, decoration: BoxDecoration(color: const Color(0xFF1A1A1A), borderRadius: BorderRadius.circular(2))),
|
||||
const SizedBox(width: 8),
|
||||
Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||||
],
|
||||
),
|
||||
child: Icon(icon, size: 18, color: const Color(0xFF666666)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(label, style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A))),
|
||||
const Spacer(),
|
||||
Text('$count', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||||
const SizedBox(width: 4),
|
||||
const Text('个', style: TextStyle(fontSize: 13, color: Color(0xFF999999))),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionTitle(String title) {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 4, height: 16,
|
||||
decoration: BoxDecoration(color: const Color(0xFF1A1A1A), borderRadius: BorderRadius.circular(2)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusData {
|
||||
class _CardData {
|
||||
final String label;
|
||||
final int count;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
_StatusData(this.label, this.count, this.color);
|
||||
_CardData(this.label, this.count, this.icon, this.color);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/app_provider.dart';
|
||||
import '../models/data_models.dart';
|
||||
import '../widgets/animated_star_rating.dart';
|
||||
|
||||
/// 漫步页面 - 随机发现一条内容
|
||||
/// 漫步页面 - 随机发现内容
|
||||
class StrollPage extends StatefulWidget {
|
||||
const StrollPage({super.key});
|
||||
|
||||
@@ -13,25 +14,34 @@ class StrollPage extends StatefulWidget {
|
||||
State<StrollPage> createState() => _StrollPageState();
|
||||
}
|
||||
|
||||
class _StrollPageState extends State<StrollPage> {
|
||||
class _StrollPageState extends State<StrollPage> with SingleTickerProviderStateMixin {
|
||||
final _random = Random();
|
||||
_StrollItem? _currentItem;
|
||||
late AnimationController _animController;
|
||||
late Animation<double> _fadeAnim;
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_refresh();
|
||||
_animController = AnimationController(vsync: this, duration: const Duration(milliseconds: 500));
|
||||
_fadeAnim = CurvedAnimation(parent: _animController, curve: Curves.easeIn);
|
||||
_animController.value = 1.0;
|
||||
_loadRandom();
|
||||
}
|
||||
|
||||
void _refresh() {
|
||||
final provider = context.read<AppProvider>();
|
||||
@override
|
||||
void dispose() {
|
||||
_animController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// 按类别分组
|
||||
void _loadRandom() {
|
||||
final provider = context.read<AppProvider>();
|
||||
final movies = provider.movies.where((m) => !m.isDeleted).toList();
|
||||
final books = provider.books.where((b) => !b.isDeleted).toList();
|
||||
final notes = provider.notes.where((n) => !n.isDeleted).toList();
|
||||
|
||||
// 收集非空类别
|
||||
final categories = <String, List<dynamic>>{};
|
||||
if (movies.isNotEmpty) categories['movie'] = movies;
|
||||
if (books.isNotEmpty) categories['book'] = books;
|
||||
@@ -42,7 +52,6 @@ class _StrollPageState extends State<StrollPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
// 先等概率选类别,再从该类中随机选一条
|
||||
final categoryKeys = categories.keys.toList();
|
||||
final pickedCategory = categoryKeys[_random.nextInt(categoryKeys.length)];
|
||||
|
||||
@@ -53,12 +62,14 @@ class _StrollPageState extends State<StrollPage> {
|
||||
item = _StrollItem(
|
||||
type: 'movie',
|
||||
title: m.title,
|
||||
subtitle: m.alternateTitles.isNotEmpty ? m.alternateTitles.first : '',
|
||||
detail: _buildMovieDetail(m),
|
||||
subtitle: m.alternateTitles.take(2).join(' / '),
|
||||
detail: _movieDetail(m),
|
||||
imagePath: m.posterPath,
|
||||
icon: Icons.movie_outlined,
|
||||
label: '影视',
|
||||
rating: m.rating,
|
||||
createdAt: m.createdAt,
|
||||
color: const Color(0xFF4A90D9),
|
||||
);
|
||||
break;
|
||||
case 'book':
|
||||
@@ -66,25 +77,28 @@ class _StrollPageState extends State<StrollPage> {
|
||||
item = _StrollItem(
|
||||
type: 'book',
|
||||
title: b.title,
|
||||
subtitle: b.authors.isNotEmpty ? b.authors.first : '',
|
||||
detail: _buildBookDetail(b),
|
||||
subtitle: b.authors.take(2).join(' / '),
|
||||
detail: _bookDetail(b),
|
||||
imagePath: b.coverPath,
|
||||
icon: Icons.menu_book_outlined,
|
||||
label: '书籍',
|
||||
rating: b.rating,
|
||||
createdAt: b.createdAt,
|
||||
color: const Color(0xFF7E57C2),
|
||||
);
|
||||
break;
|
||||
case 'note':
|
||||
final n = notes[_random.nextInt(notes.length)];
|
||||
item = _StrollItem(
|
||||
type: 'note',
|
||||
title: n.title.isNotEmpty ? n.title : '无标题',
|
||||
subtitle: '${n.content.length} 字 · ${n.tags.isNotEmpty ? n.tags.take(2).join(' / ') : '无标签'}',
|
||||
title: n.title.isNotEmpty ? n.title : '随手记',
|
||||
subtitle: n.tags.take(3).join(' · '),
|
||||
detail: n.content,
|
||||
imagePath: n.images.isNotEmpty ? n.images.first : null,
|
||||
icon: Icons.note_outlined,
|
||||
label: '笔记',
|
||||
createdAt: n.createdAt,
|
||||
color: const Color(0xFF66BB6A),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
@@ -95,44 +109,45 @@ class _StrollPageState extends State<StrollPage> {
|
||||
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>[];
|
||||
if (m.rating != null && m.rating! > 0) parts.add('评分 ${m.rating!.toStringAsFixed(1)}');
|
||||
if (m.genres.isNotEmpty) parts.add(m.genres.take(3).join(' / '));
|
||||
if (m.status == 'watched') parts.add('已看');
|
||||
if (m.status == 'watching') parts.add('在看');
|
||||
return parts.join(' · ');
|
||||
if (m.summary != null && m.summary!.isNotEmpty) parts.add(m.summary!.length > 80 ? '${m.summary!.substring(0, 80)}...' : m.summary!);
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
String _buildBookDetail(Book b) {
|
||||
String _bookDetail(Book b) {
|
||||
final parts = <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.status == 'read') parts.add('已读');
|
||||
if (b.status == 'reading') parts.add('在读');
|
||||
return parts.join(' · ');
|
||||
if (b.summary != null && b.summary!.isNotEmpty) parts.add(b.summary!.length > 80 ? '${b.summary!.substring(0, 80)}...' : b.summary!);
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
String _getTimeAgoText(DateTime date) {
|
||||
String _timeAgoText(DateTime date) {
|
||||
final diff = DateTime.now().difference(date);
|
||||
if (diff.inDays >= 365) return '1年前';
|
||||
if (diff.inDays >= 180) return '6个月前';
|
||||
if (diff.inDays >= 90) return '3个月前';
|
||||
if (diff.inDays >= 30) return '1个月前';
|
||||
return '${diff.inDays}天前';
|
||||
if (diff.inDays >= 365) return '${(diff.inDays / 365).floor()}年前';
|
||||
if (diff.inDays >= 30) return '${(diff.inDays / 30).floor()}个月前';
|
||||
if (diff.inDays > 0) return '${diff.inDays}天前';
|
||||
if (diff.inHours > 0) return '${diff.inHours}小时前';
|
||||
return '刚刚';
|
||||
}
|
||||
|
||||
String _getActionTimeAgo(_StrollItem item) {
|
||||
final timeAgo = _getTimeAgoText(item.createdAt);
|
||||
String _actionText(_StrollItem item) {
|
||||
final ago = _timeAgoText(item.createdAt);
|
||||
switch (item.type) {
|
||||
case 'movie':
|
||||
return '${timeAgo}看过';
|
||||
case 'book':
|
||||
return '${timeAgo}读过';
|
||||
case 'note':
|
||||
return '${timeAgo}写下';
|
||||
default:
|
||||
return timeAgo;
|
||||
case 'movie': return '$ago 看过';
|
||||
case 'book': return '$ago 读过';
|
||||
case 'note': return '$ago 写下';
|
||||
default: return ago;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,23 +161,20 @@ class _StrollPageState extends State<StrollPage> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: GestureDetector(
|
||||
onTap: _refresh,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
onTap: _isLoading ? null : _refresh,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F5F5),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
|
||||
color: const Color(0xFF1A1A1A),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: Row(
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.refresh, size: 14, color: Color(0xFF666666)),
|
||||
const SizedBox(width: 4),
|
||||
const Text(
|
||||
'换一个',
|
||||
style: TextStyle(fontSize: 12, color: Color(0xFF666666)),
|
||||
),
|
||||
Icon(Icons.casino_outlined, size: 14, color: Colors.white),
|
||||
SizedBox(width: 5),
|
||||
Text('随机', style: TextStyle(fontSize: 12, color: Colors.white, fontWeight: FontWeight.w500)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -171,211 +183,163 @@ class _StrollPageState extends State<StrollPage> {
|
||||
],
|
||||
),
|
||||
body: _currentItem == null
|
||||
? const Center(
|
||||
child: Text(
|
||||
'还没有任何内容\n去添加一些吧',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 15, color: Color(0xFFBBBBBB), height: 1.6),
|
||||
),
|
||||
)
|
||||
: Consumer<AppProvider>(
|
||||
builder: (context, provider, _) {
|
||||
final item = _currentItem!;
|
||||
? const Center(child: Text('还没有任何内容\n去添加一些吧', textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 15, color: Color(0xFFBBBBBB), height: 1.6)))
|
||||
: Consumer<AppProvider>(builder: (context, provider, _) {
|
||||
final item = _currentItem!;
|
||||
final hasImage = item.imagePath != null && item.imagePath!.isNotEmpty && File(item.imagePath!).existsSync();
|
||||
|
||||
// 笔记:独立卡片样式
|
||||
if (item.type == 'note') {
|
||||
return _buildNoteCard(item);
|
||||
}
|
||||
|
||||
// 影视/书籍:海报+信息样式
|
||||
final hasImage = item.imagePath != null &&
|
||||
item.imagePath!.isNotEmpty &&
|
||||
File(item.imagePath!).existsSync();
|
||||
|
||||
return Center(
|
||||
return FadeTransition(
|
||||
opacity: _fadeAnim,
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(32),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 类型标签
|
||||
_buildTypeChip(item),
|
||||
_buildTypeBadge(item),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
const SizedBox(height: 28),
|
||||
|
||||
// 海报/封面/图标
|
||||
if (hasImage)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Image.file(
|
||||
File(item.imagePath!),
|
||||
width: 200,
|
||||
height: 260,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => _buildPlaceholder(item),
|
||||
),
|
||||
)
|
||||
// 封面/图片
|
||||
if (item.type != 'note')
|
||||
_buildCoverCard(item, hasImage)
|
||||
else
|
||||
_buildPlaceholder(item),
|
||||
_buildNoteCard(item, hasImage),
|
||||
|
||||
const SizedBox(height: 28),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 标题
|
||||
Text(
|
||||
item.title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF1A1A1A),
|
||||
height: 1.3,
|
||||
// 标题(笔记卡片已包含标题和内容,不需要重复)
|
||||
if (item.type != 'note') ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text(
|
||||
item.title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: Color(0xFF1A1A1A), height: 1.3),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 副标题
|
||||
if (item.subtitle.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
item.subtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 14, color: Color(0xFF999999)),
|
||||
),
|
||||
],
|
||||
if (item.subtitle.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(item.subtitle, textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 13, color: Color(0xFF999999))),
|
||||
),
|
||||
],
|
||||
|
||||
// 详情
|
||||
if (item.detail.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
item.detail,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 13, color: Color(0xFFAAAAAA)),
|
||||
),
|
||||
// 评分
|
||||
if (item.rating != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
AnimatedStarRating(rating: item.rating!, starSize: 16, showNumber: true),
|
||||
],
|
||||
|
||||
// 详情
|
||||
if (item.detail.isNotEmpty) ...[
|
||||
const SizedBox(height: 14),
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFAFAFA),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(item.detail,
|
||||
style: const TextStyle(fontSize: 13, color: Color(0xFF777777), height: 1.7)),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
// 时间
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
_getActionTimeAgo(item),
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFFCCCCCC)),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(_actionText(item), style: const TextStyle(fontSize: 12, color: Color(0xFFCCCCCC))),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTypeChip(_StrollItem item) {
|
||||
Widget _buildTypeBadge(_StrollItem item) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F5F5),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: item.color.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(item.icon, size: 14, color: const Color(0xFF999999)),
|
||||
const SizedBox(width: 6),
|
||||
Text(item.label, style: const TextStyle(fontSize: 12, color: Color(0xFF888888))),
|
||||
Icon(item.icon, size: 14, color: item.color),
|
||||
const SizedBox(width: 5),
|
||||
Text(item.label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: item.color)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 笔记卡片样式
|
||||
Widget _buildNoteCard(_StrollItem item) {
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildTypeChip(item),
|
||||
const SizedBox(height: 24),
|
||||
Widget _buildCoverCard(_StrollItem item, bool hasImage) {
|
||||
return Container(
|
||||
width: 220,
|
||||
height: 290,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(color: item.color.withValues(alpha: 0.15), blurRadius: 20, offset: const Offset(0, 8)),
|
||||
],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: hasImage
|
||||
? Image.file(File(item.imagePath!), fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => _buildPlaceholder(item))
|
||||
: _buildPlaceholder(item),
|
||||
);
|
||||
}
|
||||
|
||||
// 笔记卡片 - 类似分享海报
|
||||
Container(
|
||||
width: double.infinity,
|
||||
constraints: const BoxConstraints(maxWidth: 340),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.08),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 内容
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
item.detail,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: Color(0xFF333333),
|
||||
height: 1.8,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 底部分隔
|
||||
Container(height: 0.5, color: const Color(0xFFEEEEEE)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.note_outlined, size: 13, color: Color(0xFFCCCCCC)),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'${item.detail.length} 字',
|
||||
style: const TextStyle(fontSize: 11, color: Color(0xFFBBBBBB)),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
_getActionTimeAgo(item),
|
||||
style: const TextStyle(fontSize: 11, color: Color(0xFFBBBBBB)),
|
||||
),
|
||||
const Spacer(),
|
||||
const Text(
|
||||
'Mooknote',
|
||||
style: TextStyle(fontSize: 11, color: Color(0xFFDDDDDD)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
Widget _buildNoteCard(_StrollItem item, bool hasImage) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
constraints: const BoxConstraints(maxWidth: 360),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 16, offset: const Offset(0, 6)),
|
||||
],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (hasImage)
|
||||
Image.file(File(item.imagePath!), fit: BoxFit.cover, height: 200, width: double.infinity,
|
||||
errorBuilder: (_, __, ___) => const SizedBox.shrink()),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Text(item.detail.isEmpty ? '(无内容)' : item.detail,
|
||||
style: const TextStyle(fontSize: 14, color: Color(0xFF444444), height: 1.8)),
|
||||
),
|
||||
const Divider(height: 1, color: Color(0xFFF0F0F0)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
child: Text('${item.detail.length} 字 · Mooknote', style: const TextStyle(fontSize: 11, color: Color(0xFFBBBBBB))),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaceholder(_StrollItem item) {
|
||||
return Container(
|
||||
width: 120,
|
||||
height: 160,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFAFAFA),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFFEEEEEE), width: 0.5),
|
||||
color: const Color(0xFFF8F8F8),
|
||||
child: Center(
|
||||
child: Icon(item.icon, size: 48, color: item.color.withValues(alpha: 0.2)),
|
||||
),
|
||||
child: Icon(item.icon, size: 40, color: const Color(0xFFDDDDDD)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -388,7 +352,9 @@ class _StrollItem {
|
||||
final String? imagePath;
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final double? rating;
|
||||
final DateTime createdAt;
|
||||
final Color color;
|
||||
|
||||
_StrollItem({
|
||||
required this.type,
|
||||
@@ -398,6 +364,8 @@ class _StrollItem {
|
||||
this.imagePath,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
this.rating,
|
||||
required this.createdAt,
|
||||
required this.color,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,85 +7,62 @@ import '../pages/movies/movie_detail_page.dart';
|
||||
import '../pages/book/book_detail_page.dart';
|
||||
import '../pages/note/note_detail_page.dart';
|
||||
import '../pages/movies/douban_webview_page.dart';
|
||||
import 'slide_up_page_route.dart';
|
||||
|
||||
/// 路由生成器
|
||||
class AppRouter {
|
||||
static Route<dynamic> generateRoute(RouteSettings settings) {
|
||||
switch (settings.name) {
|
||||
case '/movie-form':
|
||||
// 处理不同参数类型:Movie 对象或 Map(包含 initialStatus)
|
||||
final args = settings.arguments;
|
||||
Movie? movie;
|
||||
String? initialStatus;
|
||||
|
||||
if (args is Movie) {
|
||||
movie = args;
|
||||
} else if (args is Map<String, dynamic>) {
|
||||
initialStatus = args['initialStatus'] as String?;
|
||||
}
|
||||
|
||||
return MaterialPageRoute(
|
||||
builder: (_) => MovieFormPage(
|
||||
movie: movie,
|
||||
initialStatus: initialStatus,
|
||||
),
|
||||
return SlideUpPageRoute(
|
||||
page: MovieFormPage(movie: movie, initialStatus: initialStatus),
|
||||
);
|
||||
|
||||
case '/book-form':
|
||||
// 处理不同参数类型:Book 对象或 Map(包含 initialStatus)
|
||||
final args = settings.arguments;
|
||||
Book? book;
|
||||
String? initialStatus;
|
||||
|
||||
if (args is Book) {
|
||||
book = args;
|
||||
} else if (args is Map<String, dynamic>) {
|
||||
initialStatus = args['initialStatus'] as String?;
|
||||
}
|
||||
|
||||
return MaterialPageRoute(
|
||||
builder: (_) => BookFormPage(
|
||||
book: book,
|
||||
initialStatus: initialStatus,
|
||||
),
|
||||
return SlideUpPageRoute(
|
||||
page: BookFormPage(book: book, initialStatus: initialStatus),
|
||||
);
|
||||
|
||||
case '/note-form':
|
||||
final note = settings.arguments as Note?;
|
||||
return MaterialPageRoute(
|
||||
builder: (_) => NoteFormPage(note: note),
|
||||
);
|
||||
return SlideUpPageRoute(page: NoteFormPage(note: note));
|
||||
|
||||
case '/movie-detail':
|
||||
final movie = settings.arguments as Movie;
|
||||
return MaterialPageRoute(
|
||||
builder: (_) => MovieDetailPage(movie: movie),
|
||||
);
|
||||
return SlideUpPageRoute(page: MovieDetailPage(movie: movie));
|
||||
|
||||
case '/book-detail':
|
||||
final book = settings.arguments as Book;
|
||||
return MaterialPageRoute(
|
||||
builder: (_) => BookDetailPage(book: book),
|
||||
);
|
||||
return SlideUpPageRoute(page: BookDetailPage(book: book));
|
||||
|
||||
case '/note-detail':
|
||||
final note = settings.arguments as Note;
|
||||
return MaterialPageRoute(
|
||||
builder: (_) => NoteDetailPage(note: note),
|
||||
);
|
||||
return SlideUpPageRoute(page: NoteDetailPage(note: note));
|
||||
|
||||
case '/douban-webview':
|
||||
final url = settings.arguments as String;
|
||||
return MaterialPageRoute(
|
||||
builder: (_) => DoubanWebViewPage(url: url),
|
||||
);
|
||||
return SlideUpPageRoute(page: DoubanWebViewPage(url: url));
|
||||
|
||||
default:
|
||||
return MaterialPageRoute(
|
||||
builder: (_) => Scaffold(
|
||||
body: Center(
|
||||
child: Text('未找到页面:${settings.name}'),
|
||||
),
|
||||
body: Center(child: Text('未找到页面:${settings.name}')),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
28
lib/utils/slide_up_page_route.dart
Normal file
28
lib/utils/slide_up_page_route.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
@@ -68,10 +68,18 @@ class UserPrefs {
|
||||
int get defaultMainTabIndex => prefs.getInt('defaultMainTabIndex') ?? 0;
|
||||
Future<bool> setDefaultMainTabIndex(int value) => prefs.setInt('defaultMainTabIndex', value);
|
||||
|
||||
/// 笔记布局样式 (0: 列表, 1: 瀑布流)
|
||||
/// 笔记布局样式 (0: 列表, 1: 瀑布流, 2: 时间线)
|
||||
int get noteLayoutStyle => prefs.getInt('noteLayoutStyle') ?? 0;
|
||||
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 阅读器最近选择的目录
|
||||
|
||||
96
lib/widgets/animated_star_rating.dart
Normal file
96
lib/widgets/animated_star_rating.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
28
lib/widgets/app_refresh_indicator.dart
Normal file
28
lib/widgets/app_refresh_indicator.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/data_models.dart';
|
||||
import '../providers/app_provider.dart';
|
||||
import '../widgets/fade_in_local_image.dart';
|
||||
import '../widgets/animated_star_rating.dart';
|
||||
import '../utils/toast_util.dart';
|
||||
|
||||
/// 书籍列表项组件 - 网格布局设计
|
||||
@@ -42,9 +44,9 @@ class BookListItem extends StatelessWidget {
|
||||
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// 评分 - 5星显示
|
||||
// 评分
|
||||
if (book.rating != null)
|
||||
_buildStarRating(book.rating!)
|
||||
AnimatedStarRating(rating: book.rating!, starSize: 12, showNumber: true)
|
||||
else
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
@@ -57,75 +59,19 @@ class BookListItem extends StatelessWidget {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F5F5),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: const Color(0xFFE5E5E5), width: 0.5),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: book.coverPath != null && book.coverPath!.isNotEmpty
|
||||
? Image.file(
|
||||
File(book.coverPath!),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => _buildCoverPlaceholder(),
|
||||
)
|
||||
: _buildCoverPlaceholder(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCoverPlaceholder() {
|
||||
return const Center(
|
||||
child: Icon(
|
||||
Icons.menu_book_outlined,
|
||||
size: 32,
|
||||
color: Color(0xFFCCCCCC),
|
||||
child: FadeInLocalImage(
|
||||
path: book.coverPath,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: const Center(child: Icon(Icons.menu_book_outlined, size: 32, color: Color(0xFFCCCCCC))),
|
||||
errorWidget: const Center(child: Icon(Icons.menu_book_outlined, size: 32, color: Color(0xFFCCCCCC))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建5星评分显示(评分范围1-10,每星2分)
|
||||
Widget _buildStarRating(double rating) {
|
||||
// 将10分制转换为5星制
|
||||
final starValue = rating / 2;
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 5个星星
|
||||
...List.generate(5, (index) {
|
||||
final starIndex = index + 1;
|
||||
IconData iconData;
|
||||
|
||||
if (starValue >= starIndex) {
|
||||
// 满星
|
||||
iconData = Icons.star;
|
||||
} else if (starValue >= starIndex - 0.5) {
|
||||
// 半星
|
||||
iconData = Icons.star_half;
|
||||
} else {
|
||||
// 空星
|
||||
iconData = Icons.star_border;
|
||||
}
|
||||
|
||||
return Icon(
|
||||
iconData,
|
||||
size: 12,
|
||||
color: const Color(0xFFFFB800),
|
||||
);
|
||||
}),
|
||||
const SizedBox(width: 4),
|
||||
// 评分数字
|
||||
Text(
|
||||
rating.toStringAsFixed(1),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF666666),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示删除确认对话框
|
||||
void _showDeleteDialog(BuildContext context) {
|
||||
showDialog(
|
||||
|
||||
111
lib/widgets/fade_in_local_image.dart
Normal file
111
lib/widgets/fade_in_local_image.dart
Normal 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)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/data_models.dart';
|
||||
import '../providers/app_provider.dart';
|
||||
import '../widgets/fade_in_local_image.dart';
|
||||
import '../widgets/animated_star_rating.dart';
|
||||
import '../utils/toast_util.dart';
|
||||
|
||||
/// 观影列表项组件 - 网格布局设计
|
||||
@@ -42,9 +44,9 @@ class MovieListItem extends StatelessWidget {
|
||||
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// 评分 - 5星显示
|
||||
// 评分
|
||||
if (movie.rating != null)
|
||||
_buildStarRating(movie.rating!)
|
||||
AnimatedStarRating(rating: movie.rating!, starSize: 12, showNumber: true)
|
||||
else
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
@@ -57,75 +59,19 @@ class MovieListItem extends StatelessWidget {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F5F5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFFE5E5E5), width: 0.5),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: movie.posterPath != null && movie.posterPath!.isNotEmpty
|
||||
? Image.file(
|
||||
File(movie.posterPath!),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => _buildPosterPlaceholder(),
|
||||
)
|
||||
: _buildPosterPlaceholder(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPosterPlaceholder() {
|
||||
return const Center(
|
||||
child: Icon(
|
||||
Icons.movie_outlined,
|
||||
size: 24,
|
||||
color: Color(0xFFCCCCCC),
|
||||
child: FadeInLocalImage(
|
||||
path: movie.posterPath,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: const Center(child: Icon(Icons.movie_outlined, size: 24, color: Color(0xFFCCCCCC))),
|
||||
errorWidget: const Center(child: Icon(Icons.movie_outlined, size: 24, color: Color(0xFFCCCCCC))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建5星评分显示(评分范围1-10,每星2分)
|
||||
Widget _buildStarRating(double rating) {
|
||||
// 将10分制转换为5星制
|
||||
final starValue = rating / 2;
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 5个星星
|
||||
...List.generate(5, (index) {
|
||||
final starIndex = index + 1;
|
||||
IconData iconData;
|
||||
|
||||
if (starValue >= starIndex) {
|
||||
// 满星
|
||||
iconData = Icons.star;
|
||||
} else if (starValue >= starIndex - 0.5) {
|
||||
// 半星
|
||||
iconData = Icons.star_half;
|
||||
} else {
|
||||
// 空星
|
||||
iconData = Icons.star_border;
|
||||
}
|
||||
|
||||
return Icon(
|
||||
iconData,
|
||||
size: 12,
|
||||
color: const Color(0xFFFFB800),
|
||||
);
|
||||
}),
|
||||
const SizedBox(width: 4),
|
||||
// 评分数字
|
||||
Text(
|
||||
rating.toStringAsFixed(1),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF666666),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示删除确认对话框
|
||||
void _showDeleteDialog(BuildContext context) {
|
||||
showDialog(
|
||||
|
||||
168
lib/widgets/shimmer_skeleton.dart
Normal file
168
lib/widgets/shimmer_skeleton.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user