Files
MookNote/lib/pages/statistics_page.dart
2026-03-07 00:40:01 +08:00

729 lines
24 KiB
Dart

import 'dart:math';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:fl_chart/fl_chart.dart';
import '../providers/app_provider.dart';
import '../models/data_models.dart';
/// 数据统计页面 - 多维度数据分析
class StatisticsPage extends StatefulWidget {
const StatisticsPage({super.key});
@override
State<StatisticsPage> createState() => _StatisticsPageState();
}
class _StatisticsPageState extends State<StatisticsPage> with SingleTickerProviderStateMixin {
late TabController _tabController;
DateTime _selectedMonth = DateTime.now();
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 3,
child: Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: const Text('数据统计'),
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(text: '概览'),
Tab(text: '日历'),
Tab(text: '趋势'),
],
labelColor: const Color(0xFF1A1A1A),
unselectedLabelColor: const Color(0xFF999999),
indicatorColor: const Color(0xFF1A1A1A),
),
),
body: Consumer<AppProvider>(
builder: (context, provider, child) {
final movies = provider.movies;
final books = provider.books;
final notes = provider.notes;
return TabBarView(
controller: _tabController,
children: [
_buildOverviewTab(movies, books, notes),
_buildCalendarTab(movies, books, notes),
_buildTrendTab(movies, books, notes),
],
);
},
),
),
);
}
/// 概览标签页
Widget _buildOverviewTab(List<Movie> movies, List<Book> books, List<Note> notes) {
final totalMovies = movies.length;
final totalBooks = books.length;
final totalNotes = notes.length;
final watchedMovies = movies.where((m) => m.status == 'watched').length;
final watchingMovies = movies.where((m) => m.status == 'watching').length;
final wantToWatchMovies = movies.where((m) => m.status == 'want_to_watch').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;
return ListView(
padding: const EdgeInsets.all(24),
children: [
// 总数据卡片
_buildSectionTitle('数据总览'),
const SizedBox(height: 16),
Row(
children: [
Expanded(child: _buildStatCard('影视', totalMovies, Icons.movie_outlined, const Color(0xFF1A1A1A))),
const SizedBox(width: 12),
Expanded(child: _buildStatCard('书籍', totalBooks, Icons.menu_book_outlined, const Color(0xFF666666))),
const SizedBox(width: 12),
Expanded(child: _buildStatCard('笔记', totalNotes, Icons.note_outlined, const Color(0xFF999999))),
],
),
const SizedBox(height: 32),
// 影视状态分布
_buildSectionTitle('影视状态分布'),
const SizedBox(height: 16),
_buildStatusDistribution([
_StatusData('已看', watchedMovies, const Color(0xFF1A1A1A)),
_StatusData('在看', watchingMovies, const Color(0xFF666666)),
_StatusData('想看', wantToWatchMovies, const Color(0xFF999999)),
]),
const SizedBox(height: 32),
// 书籍状态分布
_buildSectionTitle('书籍状态分布'),
const SizedBox(height: 16),
_buildStatusDistribution([
_StatusData('已读', readBooks, const Color(0xFF1A1A1A)),
_StatusData('在读', readingBooks, const Color(0xFF666666)),
_StatusData('想读', wantToReadBooks, const Color(0xFF999999)),
]),
const SizedBox(height: 32),
// 最近活动
_buildSectionTitle('最近7天活动'),
const SizedBox(height: 16),
_buildRecentActivity(movies, books, notes),
],
);
}
/// 日历标签页
Widget _buildCalendarTab(List<Movie> movies, List<Book> books, List<Note> notes) {
// 合并所有数据按日期
final Map<DateTime, _DailyData> dailyData = {};
for (final movie in movies) {
final date = DateTime(movie.createdAt.year, movie.createdAt.month, movie.createdAt.day);
dailyData.putIfAbsent(date, () => _DailyData()).movies++;
}
for (final book in books) {
final date = DateTime(book.createdAt.year, book.createdAt.month, book.createdAt.day);
dailyData.putIfAbsent(date, () => _DailyData()).books++;
}
for (final note in notes) {
final date = DateTime(note.createdAt.year, note.createdAt.month, note.createdAt.day);
dailyData.putIfAbsent(date, () => _DailyData()).notes++;
}
return Column(
children: [
// 月份选择器
Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: () {
setState(() {
_selectedMonth = DateTime(_selectedMonth.year, _selectedMonth.month - 1);
});
},
),
Text(
'${_selectedMonth.year}${_selectedMonth.month}',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: () {
setState(() {
_selectedMonth = DateTime(_selectedMonth.year, _selectedMonth.month + 1);
});
},
),
],
),
),
// 热力图图例
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
const Text('', style: TextStyle(fontSize: 12, color: Color(0xFF999999))),
const SizedBox(width: 8),
...List.generate(4, (index) {
final opacity = 0.2 + (index * 0.2);
return Container(
width: 12,
height: 12,
margin: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration(
color: const Color(0xFF1A1A1A).withOpacity(opacity),
borderRadius: BorderRadius.circular(2),
),
);
}),
const SizedBox(width: 8),
const Text('', style: TextStyle(fontSize: 12, color: Color(0xFF999999))),
],
),
),
const SizedBox(height: 16),
// 日历热力图
Expanded(
child: _buildHeatMapCalendar(dailyData),
),
],
);
}
/// 趋势标签页
Widget _buildTrendTab(List<Movie> movies, List<Book> books, List<Note> notes) {
// 近30天每日新增数据
final now = DateTime.now();
final thirtyDaysAgo = now.subtract(const Duration(days: 29));
final List<_DailyTrend> movieTrend = [];
final List<_DailyTrend> bookTrend = [];
final List<_DailyTrend> noteTrend = [];
for (int i = 0; i < 30; i++) {
final date = DateTime(thirtyDaysAgo.year, thirtyDaysAgo.month, thirtyDaysAgo.day + i);
final movieCount = movies.where((m) {
final mDate = DateTime(m.createdAt.year, m.createdAt.month, m.createdAt.day);
return mDate.isAtSameMomentAs(date);
}).length;
final bookCount = books.where((b) {
final bDate = DateTime(b.createdAt.year, b.createdAt.month, b.createdAt.day);
return bDate.isAtSameMomentAs(date);
}).length;
final noteCount = notes.where((n) {
final nDate = DateTime(n.createdAt.year, n.createdAt.month, n.createdAt.day);
return nDate.isAtSameMomentAs(date);
}).length;
movieTrend.add(_DailyTrend(date, movieCount));
bookTrend.add(_DailyTrend(date, bookCount));
noteTrend.add(_DailyTrend(date, noteCount));
}
return ListView(
padding: const EdgeInsets.all(24),
children: [
_buildSectionTitle('近30天新增趋势'),
const SizedBox(height: 8),
const Text(
'每日新增数据统计',
style: TextStyle(fontSize: 13, color: Color(0xFF999999)),
),
const SizedBox(height: 24),
// 折线图
SizedBox(
height: 250,
child: _buildLineChart(movieTrend, bookTrend, noteTrend),
),
const SizedBox(height: 32),
// 图例
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildLegendItem('影视', const Color(0xFF1A1A1A)),
const SizedBox(width: 24),
_buildLegendItem('书籍', const Color(0xFF666666)),
const SizedBox(width: 24),
_buildLegendItem('笔记', const Color(0xFF999999)),
],
),
const SizedBox(height: 32),
// 近7天统计
_buildSectionTitle('近7天新增统计'),
const SizedBox(height: 16),
_buildWeeklyStats(movieTrend.sublist(23), bookTrend.sublist(23), noteTrend.sublist(23)),
],
);
}
/// 构建统计卡片
Widget _buildStatCard(String title, int count, IconData icon, Color color) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
border: Border.all(color: const Color(0xFFE5E5E5)),
),
child: Column(
children: [
Icon(icon, color: color, size: 24),
const SizedBox(height: 8),
Text(
count.toString(),
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w600,
color: color,
),
),
const SizedBox(height: 4),
Text(
title,
style: const TextStyle(fontSize: 12, color: Color(0xFF666666)),
),
],
),
);
}
/// 构建状态分布
Widget _buildStatusDistribution(List<_StatusData> data) {
final total = data.fold(0, (sum, item) => sum + item.count);
return 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: 12,
height: 12,
decoration: BoxDecoration(color: item.color),
),
const SizedBox(width: 12),
Text(item.label, style: const TextStyle(fontSize: 14)),
const Spacer(),
Text(
'${item.count} ($percentage%)',
style: const TextStyle(fontSize: 14, color: Color(0xFF666666)),
),
],
),
);
}).toList(),
);
}
/// 构建最近活动
Widget _buildRecentActivity(List<Movie> movies, List<Book> books, List<Note> notes) {
final now = DateTime.now();
final sevenDaysAgo = now.subtract(const Duration(days: 7));
final recentMovies = movies.where((m) => m.createdAt.isAfter(sevenDaysAgo)).length;
final recentBooks = books.where((b) => b.createdAt.isAfter(sevenDaysAgo)).length;
final recentNotes = notes.where((n) => n.createdAt.isAfter(sevenDaysAgo)).length;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E5E5)),
),
child: Column(
children: [
_buildActivityRow('新增影视', recentMovies, Icons.movie_outlined),
const Divider(height: 24),
_buildActivityRow('新增书籍', recentBooks, Icons.menu_book_outlined),
const Divider(height: 24),
_buildActivityRow('新增笔记', recentNotes, Icons.note_outlined),
],
),
);
}
Widget _buildActivityRow(String label, int count, IconData icon) {
return Row(
children: [
Icon(icon, size: 20, color: const Color(0xFF666666)),
const SizedBox(width: 12),
Text(label, style: const TextStyle(fontSize: 14)),
const Spacer(),
Text(
'$count',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
),
],
);
}
/// 构建热力图日历
Widget _buildHeatMapCalendar(Map<DateTime, _DailyData> dailyData) {
final year = _selectedMonth.year;
final month = _selectedMonth.month;
final daysInMonth = DateTime(year, month + 1, 0).day;
final firstWeekday = DateTime(year, month, 1).weekday % 7;
// 计算最大数量用于颜色强度
int maxCount = 0;
for (final data in dailyData.values) {
final count = data.total;
if (count > maxCount) maxCount = count;
}
if (maxCount == 0) maxCount = 1;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
// 星期标题
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: const ['', '', '', '', '', '', '']
.map((d) => SizedBox(
width: 36,
child: Text(
d,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12, color: Color(0xFF999999)),
),
))
.toList(),
),
const SizedBox(height: 8),
// 日历网格
Wrap(
spacing: 8,
runSpacing: 8,
children: [
// 空白填充
...List.generate(firstWeekday, (_) => const SizedBox(width: 36, height: 36)),
// 日期
...List.generate(daysInMonth, (index) {
final day = index + 1;
final date = DateTime(year, month, day);
final data = dailyData[date];
final count = data?.total ?? 0;
// 计算颜色强度
double opacity = 0.1;
if (count > 0) {
opacity = 0.3 + (count / maxCount * 0.7);
opacity = opacity.clamp(0.3, 1.0);
}
return GestureDetector(
onTap: () {
if (count > 0) {
_showDayDetail(context, date, data!);
}
},
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: count > 0
? const Color(0xFF1A1A1A).withOpacity(opacity)
: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(4),
),
child: Center(
child: Text(
day.toString(),
style: TextStyle(
fontSize: 12,
color: count > 0 ? Colors.white : const Color(0xFF666666),
),
),
),
),
);
}),
],
),
],
),
);
}
/// 显示某天详情
void _showDayDetail(BuildContext context, DateTime date, _DailyData data) {
showModalBottomSheet(
context: context,
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
builder: (context) => Container(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${date.year}${date.month}${date.day}',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
const SizedBox(height: 16),
if (data.movies > 0)
_buildDetailRow(Icons.movie_outlined, '影视', data.movies),
if (data.books > 0)
_buildDetailRow(Icons.menu_book_outlined, '书籍', data.books),
if (data.notes > 0)
_buildDetailRow(Icons.note_outlined, '笔记', data.notes),
const SizedBox(height: 16),
Text(
'总计: ${data.total}',
style: const TextStyle(fontSize: 14, color: Color(0xFF666666)),
),
],
),
),
);
}
Widget _buildDetailRow(IconData icon, String label, int count) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Icon(icon, size: 18, color: const Color(0xFF666666)),
const SizedBox(width: 8),
Text(label, style: const TextStyle(fontSize: 14)),
const Spacer(),
Text('$count', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
],
),
);
}
/// 构建折线图
Widget _buildLineChart(List<_DailyTrend> movieTrend, List<_DailyTrend> bookTrend, List<_DailyTrend> noteTrend) {
final spotsMovie = movieTrend.asMap().entries.map((e) {
return FlSpot(e.key.toDouble(), e.value.count.toDouble());
}).toList();
final spotsBook = bookTrend.asMap().entries.map((e) {
return FlSpot(e.key.toDouble(), e.value.count.toDouble());
}).toList();
final spotsNote = noteTrend.asMap().entries.map((e) {
return FlSpot(e.key.toDouble(), e.value.count.toDouble());
}).toList();
return LineChart(
LineChartData(
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: 1,
getDrawingHorizontalLine: (value) {
return FlLine(
color: const Color(0xFFE5E5E5),
strokeWidth: 0.5,
);
},
),
titlesData: FlTitlesData(
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
getTitlesWidget: (value, meta) {
return Text(
value.toInt().toString(),
style: const TextStyle(fontSize: 10, color: Color(0xFF999999)),
);
},
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
interval: 5,
getTitlesWidget: (value, meta) {
if (value.toInt() >= 0 && value.toInt() < movieTrend.length) {
final date = movieTrend[value.toInt()].date;
return Text(
'${date.month}/${date.day}',
style: const TextStyle(fontSize: 10, color: Color(0xFF999999)),
);
}
return const Text('');
},
),
),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
borderData: FlBorderData(show: false),
lineBarsData: [
// 影视折线
LineChartBarData(
spots: spotsMovie,
isCurved: true,
color: const Color(0xFF1A1A1A),
barWidth: 2,
isStrokeCapRound: true,
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(show: false),
),
// 书籍折线
LineChartBarData(
spots: spotsBook,
isCurved: true,
color: const Color(0xFF666666),
barWidth: 2,
isStrokeCapRound: true,
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(show: false),
),
// 笔记折线
LineChartBarData(
spots: spotsNote,
isCurved: true,
color: const Color(0xFF999999),
barWidth: 2,
isStrokeCapRound: true,
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(show: false),
),
],
),
);
}
/// 构建图例项
Widget _buildLegendItem(String label, Color color) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 12,
height: 3,
decoration: BoxDecoration(color: color),
),
const SizedBox(width: 8),
Text(label, style: const TextStyle(fontSize: 12, color: Color(0xFF666666))),
],
);
}
/// 构建近7天统计
Widget _buildWeeklyStats(List<_DailyTrend> movieTrend, List<_DailyTrend> bookTrend, List<_DailyTrend> noteTrend) {
final totalMovies = movieTrend.fold(0, (sum, item) => sum + item.count);
final totalBooks = bookTrend.fold(0, (sum, item) => sum + item.count);
final totalNotes = noteTrend.fold(0, (sum, item) => sum + item.count);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E5E5)),
),
child: Column(
children: [
_buildWeeklyRow('影视新增', totalMovies, const Color(0xFF1A1A1A)),
const Divider(height: 24),
_buildWeeklyRow('书籍新增', totalBooks, const Color(0xFF666666)),
const Divider(height: 24),
_buildWeeklyRow('笔记新增', totalNotes, const Color(0xFF999999)),
const Divider(height: 24),
_buildWeeklyRow('总计', totalMovies + totalBooks + totalNotes, const Color(0xFF1A1A1A), isTotal: true),
],
),
);
}
Widget _buildWeeklyRow(String label, int count, Color color, {bool isTotal = false}) {
return Row(
children: [
Text(
label,
style: TextStyle(
fontSize: isTotal ? 14 : 13,
fontWeight: isTotal ? FontWeight.w600 : FontWeight.normal,
),
),
const Spacer(),
Text(
'$count',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: color,
),
),
],
);
}
Widget _buildSectionTitle(String title) {
return Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
);
}
}
/// 状态数据
class _StatusData {
final String label;
final int count;
final Color color;
_StatusData(this.label, this.count, this.color);
}
/// 每日数据
class _DailyData {
int movies = 0;
int books = 0;
int notes = 0;
int get total => movies + books + notes;
}
/// 每日趋势
class _DailyTrend {
final DateTime date;
final int count;
_DailyTrend(this.date, this.count);
}