优化项目结构

This commit is contained in:
DelLevin-Home
2026-07-05 13:58:25 +08:00
parent 82aca12402
commit de180eee47
41 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,435 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/app_provider.dart';
import '../utils/user_prefs.dart';
/// 相遇统计页
class EncounterPage extends StatelessWidget {
const EncounterPage({super.key});
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final userPrefs = UserPrefs();
final firstUse = userPrefs.firstUseDate;
final now = DateTime.now();
final days = DateTime(now.year, now.month, now.day)
.difference(DateTime(firstUse.year, firstUse.month, firstUse.day))
.inDays + 1;
return Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(title: const Text('统计')),
body: Consumer<AppProvider>(
builder: (context, provider, child) {
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 noteWords = notes.fold<int>(0, (sum, n) => sum + n.content.length);
int imageCount = 0;
for (final m in movies) {
if (m.posterPath != null && m.posterPath!.isNotEmpty) imageCount++;
}
for (final b in books) {
if (b.coverPath != null && b.coverPath!.isNotEmpty) imageCount++;
}
for (final n in notes) {
imageCount += n.images.length;
}
final totalRecords = movies.length + books.length + notes.length;
return CustomScrollView(
slivers: [
// 上半部分:与你 + 天数
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
const SizedBox(height: 48),
Text(
'与你',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.w700,
color: colors.onSurface,
letterSpacing: 4,
),
),
const SizedBox(height: 12),
RichText(
text: TextSpan(
children: [
TextSpan(
text: '相遇的第',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
color: colors.onSurface.withValues(alpha: 0.5),
),
),
TextSpan(
text: '$days',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.w700,
color: colors.primary,
),
),
TextSpan(
text: '',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
color: colors.onSurface.withValues(alpha: 0.5),
),
),
],
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'${firstUse.year}${firstUse.month}${firstUse.day}日 — ${now.year}${now.month}${now.day}',
style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3)),
),
],
),
),
),
// 下半部分:用 SliverFillRemaining 推到底部
SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Divider(color: colors.outlineVariant, thickness: 0.5),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_statItem(context, '${movies.length}', '影视', Icons.movie_outlined),
_statItem(context, '${books.length}', '书籍', Icons.menu_book_outlined),
_statItem(context, '${notes.length}', '笔记', Icons.note_outlined),
],
),
const SizedBox(height: 24),
Divider(color: colors.outlineVariant, thickness: 0.5),
const SizedBox(height: 24),
Text(
'已记录',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: colors.onSurface.withValues(alpha: 0.5),
letterSpacing: 2,
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_recordItem(context, '$totalRecords', '条记录'),
_recordItem(context, _formatCount(noteWords), '文字'),
_recordItem(context, '$imageCount', '张图片'),
],
),
const SizedBox(height: 24),
Divider(color: colors.outlineVariant, thickness: 0.5),
const SizedBox(height: 32),
_buildCuteAnimation(colors),
const SizedBox(height: 32),
],
),
),
),
],
);
},
),
);
}
String _formatCount(int count) {
if (count >= 10000) return '${(count / 10000).toStringAsFixed(1)}';
if (count >= 1000) return '${(count / 1000).toStringAsFixed(1)}k';
return '$count';
}
Widget _statItem(BuildContext context, String value, String label, IconData icon) {
final colors = Theme.of(context).colorScheme;
return Column(
children: [
Icon(icon, size: 20, color: colors.onSurface.withValues(alpha: 0.3)),
const SizedBox(height: 8),
Text(
value,
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: colors.onSurface),
),
const SizedBox(height: 4),
Text(
label,
style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4)),
),
],
);
}
Widget _recordItem(BuildContext context, String value, String label) {
final colors = Theme.of(context).colorScheme;
return Column(
children: [
Text(
value,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.primary),
),
const SizedBox(height: 4),
Text(
label,
style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4)),
),
],
);
}
/// 慢速滚动城市天际线
Widget _buildCuteAnimation(ColorScheme colors) {
return SizedBox(
height: 72,
child: _CityScape(colors: colors),
);
}
}
class _CityScape extends StatefulWidget {
final ColorScheme colors;
const _CityScape({required this.colors});
@override
State<_CityScape> createState() => _CityScapeState();
}
class _CityScapeState extends State<_CityScape> with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 20),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
return CustomPaint(
size: const Size(double.infinity, 72),
painter: _CityPainter(_controller.value, widget.colors),
);
},
);
}
}
class _CityPainter extends CustomPainter {
final double t;
final ColorScheme colors;
_CityPainter(this.t, this.colors);
// 后层建筑数据(高度比, 宽度, 是否尖顶)
static const _buildings = <(double, double, bool)>[
(0.30, 16, false),
(0.48, 10, true),
(0.22, 20, false),
(0.55, 10, true),
(0.35, 14, false),
(0.42, 10, true),
(0.25, 18, false),
];
// 前层绿植数据(高度比, 宽度, 类型: 0=圆冠, 1=松树, 2=灌木)
static const _plants = <(double, double, int)>[
(0.35, 10, 0),
(0.50, 8, 1),
(0.22, 14, 2),
(0.45, 8, 0),
(0.30, 12, 1),
(0.20, 10, 2),
(0.52, 8, 1),
(0.28, 14, 2),
];
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..style = PaintingStyle.fill;
final groundY = size.height - 2;
// 星星
_drawStars(canvas, paint, size, t);
// 后层建筑(慢 0.3x,浅色)
_drawBuildings(canvas, paint, size, groundY, t * 0.6);
// 前层绿植(快 1.5x,深色)
_drawPlants(canvas, paint, size, groundY, t * 1.0);
// 地面线
paint.color = colors.onSurface.withValues(alpha: 0.10);
canvas.drawRect(Rect.fromLTWH(0, groundY, size.width, 1), paint);
}
// ── 后层:建筑 ──
void _drawBuildings(Canvas canvas, Paint paint, Size size, double groundY, double scrollT) {
double totalW = 0;
for (final b in _buildings) {
totalW += b.$2 + 4;
}
final offset = (scrollT * totalW) % totalW;
double x = -offset;
int i = 0;
while (x < size.width + 20) {
final (hR, w, spire) = _buildings[i % _buildings.length];
final h = hR * (size.height - 8);
final bx = x;
final by = groundY - h;
if (bx + w > -10 && bx < size.width + 10) {
final a = 0.06 + hR * 0.05;
paint.color = colors.onSurface.withValues(alpha: a);
canvas.drawRect(Rect.fromLTWH(bx, by, w, h), paint);
// 窗户
if (h > 18) {
paint.color = colors.onSurface.withValues(alpha: 0.04);
for (int r = 0; r < ((h - 6) / 5).floor(); r++) {
for (int c = 0; c < ((w - 4) / 4).floor(); c++) {
if ((i * 13 + r * 7 + c * 11) % 4 == 0) continue;
canvas.drawRect(Rect.fromLTWH(bx + 3 + c * 4.0, by + 4 + r * 5.0, 2, 2), paint);
}
}
}
if (spire) {
paint.color = colors.onSurface.withValues(alpha: a);
final sh = h * 0.18;
canvas.drawPath(
Path()
..moveTo(bx + w / 2 - 2, by)
..lineTo(bx + w / 2, by - sh)
..lineTo(bx + w / 2 + 2, by)
..close(),
paint,
);
}
if (!spire && hR > 0.4 && i % 3 == 0) {
paint.color = colors.onSurface.withValues(alpha: a * 0.5);
canvas.drawRect(Rect.fromLTWH(bx + w / 2 - 0.5, by - 6, 1, 6), paint);
canvas.drawCircle(Offset(bx + w / 2, by - 6), 1.2, paint);
}
}
x += w + 16;
i++;
}
}
// ── 前层:绿植 ──
void _drawPlants(Canvas canvas, Paint paint, Size size, double groundY, double scrollT) {
double totalW = 0;
for (final p in _plants) {
totalW += p.$2 + 6;
}
final offset = (scrollT * totalW) % totalW;
double x = -offset;
int i = 0;
while (x < size.width + 20) {
final (hR, w, type) = _plants[i % _plants.length];
final h = hR * (size.height - 10);
final bx = x;
final by = groundY;
if (bx + w > -10 && bx < size.width + 10) {
final alpha = 0.18 + hR * 0.10;
if (type == 0) {
// 圆冠树
final trunkH = h * 0.4;
final crownR = w * 0.45;
paint.color = colors.onSurface.withValues(alpha: alpha * 0.7);
canvas.drawRect(Rect.fromLTWH(bx + w / 2 - 1.5, by - trunkH, 3, trunkH), paint);
paint.color = colors.onSurface.withValues(alpha: alpha);
canvas.drawOval(
Rect.fromCenter(center: Offset(bx + w / 2, by - trunkH - crownR * 0.6), width: crownR * 2, height: crownR * 1.6),
paint,
);
} else if (type == 1) {
// 松树
final trunkH = h * 0.25;
paint.color = colors.onSurface.withValues(alpha: alpha * 0.7);
canvas.drawRect(Rect.fromLTWH(bx + w / 2 - 1.5, by - trunkH, 3, trunkH), paint);
paint.color = colors.onSurface.withValues(alpha: alpha);
for (int layer = 0; layer < 3; layer++) {
final layerW = w * (1.0 - layer * 0.2);
final layerBottom = by - trunkH - layer * (h * 0.2);
final layerTop = layerBottom - h * 0.28;
canvas.drawPath(
Path()
..moveTo(bx + w / 2 - layerW / 2, layerBottom)
..lineTo(bx + w / 2, layerTop)
..lineTo(bx + w / 2 + layerW / 2, layerBottom)
..close(),
paint,
);
}
} else {
// 灌木
paint.color = colors.onSurface.withValues(alpha: alpha);
canvas.drawOval(Rect.fromLTWH(bx, by - h, w, h), paint);
paint.color = colors.onSurface.withValues(alpha: alpha * 0.8);
canvas.drawOval(Rect.fromLTWH(bx + w * 0.2, by - h * 0.7, w * 0.6, h * 0.6), paint);
}
}
x += w + 14;
i++;
}
}
void _drawStars(Canvas canvas, Paint paint, Size size, double t) {
const stars = [
(12.0, 5.0, 1.2), (38.0, 12.0, 0.8), (65.0, 3.0, 1.0),
(95.0, 16.0, 1.4), (130.0, 7.0, 0.9), (165.0, 14.0, 1.1),
(200.0, 4.0, 1.3), (235.0, 18.0, 0.7), (270.0, 9.0, 1.0),
(310.0, 2.0, 1.2), (345.0, 15.0, 0.9), (380.0, 6.0, 1.1),
(420.0, 11.0, 0.8), (460.0, 3.0, 1.0), (500.0, 17.0, 1.3),
];
for (int i = 0; i < stars.length; i++) {
final (sx, sy, r) = stars[i];
if (sx > size.width) continue;
final flicker = 0.15 + 0.12 * sin(t * 2 * pi + i * 1.1);
paint.color = colors.onSurface.withValues(alpha: flicker);
canvas.drawCircle(Offset(sx, sy), r, paint);
}
}
@override
bool shouldRepaint(_CityPainter old) => old.t != t;
}

View File

@@ -0,0 +1,444 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/app_provider.dart';
import '../models/data_models.dart';
import 'movies/movie_detail_page.dart';
import 'movies/movie_form_page.dart';
import 'book/book_detail_page.dart';
import 'book/book_form_page.dart';
/// 书影日历 - 按月展示影视/书籍添加记录
class MediaCalendarPage extends StatefulWidget {
const MediaCalendarPage({super.key});
@override
State<MediaCalendarPage> createState() => _MediaCalendarPageState();
}
class _MediaCalendarPageState extends State<MediaCalendarPage> {
late DateTime _currentMonth;
DateTime? _selectedDay;
// {DateTime(dayOnly): [{path, title, type, data}]}
late Map<DateTime, List<_CalendarItem>> _dayItems;
@override
void initState() {
super.initState();
final now = DateTime.now();
_currentMonth = DateTime(now.year, now.month);
_selectedDay = DateTime(now.year, now.month, now.day);
_buildDayMap();
}
void _buildDayMap() {
final provider = context.read<AppProvider>();
final map = <DateTime, List<_CalendarItem>>{};
for (final m in provider.movies.where((m) => !m.isDeleted)) {
if (m.posterPath == null || m.posterPath!.isEmpty) continue;
final day = DateTime(m.createdAt.year, m.createdAt.month, m.createdAt.day);
map.putIfAbsent(day, () => []);
map[day]!.add(_CalendarItem(
path: m.posterPath!,
title: m.title,
type: 'movie',
data: m,
));
}
for (final b in provider.books.where((b) => !b.isDeleted)) {
if (b.coverPath == null || b.coverPath!.isEmpty) continue;
final day = DateTime(b.createdAt.year, b.createdAt.month, b.createdAt.day);
map.putIfAbsent(day, () => []);
map[day]!.add(_CalendarItem(
path: b.coverPath!,
title: b.title,
type: 'book',
data: b,
));
}
_dayItems = map;
}
void _prevMonth() {
setState(() {
_currentMonth = DateTime(_currentMonth.year, _currentMonth.month - 1);
_selectedDay = null;
});
}
void _nextMonth() {
setState(() {
_currentMonth = DateTime(_currentMonth.year, _currentMonth.month + 1);
_selectedDay = null;
});
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
return Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(title: const Text('书影日历')),
body: Column(
children: [
_buildMonthHeader(colors),
_buildWeekdayLabels(colors),
Expanded(
child: _selectedDay != null && (_dayItems[_selectedDay]?.isNotEmpty ?? false)
? Column(
children: [
SingleChildScrollView(
child: _buildCalendarGrid(colors, today),
),
Expanded(
child: _buildSelectedDayDetail(colors),
),
],
)
: SingleChildScrollView(
child: Column(
children: [
_buildCalendarGrid(colors, today),
if (_selectedDay != null) _buildSelectedDayDetail(colors),
const SizedBox(height: 80),
],
),
),
),
],
),
floatingActionButton: _selectedDay != null
? FloatingActionButton(
onPressed: () => _showAddMenu(context),
backgroundColor: colors.primary,
child: Icon(Icons.add, color: colors.onPrimary),
)
: null,
);
}
// ─── 月份标题 ───
Widget _buildMonthHeader(ColorScheme colors) {
final months = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
children: [
IconButton(
onPressed: _prevMonth,
icon: Icon(Icons.chevron_left, color: colors.onSurface.withValues(alpha: 0.6)),
),
Expanded(
child: Text(
'${_currentMonth.year}${months[_currentMonth.month - 1]}',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: colors.onSurface),
),
),
IconButton(
onPressed: _nextMonth,
icon: Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.6)),
),
],
),
);
}
// ─── 星期头 ───
Widget _buildWeekdayLabels(ColorScheme colors) {
const weekdays = ['', '', '', '', '', '', ''];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: weekdays.map((d) => Expanded(
child: Center(child: Text(d, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: colors.onSurface.withValues(alpha: 0.35)))),
)).toList(),
),
);
}
// ─── 日历网格 ───
static const double _cellHeight = 62;
Widget _buildCalendarGrid(ColorScheme colors, DateTime today) {
final firstDay = DateTime(_currentMonth.year, _currentMonth.month, 1);
final lastDay = DateTime(_currentMonth.year, _currentMonth.month + 1, 0);
final startOffset = firstDay.weekday - 1;
final totalDays = lastDay.day;
final totalCells = startOffset + totalDays;
final rows = (totalCells / 7).ceil();
return Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 8),
child: Column(
children: List.generate(rows, (row) {
return SizedBox(
height: _cellHeight,
child: Row(
children: List.generate(7, (col) {
final index = row * 7 + col;
if (index < startOffset || index >= startOffset + totalDays) {
return const Expanded(child: SizedBox());
}
final day = index - startOffset + 1;
final date = DateTime(_currentMonth.year, _currentMonth.month, day);
final isToday = date == today;
final isSelected = _selectedDay == date;
final items = _dayItems[date] ?? [];
return Expanded(child: _buildDayCell(colors, date, day, isToday, isSelected, items));
}),
),
);
}),
),
);
}
Widget _buildDayCell(ColorScheme colors, DateTime date, int day, bool isToday, bool isSelected, List<_CalendarItem> items) {
final hasItems = items.isNotEmpty;
return GestureDetector(
onTap: () => setState(() => _selectedDay = date),
child: Container(
margin: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: isSelected
? colors.primary.withValues(alpha: 0.08)
: hasItems
? colors.surfaceContainerHigh
: null,
borderRadius: BorderRadius.circular(10),
border: isToday
? Border.all(color: colors.primary, width: 1.5)
: isSelected
? Border.all(color: colors.primary.withValues(alpha: 0.3), width: 1)
: null,
),
child: hasItems
? _buildImageCell(colors, day, items, isToday)
: Center(
child: Text(
'$day',
style: TextStyle(
fontSize: 13,
fontWeight: isToday ? FontWeight.w600 : FontWeight.normal,
color: isToday
? colors.primary
: colors.onSurface.withValues(alpha: 0.35),
),
),
),
),
);
}
Widget _buildImageCell(ColorScheme colors, int day, List<_CalendarItem> items, bool isToday) {
return ClipRRect(
borderRadius: BorderRadius.circular(9),
child: Stack(
fit: StackFit.expand,
children: [
Image(
image: FileImage(File(items.first.path)),
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
color: colors.surfaceContainerHighest,
child: Center(child: Icon(Icons.image_outlined, size: 16, color: colors.onSurface.withValues(alpha: 0.2))),
),
),
// 底部渐变 + 日期
Positioned(
left: 0, right: 0, bottom: 0,
child: Container(
padding: const EdgeInsets.fromLTRB(4, 12, 4, 2),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black.withValues(alpha: 0.55)],
),
),
child: Text(
'$day',
style: TextStyle(
fontSize: 10,
fontWeight: isToday ? FontWeight.w700 : FontWeight.w500,
color: isToday ? const Color(0xFFFFD54F) : Colors.white,
),
),
),
),
// +N 标记
if (items.length > 1)
Positioned(
top: 3, right: 3,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(6),
),
child: Text('+${items.length - 1}', style: const TextStyle(fontSize: 9, fontWeight: FontWeight.w600, color: Colors.white)),
),
),
],
),
);
}
// ─── 选中日期的详情 ───
Widget _buildSelectedDayDetail(ColorScheme colors) {
final items = _dayItems[_selectedDay] ?? [];
if (items.isEmpty) {
return Container(
padding: const EdgeInsets.all(16),
child: Text(
'${_selectedDay!.month}${_selectedDay!.day}日 暂无记录',
style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)),
),
);
}
return Container(
decoration: BoxDecoration(
border: Border(top: BorderSide(color: colors.outlineVariant, width: 0.5)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 6),
child: Row(
children: [
Text(
'${_selectedDay!.month}${_selectedDay!.day}',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.6)),
),
const SizedBox(width: 6),
Text(
'${items.length}条记录',
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35)),
),
],
),
),
Expanded(
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: items.length,
separatorBuilder: (_, __) => Divider(height: 0.5, color: colors.outlineVariant),
itemBuilder: (_, i) {
final item = items[i];
return ListTile(
contentPadding: EdgeInsets.zero,
leading: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: SizedBox(
width: 40, height: 40,
child: Image(
image: FileImage(File(item.path)),
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
color: colors.surfaceContainerHighest,
child: Icon(Icons.image_outlined, size: 16, color: colors.onSurface.withValues(alpha: 0.2)),
),
),
),
),
title: Text(item.title, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface), maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: item.type == 'movie' ? const Color(0xFF4A90D9).withValues(alpha: 0.1) : const Color(0xFF7E57C2).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
item.type == 'movie' ? '影视' : '书籍',
style: TextStyle(fontSize: 11, color: item.type == 'movie' ? const Color(0xFF4A90D9) : const Color(0xFF7E57C2)),
),
),
onTap: () {
if (item.type == 'movie') {
Navigator.push(context, MaterialPageRoute(builder: (_) => MovieDetailPage(movie: item.data as Movie)));
} else {
Navigator.push(context, MaterialPageRoute(builder: (_) => BookDetailPage(book: item.data as Book)));
}
},
);
},
),
),
const SizedBox(height: 8),
],
),
);
}
void _showAddMenu(BuildContext context) {
final colors = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
backgroundColor: colors.surface,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (ctx) => SafeArea(
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(width: 36, height: 4, margin: const EdgeInsets.only(top: 12, bottom: 16),
decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2))),
Align(alignment: Alignment.centerLeft, child: Padding(padding: const EdgeInsets.symmetric(horizontal: 20),
child: Text('添加记录', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)))),
const SizedBox(height: 8),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)),
child: Icon(Icons.movie_outlined, size: 20, color: const Color(0xFF4A90D9))),
title: Text('添加影视', style: TextStyle(fontSize: 14, color: colors.onSurface)),
subtitle: Text('记录一部影视作品', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))),
trailing: Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25)),
onTap: () {
Navigator.pop(ctx);
Navigator.push(context, MaterialPageRoute(builder: (_) => const MovieFormPage()));
},
),
Divider(height: 0.5, indent: 20, endIndent: 20, color: colors.outlineVariant),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)),
child: Icon(Icons.menu_book_outlined, size: 20, color: const Color(0xFF7E57C2))),
title: Text('添加书籍', style: TextStyle(fontSize: 14, color: colors.onSurface)),
subtitle: Text('记录一本书籍', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))),
trailing: Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25)),
onTap: () {
Navigator.pop(ctx);
Navigator.push(context, MaterialPageRoute(builder: (_) => const BookFormPage()));
},
),
const SizedBox(height: 12),
]),
),
);
}
}
class _CalendarItem {
final String path;
final String title;
final String type;
final dynamic data;
_CalendarItem({
required this.path,
required this.title,
required this.type,
required this.data,
});
}

View File

@@ -0,0 +1,400 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/app_provider.dart';
import '../models/data_models.dart';
import 'movies/movie_detail_page.dart';
import 'book/book_detail_page.dart';
/// 角色信息页面 - 列出所有导演/主演/编剧/作者
class PersonListPage extends StatefulWidget {
const PersonListPage({super.key});
@override
State<PersonListPage> createState() => _PersonListPageState();
}
class _PersonListPageState extends State<PersonListPage> {
String _filter = 'all'; // all / 导演 / 编剧 / 主演 / 作者
String _searchQuery = '';
final _searchController = TextEditingController();
bool _loading = false;
@override
void initState() {
super.initState();
_refresh();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _refresh() async {
setState(() => _loading = true);
final provider = context.read<AppProvider>();
await Future.wait([
provider.loadMovies(),
provider.loadBooks(),
]);
if (mounted) setState(() => _loading = false);
}
List<_PersonEntry> _buildPersons() {
final provider = context.read<AppProvider>();
final map = <String, _PersonEntry>{};
void addRole(String name, String role, {Movie? movie, Book? book}) {
if (name.trim().isEmpty) return;
final key = name.trim();
map.putIfAbsent(key, () => _PersonEntry(name: key));
map[key]!.roles.add(role);
if (movie != null && !map[key]!.movies.any((m) => m.id == movie.id)) {
map[key]!.movies.add(movie);
}
if (book != null && !map[key]!.books.any((b) => b.id == book.id)) {
map[key]!.books.add(book);
}
}
for (final m in provider.movies.where((m) => !m.isDeleted)) {
for (final d in m.directors) addRole(d, '导演', movie: m);
for (final w in m.writers) addRole(w, '编剧', movie: m);
for (final a in m.actors) addRole(a, '主演', movie: m);
}
for (final b in provider.books.where((b) => !b.isDeleted)) {
for (final a in b.authors) addRole(a, '作者', book: b);
for (final t in b.translators) addRole(t, '译者', book: b);
}
var list = map.values.toList();
list.sort((a, b) => a.name.compareTo(b.name));
return list;
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final allPersons = _buildPersons();
var filtered = allPersons.where((p) {
if (_filter != 'all' && !p.roles.contains(_filter)) return false;
if (_searchQuery.isNotEmpty && !p.name.toLowerCase().contains(_searchQuery.toLowerCase())) return false;
return true;
}).toList();
return Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(
title: const Text('角色信息'),
actions: [
IconButton(
icon: _loading
? SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: colors.onSurface.withValues(alpha: 0.5)))
: const Icon(Icons.refresh),
onPressed: _loading ? null : _refresh,
),
],
),
body: Column(
children: [
// 搜索栏
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Container(
height: 44,
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(22),
),
child: Row(
children: [
const SizedBox(width: 16),
Icon(Icons.search, size: 20, color: colors.onSurface.withValues(alpha: 0.3)),
const SizedBox(width: 10),
Expanded(
child: TextField(
controller: _searchController,
style: TextStyle(fontSize: 15, color: colors.onSurface),
cursorColor: colors.primary,
decoration: InputDecoration(
hintText: '搜索导演、编剧、演员、作者、译者',
hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.3)),
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 12),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
disabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
focusedErrorBorder: InputBorder.none,
filled: false,
),
onChanged: (v) => setState(() => _searchQuery = v.trim()),
),
),
if (_searchQuery.isNotEmpty)
GestureDetector(
onTap: () { _searchController.clear(); setState(() => _searchQuery = ''); FocusManager.instance.primaryFocus?.unfocus(); },
child: Container(
margin: const EdgeInsets.only(right: 10),
padding: const EdgeInsets.all(5),
decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.08), shape: BoxShape.circle),
child: Icon(Icons.close, size: 14, color: colors.onSurface.withValues(alpha: 0.4)),
),
)
else
const SizedBox(width: 16),
],
),
),
),
// 角色筛选
Padding(
padding: const EdgeInsets.fromLTRB(20, 10, 20, 4),
child: Row(
children: [
for (final f in ['all', '导演', '编剧', '主演', '作者', '译者'])
Padding(
padding: const EdgeInsets.only(right: 6),
child: GestureDetector(
onTap: () => setState(() => _filter = f),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: _filter == f ? colors.primary : colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16),
),
child: Text(f == 'all' ? '全部' : f,
style: TextStyle(fontSize: 12, fontWeight: _filter == f ? FontWeight.w600 : FontWeight.normal,
color: _filter == f ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5))),
),
),
),
],
),
),
// 数量
Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 4),
child: Align(
alignment: Alignment.centerLeft,
child: Text('${filtered.length}', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35))),
),
),
// 列表
Expanded(
child: filtered.isEmpty
? Center(child: Text('暂无数据', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.3))))
: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 20),
itemCount: filtered.length,
separatorBuilder: (_, __) => Divider(height: 0.5, color: colors.outlineVariant),
itemBuilder: (_, i) => _buildPersonTile(filtered[i], colors),
),
),
],
),
);
}
Widget _buildPersonTile(_PersonEntry person, ColorScheme colors) {
final roleColors = {
'导演': const Color(0xFF4A90D9),
'编剧': const Color(0xFF009688),
'主演': const Color(0xFFE91E63),
'作者': const Color(0xFF7E57C2),
'译者': const Color(0xFFFF7043),
};
final totalWorks = person.movies.length + person.books.length;
return ListTile(
contentPadding: const EdgeInsets.symmetric(vertical: 4),
leading: CircleAvatar(
radius: 20,
backgroundColor: colors.surfaceContainerHighest,
child: Text(
person.name.isNotEmpty ? person.name[0] : '?',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.5)),
),
),
title: Text(person.name, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface)),
subtitle: Padding(
padding: const EdgeInsets.only(top: 4),
child: Wrap(
spacing: 4,
runSpacing: 4,
children: [
for (final role in person.roles.toSet())
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: (roleColors[role] ?? colors.outline).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(role, style: TextStyle(fontSize: 10, color: roleColors[role] ?? colors.onSurface)),
),
Text('$totalWorks 部作品', style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.35))),
],
),
),
trailing: Icon(Icons.chevron_right, size: 18, color: colors.onSurface.withValues(alpha: 0.25)),
onTap: () => Navigator.push(context, MaterialPageRoute(
builder: (_) => _PersonDetailPage(person: person),
)),
);
}
}
// ─── 人物详情页(只读展示)───
class _PersonDetailPage extends StatelessWidget {
final _PersonEntry person;
const _PersonDetailPage({required this.person});
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final roleColors = {
'导演': const Color(0xFF4A90D9),
'编剧': const Color(0xFF009688),
'主演': const Color(0xFFE91E63),
'作者': const Color(0xFF7E57C2),
'译者': const Color(0xFFFF7043),
};
final movieItems = <_WorkItem>[];
final bookItems = <_WorkItem>[];
for (final m in person.movies) {
final roles = <String>[];
if (m.directors.contains(person.name)) roles.add('导演');
if (m.writers.contains(person.name)) roles.add('编剧');
if (m.actors.contains(person.name)) roles.add('主演');
movieItems.add(_WorkItem(title: m.title, roles: roles, path: m.posterPath, data: m));
}
for (final b in person.books) {
final roles = <String>[];
if (b.authors.contains(person.name)) roles.add('作者');
if (b.translators.contains(person.name)) roles.add('译者');
bookItems.add(_WorkItem(title: b.title, roles: roles, path: b.coverPath, data: b));
}
return Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(title: Text(person.name)),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 40),
children: [
// 角色标签
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (final role in person.roles.toSet())
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: (roleColors[role] ?? colors.outline).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(role, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: roleColors[role] ?? colors.onSurface)),
),
],
),
const SizedBox(height: 20),
// 影视作品
if (movieItems.isNotEmpty) ...[
Text('影视作品(${movieItems.length}', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.6))),
const SizedBox(height: 8),
for (final item in movieItems) _buildWorkTile(context, item, colors, isMovie: true),
const SizedBox(height: 16),
],
// 书籍作品
if (bookItems.isNotEmpty) ...[
Text('书籍作品(${bookItems.length}', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.6))),
const SizedBox(height: 8),
for (final item in bookItems) _buildWorkTile(context, item, colors, isMovie: false),
],
],
),
);
}
Widget _buildWorkTile(BuildContext context, _WorkItem item, ColorScheme colors, {required bool isMovie}) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: InkWell(
onTap: () {
if (isMovie) {
Navigator.push(context, MaterialPageRoute(builder: (_) => MovieDetailPage(movie: item.data as Movie)));
} else {
Navigator.push(context, MaterialPageRoute(builder: (_) => BookDetailPage(book: item.data as Book)));
}
},
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: colors.outlineVariant, width: 0.5),
),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: SizedBox(
width: 44, height: 44,
child: item.path != null && item.path!.isNotEmpty
? Image(image: FileImage(File(item.path!)), fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(color: colors.surfaceContainerHighest,
child: Icon(Icons.image_outlined, size: 16, color: colors.onSurface.withValues(alpha: 0.2))))
: Container(color: colors.surfaceContainerHighest,
child: Icon(isMovie ? Icons.movie_outlined : Icons.menu_book_outlined,
size: 16, color: colors.onSurface.withValues(alpha: 0.3))),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(item.title, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface),
maxLines: 1, overflow: TextOverflow.ellipsis),
const SizedBox(height: 3),
Text(item.roles.join(' · '), style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))),
]),
),
Icon(Icons.chevron_right, size: 16, color: colors.onSurface.withValues(alpha: 0.2)),
],
),
),
),
);
}
}
// ─── 数据模型 ───
class _PersonEntry {
final String name;
final Set<String> roles = {};
final List<Movie> movies = [];
final List<Book> books = [];
_PersonEntry({required this.name});
}
class _WorkItem {
final String title;
final List<String> roles;
final String? path;
final dynamic data;
_WorkItem({required this.title, required this.roles, this.path, required this.data});
}

View File

@@ -0,0 +1,993 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:provider/provider.dart';
import '../providers/app_provider.dart';
import '../models/data_models.dart';
import '../utils/user_prefs.dart';
import '../widgets/fade_in_local_image.dart';
/// 数据统计页面 - 多维度数据分析
class StatisticsPage extends StatefulWidget {
const StatisticsPage({super.key});
@override
State<StatisticsPage> createState() => _StatisticsPageState();
}
class _StatisticsPageState extends State<StatisticsPage> {
int _cloudTabIndex = 0;
bool get _showMovies => UserPrefs().showMovieTab;
bool get _showBooks => UserPrefs().showBookTab;
bool get _showNotes => UserPrefs().showNoteTab;
// 缓存过滤后的列表,避免每次 build 都重新过滤
List<Movie>? _cachedMovies;
List<Book>? _cachedBooks;
List<Note>? _cachedNotes;
List<Movie>? _filteredMovies;
List<Book>? _filteredBooks;
List<Note>? _filteredNotes;
(List<Movie>, List<Book>, List<Note>) _getFilteredLists(
List<Movie> movies, List<Book> books, List<Note> notes) {
if (!identical(movies, _cachedMovies) ||
!identical(books, _cachedBooks) ||
!identical(notes, _cachedNotes)) {
_cachedMovies = movies;
_cachedBooks = books;
_cachedNotes = notes;
_filteredMovies = movies.where((m) => !m.isDeleted).toList();
_filteredBooks = books.where((b) => !b.isDeleted).toList();
_filteredNotes = notes.where((n) => !n.isDeleted).toList();
}
return (_filteredMovies!, _filteredBooks!, _filteredNotes!);
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final movies = context.select<AppProvider, List<Movie>>((p) => p.movies);
final books = context.select<AppProvider, List<Book>>((p) => p.books);
final notes = context.select<AppProvider, List<Note>>((p) => p.notes);
final (fm, fb, fn) = _getFilteredLists(movies, books, notes);
return Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(title: const Text('数据统计')),
body: ListView(
padding: const EdgeInsets.all(20),
children: [
// 1. 总览
_buildOverview(fm, fb, fn),
const SizedBox(height: 28),
// 2. 状态分布
if (_showMovies) ...[
_buildStatusSection('影视状态分布', fm, (m) => m.status, {'已看': 'watched', '在看': 'watching', '想看': 'want_to_watch'}),
const SizedBox(height: 28),
],
if (_showBooks) ...[
_buildStatusSection('阅读状态分布', fb, (b) => b.status, {'已读': 'read', '在读': 'reading', '想读': 'want_to_read'}),
const SizedBox(height: 28),
],
// 3. 习惯洞察
_buildHabitsInsight(fm, fb, fn),
const SizedBox(height: 28),
// 4. 类型偏好雷达图
_buildGenreRadar(fm, fb),
const SizedBox(height: 28),
// 5. 导演/作者 TOP 5
_buildDirectorTop5(fm),
const SizedBox(height: 28),
_buildAuthorTop5(fb),
const SizedBox(height: 28),
// 6. 高分之最
_buildTopRated(fm, fb),
const SizedBox(height: 28),
// 7. 评分分布
_buildRatingDistribution(fm, fb),
const SizedBox(height: 28),
// 8. 年度趋势
_buildYearlyTrend(fm, fb, fn),
const SizedBox(height: 28),
// 9. 星期分布
_buildWeekdayDistribution(fm, fb, fn),
const SizedBox(height: 28),
// 10. 累计增长
_buildCumulativeGrowth(fm, fb, fn),
const SizedBox(height: 28),
// 11. 标签词云
_buildTagCloud(fm, fb, fn),
const SizedBox(height: 28),
// 12+13. 马拉松 + 标签之最
_buildFunStats(fm, fb, fn),
const SizedBox(height: 80),
],
),
);
}
// ─── 1. 总览区域 ──────────────────────────────────────────────────────
Widget _buildOverview(List<Movie> movies, List<Book> books, List<Note> notes) {
final colors = Theme.of(context).colorScheme;
final completed = movies.where((m) => m.status == 'watched').length +
books.where((b) => b.status == 'read').length;
final totalWithStatus = movies.length + books.length;
final completionRate = totalWithStatus > 0 ? completed / totalWithStatus : 0.0;
// 本月新增
final now = DateTime.now();
final thisMonth = movies.where((m) => m.createdAt.year == now.year && m.createdAt.month == now.month).length +
books.where((b) => b.createdAt.year == now.year && b.createdAt.month == now.month).length +
notes.where((n) => n.createdAt.year == now.year && n.createdAt.month == now.month).length;
final lastMonthDate = DateTime(now.year, now.month - 1, 1);
final lastMonth = movies.where((m) => m.createdAt.year == lastMonthDate.year && m.createdAt.month == lastMonthDate.month).length +
books.where((b) => b.createdAt.year == lastMonthDate.year && b.createdAt.month == lastMonthDate.month).length +
notes.where((n) => n.createdAt.year == lastMonthDate.year && n.createdAt.month == lastMonthDate.month).length;
final monthDiff = thisMonth - lastMonth;
// 平均评分
final allRatings = [...movies, ...books].map((e) => (e as dynamic).rating as double?).where((r) => r != null && r > 0).toList();
final avgRating = allRatings.isNotEmpty ? allRatings.reduce((a, b) => a! + b!)! / allRatings.length : 0.0;
// 记录天数
final allDates = [...movies.map((m) => m.createdAt), ...books.map((b) => b.createdAt), ...notes.map((n) => n.createdAt)];
final daysTracked = allDates.isNotEmpty ? now.difference(allDates.reduce((a, b) => a.isBefore(b) ? a : b)).inDays + 1 : 0;
return Row(
children: [
_buildOverviewCard('完成率', completionRate == 0 ? '-' : '${(completionRate * 100).toStringAsFixed(0)}%', Icons.check_circle_outline, colors.primary, subtitle: completionRate > 0 ? '已看+已读' : null),
const SizedBox(width: 10),
_buildOverviewCard('本月新增', '$thisMonth', Icons.trending_up, const Color(0xFF66BB6A), subtitle: monthDiff >= 0 ? '$monthDiff' : '${monthDiff.abs()}'),
const SizedBox(width: 10),
_buildOverviewCard('平均评分', avgRating > 0 ? avgRating.toStringAsFixed(1) : '-', Icons.star_outline, const Color(0xFFFFB800), subtitle: avgRating > 0 ? '/ 10' : null),
const SizedBox(width: 10),
_buildOverviewCard('记录天数', daysTracked > 0 ? '$daysTracked' : '-', Icons.calendar_today_outlined, const Color(0xFF7E57C2), subtitle: daysTracked > 0 ? '' : null),
],
);
}
Widget _buildOverviewCard(String label, String value, IconData icon, Color color, {String? subtitle}) {
final colors = Theme.of(context).colorScheme;
return Expanded(
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Icon(icon, size: 20, color: color),
const SizedBox(height: 8),
Text(value, style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: color)),
if (subtitle != null) ...[
const SizedBox(height: 2),
Text(subtitle, style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.4))),
],
const SizedBox(height: 4),
Text(label, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.5))),
],
),
),
);
}
// ─── 2. 状态分布 ────────────────────────────────────────────────────────
Widget _buildStatusSection(String title, List items, String Function(dynamic) getStatus, Map<String, String> labels) {
final colors = Theme.of(context).colorScheme;
final total = items.length;
return _buildCard(
title: title,
child: Column(
children: labels.entries.map((e) {
final count = items.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: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6))),
const Spacer(),
Text('$count', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface)),
const SizedBox(width: 4),
Text('${(pct * 100).toStringAsFixed(0)}%', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.3))),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(value: pct, backgroundColor: colors.outlineVariant, color: colors.primary, minHeight: 6),
),
],
),
);
}).toList(),
),
);
}
// ─── 3. 习惯洞察 ────────────────────────────────────────────────────────
Widget _buildHabitsInsight(List<Movie> movies, List<Book> books, List<Note> notes) {
final colors = Theme.of(context).colorScheme;
final allDates = [
...movies.map((m) => m.createdAt),
...books.map((b) => b.createdAt),
...notes.map((n) => n.createdAt),
]..sort();
if (allDates.isEmpty) return const SizedBox.shrink();
// 最活跃月份
final monthCounts = <int, int>{};
for (final d in allDates) {
monthCounts[d.month] = (monthCounts[d.month] ?? 0) + 1;
}
final busiestMonth = monthCounts.entries.isEmpty ? 1 : monthCounts.entries.reduce((a, b) => a.value >= b.value ? a : b).key;
const monthNames = ['', '一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'];
// 记录频率
final firstDate = allDates.first;
final totalMonths = math.max(1, (DateTime.now().year - firstDate.year) * 12 + DateTime.now().month - firstDate.month + 1);
final avgPerMonth = (allDates.length / totalMonths).toStringAsFixed(1);
// 观影/阅读节奏(已看完的平均间隔天数)
final watchedDates = movies.where((m) => m.status == 'watched').map((m) => m.createdAt).toList()..sort();
final readDates = books.where((b) => b.status == 'read').map((b) => b.createdAt).toList()..sort();
final watchedAvgGap = _calcAvgGap(watchedDates);
final readAvgGap = _calcAvgGap(readDates);
return _buildCard(
title: '习惯洞察',
child: Column(
children: [
_buildInsightRow(Icons.calendar_month_outlined, '最活跃月份', monthNames[busiestMonth]),
Divider(height: 1, color: colors.outlineVariant),
_buildInsightRow(Icons.speed_outlined, '记录频率', '平均每月 $avgPerMonth'),
Divider(height: 1, color: colors.outlineVariant),
if (watchedAvgGap > 0)
_buildInsightRow(Icons.movie_outlined, '观影节奏', '平均 ${watchedAvgGap.toStringAsFixed(0)} 天一部'),
if (watchedAvgGap > 0 && readAvgGap > 0)
Divider(height: 1, color: colors.outlineVariant),
if (readAvgGap > 0)
_buildInsightRow(Icons.menu_book_outlined, '阅读节奏', '平均 ${readAvgGap.toStringAsFixed(0)} 天一本'),
],
),
);
}
double _calcAvgGap(List<DateTime> dates) {
if (dates.length < 2) return 0;
final sorted = dates.toList()..sort();
double totalGap = 0;
for (int i = 1; i < sorted.length; i++) {
totalGap += sorted[i].difference(sorted[i - 1]).inDays.toDouble();
}
return totalGap / (sorted.length - 1);
}
Widget _buildInsightRow(IconData icon, String label, String value) {
final colors = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
children: [
Icon(icon, size: 18, color: colors.onSurface.withValues(alpha: 0.5)),
const SizedBox(width: 12),
Text(label, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6))),
const Spacer(),
Text(value, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface)),
],
),
);
}
// ─── 4. 类型偏好雷达图 ──────────────────────────────────────────────────
Widget _buildGenreRadar(List<Movie> movies, List<Book> books) {
final colors = Theme.of(context).colorScheme;
final movieGenres = <String, int>{};
final bookGenres = <String, int>{};
for (final m in movies) { for (final g in m.genres) { movieGenres[g] = (movieGenres[g] ?? 0) + 1; } }
for (final b in books) { for (final g in b.genres) { bookGenres[g] = (bookGenres[g] ?? 0) + 1; } }
final allGenres = <String, int>{};
allGenres.addAll(movieGenres);
for (final e in bookGenres.entries) { allGenres[e.key] = (allGenres[e.key] ?? 0) + e.value; }
final sorted = allGenres.entries.toList()..sort((a, b) => b.value.compareTo(a.value));
final top6 = sorted.take(6).toList();
if (top6.length < 3) return const SizedBox.shrink();
final maxVal = top6.first.value.toDouble();
return _buildCard(
title: '类型偏好',
child: Column(
children: [
SizedBox(
height: 220,
child: RadarChart(
RadarChartData(
radarShape: RadarShape.polygon,
dataSets: [
if (movieGenres.isNotEmpty)
RadarDataSet(
dataEntries: top6.map((e) => RadarEntry(value: (movieGenres[e.key] ?? 0) / math.max(1, maxVal))).toList(),
borderColor: const Color(0xFF4A90D9),
fillColor: const Color(0xFF4A90D9).withValues(alpha: 0.15),
borderWidth: 2,
),
if (bookGenres.isNotEmpty)
RadarDataSet(
dataEntries: top6.map((e) => RadarEntry(value: (bookGenres[e.key] ?? 0) / math.max(1, maxVal))).toList(),
borderColor: const Color(0xFF7E57C2),
fillColor: const Color(0xFF7E57C2).withValues(alpha: 0.15),
borderWidth: 2,
),
],
radarBorderData: BorderSide(color: colors.outlineVariant, width: 0.5),
gridBorderData: BorderSide(color: colors.outlineVariant, width: 0.5),
tickBorderData: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.3), width: 0.5),
ticksTextStyle: TextStyle(fontSize: 9, color: colors.onSurface.withValues(alpha: 0.3)),
titleTextStyle: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.6)),
titlePositionPercentageOffset: 0.15,
getTitle: (index, angle) => RadarChartTitle(text: top6[index].key),
tickCount: 3,
),
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (movieGenres.isNotEmpty) ...[
Container(width: 10, height: 3, decoration: BoxDecoration(color: const Color(0xFF4A90D9), borderRadius: BorderRadius.circular(1.5))),
const SizedBox(width: 4),
Text('影视', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.5))),
const SizedBox(width: 16),
],
if (bookGenres.isNotEmpty) ...[
Container(width: 10, height: 3, decoration: BoxDecoration(color: const Color(0xFF7E57C2), borderRadius: BorderRadius.circular(1.5))),
const SizedBox(width: 4),
Text('书籍', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.5))),
],
],
),
],
),
);
}
// ─── 5. 导演/作者 TOP 5 ────────────────────────────────────────────────
Widget _buildDirectorTop5(List<Movie> movies) {
final colors = Theme.of(context).colorScheme;
final counts = <String, int>{};
for (final m in movies) { for (final d in m.directors) { counts[d] = (counts[d] ?? 0) + 1; } }
final sorted = counts.entries.toList()..sort((a, b) => b.value.compareTo(a.value));
final top5 = sorted.take(5).toList();
if (top5.isEmpty) return const SizedBox.shrink();
final maxVal = top5.first.value.toDouble();
return _buildCard(
title: '导演 TOP 5',
child: Column(
children: top5.map((e) {
final ratio = e.value / maxVal;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
children: [
SizedBox(width: 60, child: Text(e.key, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.7)), overflow: TextOverflow.ellipsis)),
const SizedBox(width: 10),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(value: ratio, backgroundColor: colors.outlineVariant, color: const Color(0xFF4A90D9), minHeight: 4),
),
),
const SizedBox(width: 8),
Text('${e.value}', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colors.onSurface)),
],
),
);
}).toList(),
),
);
}
Widget _buildAuthorTop5(List<Book> books) {
final colors = Theme.of(context).colorScheme;
final counts = <String, int>{};
for (final b in books) { for (final a in b.authors) { counts[a] = (counts[a] ?? 0) + 1; } }
final sorted = counts.entries.toList()..sort((a, b) => b.value.compareTo(a.value));
final top5 = sorted.take(5).toList();
if (top5.isEmpty) return const SizedBox.shrink();
final maxVal = top5.first.value.toDouble();
return _buildCard(
title: '作者 TOP 5',
child: Column(
children: top5.map((e) {
final ratio = e.value / maxVal;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
children: [
SizedBox(width: 60, child: Text(e.key, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.7)), overflow: TextOverflow.ellipsis)),
const SizedBox(width: 10),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(value: ratio, backgroundColor: colors.outlineVariant, color: const Color(0xFF7E57C2), minHeight: 4),
),
),
const SizedBox(width: 8),
Text('${e.value}', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colors.onSurface)),
],
),
);
}).toList(),
),
);
}
// ─── 6. 高分之最 TOP 5 ─────────────────────────────────────────────────
Widget _buildTopRated(List<Movie> movies, List<Book> books) {
final colors = Theme.of(context).colorScheme;
final ratedMovies = movies.where((m) => m.rating != null && m.rating! > 0).toList()
..sort((a, b) => b.rating!.compareTo(a.rating!));
final ratedBooks = books.where((b) => b.rating != null && b.rating! > 0).toList()
..sort((a, b) => b.rating!.compareTo(a.rating!));
if (ratedMovies.isEmpty && ratedBooks.isEmpty) return const SizedBox.shrink();
return _buildCard(
title: '高分之最',
child: Column(
children: [
if (ratedMovies.isNotEmpty) ...[
Text('影视 TOP 5', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.5))),
const SizedBox(height: 8),
...ratedMovies.take(5).map((m) => _buildTopRatedItem(m.title, m.rating!, m.posterPath, colors)),
if (ratedBooks.isNotEmpty) const SizedBox(height: 16),
],
if (ratedBooks.isNotEmpty) ...[
Text('书籍 TOP 5', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.5))),
const SizedBox(height: 8),
...ratedBooks.take(5).map((b) => _buildTopRatedItem(b.title, b.rating!, b.coverPath, colors)),
],
],
),
);
}
Widget _buildTopRatedItem(String title, double rating, String? imagePath, ColorScheme colors) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Container(
width: 32, height: 44,
decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(4)),
clipBehavior: Clip.antiAlias,
child: imagePath != null && imagePath.isNotEmpty
? FadeInLocalImage(path: imagePath, fit: BoxFit.cover, errorWidget: Icon(Icons.image_outlined, size: 14, color: colors.onSurface.withValues(alpha: 0.2)))
: Icon(Icons.image_outlined, size: 14, color: colors.onSurface.withValues(alpha: 0.2)),
),
const SizedBox(width: 10),
Expanded(child: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 13, color: colors.onSurface))),
Icon(Icons.star, size: 16, color: const Color(0xFFFFB800)),
const SizedBox(width: 4),
Text(rating.toStringAsFixed(1), style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: colors.onSurface)),
],
),
);
}
// ─── 7. 评分分布 ────────────────────────────────────────────────────────
Widget _buildRatingDistribution(List<Movie> movies, List<Book> books) {
final colors = Theme.of(context).colorScheme;
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;
final counts = List.filled(10, 0);
for (final r in allRatings) { counts[(r.round()).clamp(1, 10) - 1]++; }
final maxCount = counts.reduce((a, b) => a > b ? a : b).toDouble();
if (maxCount == 0) return const SizedBox.shrink();
const barColors = [
Color(0xFFBDBDBD), Color(0xFFBDBDBD), Color(0xFFFFCC80), Color(0xFFFFCC80),
Color(0xFFFFB74D), Color(0xFFFFB74D), Color(0xFFFFA726), Color(0xFFFFA726),
Color(0xFFFFB800), Color(0xFFFFB800),
];
return _buildCard(
title: '评分分布',
child: Column(
children: [
Row(
children: [
Text('平均评分', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6))),
const Spacer(),
Text(avg.toStringAsFixed(1), style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: colors.onSurface)),
Text(' / 10', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.3))),
],
),
const SizedBox(height: 20),
SizedBox(
height: 160,
child: BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: maxCount * 1.2,
minY: 0,
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
getTooltipColor: (_) => colors.inverseSurface,
getTooltipItem: (group, groupIndex, rod, rodIndex) {
return BarTooltipItem('${group.x + 1}${rod.toY.toInt()}', TextStyle(color: colors.onInverseSurface, fontSize: 12, fontWeight: FontWeight.w600));
},
),
),
titlesData: FlTitlesData(
show: true,
bottomTitles: AxisTitles(sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) => Padding(
padding: const EdgeInsets.only(top: 6),
child: Text('${value.toInt() + 1}', style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.5))),
),
reservedSize: 24,
)),
leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
borderData: FlBorderData(show: false),
gridData: const FlGridData(show: false),
barGroups: List.generate(10, (i) => BarChartGroupData(x: i, barRods: [
BarChartRodData(
toY: counts[i].toDouble(),
color: barColors[i],
width: 20,
borderRadius: const BorderRadius.vertical(top: Radius.circular(4)),
backDrawRodData: BackgroundBarChartRodData(show: true, toY: maxCount * 1.2, color: colors.outlineVariant.withValues(alpha: 0.3)),
),
])),
),
),
),
const SizedBox(height: 8),
Text('星级评分', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))),
],
),
);
}
// ─── 8. 年度趋势折线图 ──────────────────────────────────────────────────
Widget _buildYearlyTrend(List<Movie> movies, List<Book> books, List<Note> notes) {
final colors = Theme.of(context).colorScheme;
final now = DateTime.now();
final months = List.generate(12, (i) {
final d = DateTime(now.year, now.month - (11 - i), 1);
return '${d.month}';
});
List<int> countByMonth(List items) {
return List.generate(12, (i) {
final d = DateTime(now.year, now.month - (11 - i), 1);
return items.where((item) => item.createdAt.year == d.year && item.createdAt.month == d.month).length;
});
}
final movieData = countByMonth(movies);
final bookData = countByMonth(books);
final noteData = countByMonth(notes);
final allValues = [...movieData, ...bookData, ...noteData];
final maxVal = allValues.isEmpty ? 1 : allValues.reduce((a, b) => a > b ? a : b);
final safeMax = maxVal == 0 ? 1 : maxVal;
return _buildCard(
title: '年度趋势',
child: Column(
children: [
SizedBox(
height: 180,
child: LineChart(
LineChartData(
minY: 0,
maxY: (safeMax * 1.3).toDouble(),
lineBarsData: [
_buildLineData(movieData, const Color(0xFF4A90D9)),
_buildLineData(bookData, const Color(0xFF7E57C2)),
_buildLineData(noteData, const Color(0xFF66BB6A)),
],
titlesData: FlTitlesData(
bottomTitles: AxisTitles(sideTitles: SideTitles(
showTitles: true,
interval: 2,
getTitlesWidget: (value, meta) {
final idx = value.toInt();
if (idx < 0 || idx >= months.length) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(months[idx], style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.4))),
);
},
reservedSize: 24,
)),
leftTitles: AxisTitles(sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) => Text('${value.toInt()}', style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.3))),
reservedSize: 28,
)),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: math.max(1, safeMax / 3).toDouble(),
getDrawingHorizontalLine: (value) => FlLine(color: colors.outlineVariant, strokeWidth: 0.5),
),
borderData: FlBorderData(show: false),
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
getTooltipColor: (_) => colors.inverseSurface,
getTooltipItems: (spots) => spots.map((s) {
final labels = ['影视', '书籍', '笔记'];
return LineTooltipItem('${labels[s.barIndex]} ${s.y.toInt()}', TextStyle(color: colors.onInverseSurface, fontSize: 12, fontWeight: FontWeight.w600));
}).toList(),
),
),
),
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildLegend(const Color(0xFF4A90D9), '影视'),
const SizedBox(width: 16),
_buildLegend(const Color(0xFF7E57C2), '书籍'),
const SizedBox(width: 16),
_buildLegend(const Color(0xFF66BB6A), '笔记'),
],
),
],
),
);
}
LineChartBarData _buildLineData(List<int> data, Color color) {
return LineChartBarData(
spots: List.generate(12, (i) => FlSpot(i.toDouble(), data[i].toDouble())),
isCurved: true,
color: color,
barWidth: 2,
dotData: FlDotData(show: true, getDotPainter: (spot, percent, bar, index) => FlDotCirclePainter(radius: 3, color: color, strokeWidth: 0)),
belowBarData: BarAreaData(show: true, color: color.withValues(alpha: 0.08)),
);
}
Widget _buildLegend(Color color, String label) {
final colors = Theme.of(context).colorScheme;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(width: 10, height: 3, decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(1.5))),
const SizedBox(width: 4),
Text(label, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.5))),
],
);
}
// ─── 9. 星期分布 ────────────────────────────────────────────────────────
Widget _buildWeekdayDistribution(List<Movie> movies, List<Book> books, List<Note> notes) {
final colors = Theme.of(context).colorScheme;
final allDates = [
...movies.map((m) => m.createdAt),
...books.map((b) => b.createdAt),
...notes.map((n) => n.createdAt),
];
if (allDates.isEmpty) return const SizedBox.shrink();
final weekdayCounts = List.filled(7, 0);
for (final d in allDates) {
weekdayCounts[d.weekday - 1]++;
}
final maxCount = weekdayCounts.reduce((a, b) => a > b ? a : b).toDouble();
if (maxCount == 0) return const SizedBox.shrink();
final dayLabels = ['', '', '', '', '', '', ''];
return _buildCard(
title: '星期分布',
child: SizedBox(
height: 140,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: List.generate(7, (i) {
final ratio = weekdayCounts[i] / maxCount;
final barHeight = (ratio * 96).clamp(4.0, 96.0);
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text('${weekdayCounts[i]}', style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.4))),
const SizedBox(height: 4),
Container(
height: barHeight,
decoration: BoxDecoration(
color: colors.primary.withValues(alpha: 0.6 + ratio * 0.4),
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 6),
Text(dayLabels[i], style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.5))),
],
),
),
);
}),
),
),
);
}
// ─── 10. 累计增长曲线 ──────────────────────────────────────────────────
Widget _buildCumulativeGrowth(List<Movie> movies, List<Book> books, List<Note> notes) {
final colors = Theme.of(context).colorScheme;
final allItems = [...movies.map((m) => m.createdAt), ...books.map((b) => b.createdAt), ...notes.map((n) => n.createdAt)];
if (allItems.isEmpty) return const SizedBox.shrink();
allItems.sort();
// 按月累计
final monthlyCumulative = <int, int>{};
int cumulative = 0;
final now = DateTime.now();
for (int i = 11; i >= 0; i--) {
final d = DateTime(now.year, now.month - i, 1);
final nextMonth = DateTime(d.year, d.month + 1, 1);
final count = allItems.where((date) => !date.isBefore(d) && date.isBefore(nextMonth)).length;
cumulative += count;
monthlyCumulative[11 - i] = cumulative;
}
final maxVal = cumulative.toDouble();
if (maxVal == 0) return const SizedBox.shrink();
return _buildCard(
title: '累计增长',
child: SizedBox(
height: 160,
child: LineChart(
LineChartData(
minY: 0,
maxY: maxVal * 1.2,
lineBarsData: [
LineChartBarData(
spots: List.generate(12, (i) => FlSpot(i.toDouble(), (monthlyCumulative[i] ?? 0).toDouble())),
isCurved: true,
color: colors.primary,
barWidth: 2.5,
dotData: FlDotData(show: true, getDotPainter: (spot, percent, bar, index) => FlDotCirclePainter(radius: 3, color: colors.primary, strokeWidth: 0)),
belowBarData: BarAreaData(show: true, color: colors.primary.withValues(alpha: 0.08)),
),
],
titlesData: FlTitlesData(
bottomTitles: AxisTitles(sideTitles: SideTitles(
showTitles: true,
interval: 2,
getTitlesWidget: (value, meta) {
final d = DateTime(now.year, now.month - (11 - value.toInt()), 1);
return Padding(
padding: const EdgeInsets.only(top: 6),
child: Text('${d.month}', style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.4))),
);
},
reservedSize: 24,
)),
leftTitles: AxisTitles(sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) => Text('${value.toInt()}', style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.3))),
reservedSize: 28,
)),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: math.max(1, maxVal / 3).toDouble(),
getDrawingHorizontalLine: (value) => FlLine(color: colors.outlineVariant, strokeWidth: 0.5),
),
borderData: FlBorderData(show: false),
),
),
),
);
}
// ─── 11. 标签词云 ──────────────────────────────────────────────────────
Widget _buildTagCloud(List<Movie> movies, List<Book> books, List<Note> notes) {
final colors = Theme.of(context).colorScheme;
final tabs = <String>[];
if (_showMovies) tabs.add('影视');
if (_showBooks) tabs.add('书籍');
if (_showNotes) tabs.add('笔记');
if (tabs.isEmpty) return const SizedBox.shrink();
if (_cloudTabIndex >= tabs.length) _cloudTabIndex = 0;
final tagCounts = <String, int>{};
switch (tabs[_cloudTabIndex]) {
case '影视':
for (final m in movies) { for (final g in m.genres) { tagCounts[g] = (tagCounts[g] ?? 0) + 1; } }
break;
case '书籍':
for (final b in books) { for (final g in b.genres) { tagCounts[g] = (tagCounts[g] ?? 0) + 1; } }
break;
case '笔记':
for (final n in notes) { for (final t in n.tags) { tagCounts[t] = (tagCounts[t] ?? 0) + 1; } }
break;
}
final sorted = tagCounts.entries.toList()..sort((a, b) => b.value.compareTo(a.value));
if (sorted.isEmpty) return const SizedBox.shrink();
final maxCount = sorted.first.value;
final minCount = sorted.last.value;
final range = math.max(maxCount - minCount, 1);
const cloudColors = [
Color(0xFFE53935), Color(0xFF4A90D9), Color(0xFF7E57C2),
Color(0xFF66BB6A), Color(0xFFFF8F00), Color(0xFF00ACC1),
Color(0xFF5C6BC0), Color(0xFF26A69A), Color(0xFF8D6E63),
];
return _buildCard(
title: '标签词云',
child: Column(children: [
Row(
children: tabs.map((t) {
final i = tabs.indexOf(t);
final selected = _cloudTabIndex == i;
return Expanded(
child: GestureDetector(
onTap: () => setState(() => _cloudTabIndex = i),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8),
margin: EdgeInsets.only(right: i < tabs.length - 1 ? 6 : 0),
decoration: BoxDecoration(
color: selected ? colors.primary : colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Text(t, textAlign: TextAlign.center, style: TextStyle(fontSize: 13, fontWeight: selected ? FontWeight.w600 : FontWeight.w500, color: selected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5))),
),
),
);
}).toList(),
),
const SizedBox(height: 16),
Wrap(
spacing: 6,
runSpacing: 6,
children: sorted.map((e) {
final ratio = (e.value - minCount) / range;
final fontSize = (12.0 + ratio * 18.0).roundToDouble();
final c = cloudColors[((ratio * (cloudColors.length - 1)).round()).clamp(0, cloudColors.length - 1)];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 1),
child: Text(e.key, style: TextStyle(fontSize: fontSize, fontWeight: fontSize > 18 ? FontWeight.w700 : FontWeight.w500, color: c, height: 1.3)),
);
}).toList(),
),
]),
);
}
// ─── 12+13. 趣味统计 ──────────────────────────────────────────────────
Widget _buildFunStats(List<Movie> movies, List<Book> books, List<Note> notes) {
final colors = Theme.of(context).colorScheme;
final allItems = [...movies.map((m) => m.createdAt), ...books.map((b) => b.createdAt), ...notes.map((n) => n.createdAt)];
if (allItems.isEmpty) return const SizedBox.shrink();
// 观影马拉松
final sortedDates = allItems.map((d) => DateTime(d.year, d.month, d.day)).toSet().toList()..sort();
int maxStreak = 1, currentStreak = 1;
for (int i = 1; i < sortedDates.length; i++) {
if (sortedDates[i].difference(sortedDates[i - 1]).inDays == 1) {
currentStreak++;
maxStreak = math.max(maxStreak, currentStreak);
} else {
currentStreak = 1;
}
}
// 标签之最
final tagCounts = <String, int>{};
for (final m in movies) { for (final g in m.genres) { tagCounts[g] = (tagCounts[g] ?? 0) + 1; } }
for (final b in books) { for (final g in b.genres) { tagCounts[g] = (tagCounts[g] ?? 0) + 1; } }
for (final n in notes) { for (final t in n.tags) { tagCounts[t] = (tagCounts[t] ?? 0) + 1; } }
final topTag = tagCounts.entries.isEmpty ? null : tagCounts.entries.reduce((a, b) => a.value >= b.value ? a : b);
return Row(
children: [
Expanded(
child: _buildFunCard(Icons.local_fire_department_outlined, '连续记录', maxStreak > 1 ? '$maxStreak' : '-', '最长连续记录天数', const Color(0xFFFF8F00), colors),
),
const SizedBox(width: 12),
Expanded(
child: _buildFunCard(Icons.label_outlined, '最常用标签', topTag != null ? topTag.key : '-', topTag != null ? '使用 ${topTag.value}' : '', const Color(0xFF26A69A), colors),
),
],
);
}
Widget _buildFunCard(IconData icon, String label, String value, String sub, Color color, ColorScheme colors) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(14),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 22, color: color),
const SizedBox(height: 10),
Text(value, style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: color)),
const SizedBox(height: 4),
Text(label, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5))),
if (sub.isNotEmpty) ...[
const SizedBox(height: 2),
Text(sub, style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.3))),
],
],
),
);
}
// ─── 通用卡片 ────────────────────────────────────────────────────────
Widget _buildCard({required String title, required Widget child}) {
final colors = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(14),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(width: 3, height: 14, decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(2))),
const SizedBox(width: 8),
Text(title, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface)),
],
),
const SizedBox(height: 16),
child,
],
),
);
}
}

View File

@@ -0,0 +1,637 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/app_provider.dart';
import '../models/data_models.dart';
import '../widgets/fade_in_local_image.dart';
import '../utils/toast_util.dart';
import 'movies/movie_detail_page.dart';
import 'book/book_detail_page.dart';
import 'note/note_detail_page.dart';
/// 漫步页面 - 随机发现内容
class StrollPage extends StatefulWidget {
const StrollPage({super.key});
@override
State<StrollPage> createState() => _StrollPageState();
}
class _StrollPageState extends State<StrollPage> {
final _random = Random();
final List<_StrollItem> _items = [];
final Set<String> _seenIds = {};
late PageController _pageController;
String _filter = 'all'; // all / movie / book / note
@override
void initState() {
super.initState();
_pageController = PageController(viewportFraction: 0.78);
_loadBatch(5);
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
// ─── 数据加载 ───
void _loadBatch(int count) {
final provider = context.read<AppProvider>();
// 按类别分池
final moviePool = <_StrollItem>[];
final bookPool = <_StrollItem>[];
final notePool = <_StrollItem>[];
if (_filter == 'all' || _filter == 'movie') {
for (final m in provider.movies.where((m) => !m.isDeleted)) {
moviePool.add(_StrollItem(
type: 'movie', data: m, id: 'm_${m.id}',
title: m.title,
subtitle: m.alternateTitles.take(2).join(' / '),
detail: _movieDetail(m),
imagePath: m.posterPath,
icon: Icons.movie_outlined, label: '影视',
rating: m.rating, createdAt: m.createdAt,
tags: m.genres.take(3).toList(),
color: const Color(0xFF4A90D9),
));
}
}
if (_filter == 'all' || _filter == 'book') {
for (final b in provider.books.where((b) => !b.isDeleted)) {
bookPool.add(_StrollItem(
type: 'book', data: b, id: 'b_${b.id}',
title: b.title,
subtitle: b.authors.take(2).join(' / '),
detail: _bookDetail(b),
imagePath: b.coverPath,
icon: Icons.menu_book_outlined, label: '书籍',
rating: b.rating, createdAt: b.createdAt,
tags: b.genres.take(3).toList(),
color: const Color(0xFF7E57C2),
));
}
}
if (_filter == 'all' || _filter == 'note') {
for (final n in provider.notes.where((n) => !n.isDeleted)) {
notePool.add(_StrollItem(
type: 'note', data: n, id: 'n_${n.id}',
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,
tags: n.tags.take(3).toList(),
color: const Color(0xFF66BB6A),
));
}
}
// 构建非空类别列表
final pools = <List<_StrollItem>>[];
if (moviePool.isNotEmpty) pools.add(moviePool);
if (bookPool.isNotEmpty) pools.add(bookPool);
if (notePool.isNotEmpty) pools.add(notePool);
if (pools.isEmpty) return;
// 全部模式下等概率选类别,单类别模式下直接选
final target = _items.length + count;
int attempts = 0;
while (_items.length < target && attempts < count * 20) {
attempts++;
final pool = _filter == 'all'
? pools[_random.nextInt(pools.length)]
: pools.first;
final item = _weightedPick(pool);
if (item != null && !_seenIds.contains(item.id)) {
_seenIds.add(item.id);
_items.add(item);
}
}
}
/// 加权随机:评分越高权重越大
_StrollItem? _weightedPick(List<_StrollItem> pool) {
if (pool.isEmpty) return null;
final weights = pool.map((item) {
final r = item.rating ?? 5.0;
return r.clamp(1.0, 10.0);
}).toList();
final total = weights.reduce((a, b) => a + b);
var roll = _random.nextDouble() * total;
for (int i = 0; i < pool.length; i++) {
roll -= weights[i];
if (roll <= 0) return pool[i];
}
return pool.last;
}
void _reshuffle() {
setState(() {
_items.clear();
_seenIds.clear();
_loadBatch(5);
});
}
// ─── 辅助方法 ───
String _movieDetail(Movie m) {
final parts = <String>[];
if (m.summary != null && m.summary!.isNotEmpty) {
parts.add(m.summary!.length > 100 ? '${m.summary!.substring(0, 100)}...' : m.summary!);
}
return parts.join('\n');
}
String _bookDetail(Book b) {
final parts = <String>[];
if (b.publisher != null && b.publisher!.isNotEmpty) parts.add(b.publisher!);
if (b.summary != null && b.summary!.isNotEmpty) {
parts.add(b.summary!.length > 100 ? '${b.summary!.substring(0, 100)}...' : b.summary!);
}
return parts.join('\n');
}
String _timeAgoText(DateTime date) {
final diff = DateTime.now().difference(date);
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 _actionVerb(String type) {
switch (type) {
case 'movie': return '看过';
case 'book': return '读过';
case 'note': return '写下';
default: return '';
}
}
void _openDetail(_StrollItem item) {
switch (item.type) {
case 'movie':
Navigator.push(context, MaterialPageRoute(builder: (_) => MovieDetailPage(movie: item.data as Movie)));
case 'book':
Navigator.push(context, MaterialPageRoute(builder: (_) => BookDetailPage(book: item.data as Book)));
case 'note':
Navigator.push(context, MaterialPageRoute(builder: (_) => NoteDetailPage(note: item.data as Note)));
}
}
void _deleteItem(_StrollItem item) async {
final provider = context.read<AppProvider>();
switch (item.type) {
case 'movie': await provider.removeMovie(item.data.id);
case 'book': await provider.removeBook(item.data.id);
case 'note': await provider.removeNote(item.data.id);
}
setState(() => _items.remove(item));
if (mounted) ToastUtil.show(context, '已删除');
}
// ─── 界面 ───
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final hasContent = _items.isNotEmpty;
return Scaffold(
backgroundColor: colors.surface,
body: Column(
children: [
// 顶部栏
_buildTopBar(colors),
// 类型筛选
_buildFilterBar(colors),
// 内容
Expanded(
child: !hasContent
? _buildEmptyState(colors)
: RefreshIndicator(
onRefresh: () async => _reshuffle(),
color: colors.primary,
child: PageView.builder(
controller: _pageController,
onPageChanged: (index) {
if (index >= _items.length - 2) {
setState(() => _loadBatch(3));
}
},
itemCount: _items.length,
itemBuilder: (context, index) {
return AnimatedBuilder(
animation: _pageController,
builder: (context, child) {
double scale = 1.0;
if (_pageController.hasClients && _pageController.page != null) {
final diff = (_pageController.page! - index).abs();
scale = (1 - diff * 0.08).clamp(0.88, 1.0);
}
return Transform.scale(scale: scale, child: child);
},
child: _buildCard(_items[index], colors),
);
},
),
),
),
],
),
);
}
Widget _buildTopBar(ColorScheme colors) {
return SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Row(
children: [
IconButton(
onPressed: () => Navigator.pop(context),
icon: Icon(Icons.arrow_back_ios_new, size: 20, color: colors.onSurface.withValues(alpha: 0.7)),
),
const Spacer(),
Text('漫步', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
const Spacer(),
GestureDetector(
onTap: _reshuffle,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(16)),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.casino_outlined, size: 14, color: colors.onPrimary),
const SizedBox(width: 4),
Text('随机', style: TextStyle(fontSize: 12, color: colors.onPrimary, fontWeight: FontWeight.w500)),
]),
),
),
],
),
),
);
}
Widget _buildFilterBar(ColorScheme colors) {
final filters = [
('all', '全部', Icons.apps_outlined),
('movie', '影视', Icons.movie_outlined),
('book', '书籍', Icons.menu_book_outlined),
('note', '笔记', Icons.note_outlined),
];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: filters.map((f) {
final selected = _filter == f.$1;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: GestureDetector(
onTap: () {
if (_filter != f.$1) {
setState(() {
_filter = f.$1;
_items.clear();
_seenIds.clear();
_loadBatch(5);
});
}
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
decoration: BoxDecoration(
color: selected ? colors.primary : colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(f.$3, size: 14, color: selected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5)),
const SizedBox(width: 4),
Text(f.$2, style: TextStyle(fontSize: 12, fontWeight: selected ? FontWeight.w600 : FontWeight.normal,
color: selected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5))),
]),
),
),
);
}).toList(),
),
);
}
Widget _buildCard(_StrollItem item, ColorScheme colors) {
final hasImage = item.imagePath != null && item.imagePath!.isNotEmpty;
return GestureDetector(
onTap: () => _openDetail(item),
onDoubleTap: () => ToastUtil.show(context, '已收藏'),
child: hasImage ? _buildImmersiveCard(item, colors) : _buildContentCard(item, colors),
);
}
/// 有图片的卡片:全屏沉浸式
Widget _buildImmersiveCard(_StrollItem item, ColorScheme colors) {
return Container(
margin: const EdgeInsets.symmetric(vertical: 80, horizontal: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 20, offset: const Offset(0, 8))],
),
clipBehavior: Clip.antiAlias,
child: Stack(
fit: StackFit.expand,
children: [
FadeInLocalImage(path: item.imagePath, fit: BoxFit.cover),
// 底部渐变蒙层
Positioned.fill(
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.transparent, Colors.black.withValues(alpha: 0.85)],
begin: Alignment.topCenter, end: Alignment.bottomCenter, stops: const [0.3, 0.7],
),
),
),
),
// 顶部标签 + 评分
Positioned(
top: 16, left: 16, right: 16,
child: _buildTopBadges(item),
),
// 底部内容
Positioned(
left: 20, right: 20, bottom: 20,
child: _buildBottomContent(item, Colors.white),
),
],
),
);
}
/// 无图片的卡片:内容从顶部开始
Widget _buildContentCard(_StrollItem item, ColorScheme colors) {
return Container(
margin: const EdgeInsets.symmetric(vertical: 80, horizontal: 8),
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: colors.outlineVariant, width: 0.5),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 16, offset: const Offset(0, 4))],
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 顶部标签 + 评分
_buildTopBadges(item, textColor: colors.onSurface, bgColor: item.color.withValues(alpha: 0.1)),
const SizedBox(height: 16),
// 内容
Expanded(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标签
if (item.tags.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Wrap(
spacing: 6,
children: item.tags.take(3).map((tag) => Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: item.color.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
),
child: Text(tag, style: TextStyle(fontSize: 11, color: item.color)),
)).toList(),
),
),
// 标题
Text(item.title, maxLines: 2, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: colors.onSurface, height: 1.3)),
// 副标题
if (item.subtitle.isNotEmpty) ...[
const SizedBox(height: 4),
Text(item.subtitle, maxLines: 1, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.5))),
],
// 详情
if (item.detail.isNotEmpty) ...[
const SizedBox(height: 12),
Text(item.detail, maxLines: 6, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.55), height: 1.7)),
],
],
),
),
),
const SizedBox(height: 12),
// 操作栏
Row(children: [
Text('${_timeAgoText(item.createdAt)} ${_actionVerb(item.type)}',
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.3))),
const Spacer(),
_actionBtn(Icons.visibility_outlined, '查看', () => _openDetail(item), colors: colors),
const SizedBox(width: 8),
_actionBtn(Icons.delete_outline, '删除', () => _showDeleteConfirm(item), colors: colors),
]),
],
),
),
);
}
/// 顶部类型标签 + 评分
Widget _buildTopBadges(_StrollItem item, {Color? textColor, Color? bgColor}) {
final fg = textColor ?? Colors.white;
final bg = bgColor ?? Colors.black.withValues(alpha: 0.3);
return Row(children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(20)),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(item.icon, size: 14, color: fg),
const SizedBox(width: 4),
Text(item.label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: fg)),
]),
),
const Spacer(),
if (item.rating != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(20)),
child: Row(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.star, size: 14, color: Color(0xFFFFB800)),
const SizedBox(width: 3),
Text(item.rating!.toStringAsFixed(1), style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: fg)),
]),
),
]);
}
/// 底部内容(沉浸式卡片用,白色文字)
Widget _buildBottomContent(_StrollItem item, Color textColor) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (item.tags.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Wrap(
spacing: 6,
children: item.tags.take(3).map((tag) => Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white.withValues(alpha: 0.2), width: 0.5),
),
child: Text(tag, style: TextStyle(fontSize: 11, color: Colors.white.withValues(alpha: 0.8))),
)).toList(),
),
),
Text(item.title, maxLines: 2, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: textColor, height: 1.3)),
if (item.subtitle.isNotEmpty) ...[
const SizedBox(height: 4),
Text(item.subtitle, maxLines: 1, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 14, color: textColor.withValues(alpha: 0.6))),
],
if (item.detail.isNotEmpty) ...[
const SizedBox(height: 10),
Text(item.detail, maxLines: 3, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 13, color: textColor.withValues(alpha: 0.5), height: 1.6)),
],
const SizedBox(height: 16),
Row(children: [
Text('${_timeAgoText(item.createdAt)} ${_actionVerb(item.type)}',
style: TextStyle(fontSize: 12, color: textColor.withValues(alpha: 0.4))),
const Spacer(),
_actionBtn(Icons.visibility_outlined, '查看', () => _openDetail(item)),
const SizedBox(width: 12),
_actionBtn(Icons.delete_outline, '删除', () => _showDeleteConfirm(item)),
]),
],
);
}
Widget _actionBtn(IconData icon, String label, VoidCallback onTap, {ColorScheme? colors}) {
final fg = colors?.onSurface ?? Colors.white;
final bg = colors != null ? colors.surfaceContainerHighest : Colors.white.withValues(alpha: 0.12);
final border = colors != null ? colors.outlineVariant : Colors.white.withValues(alpha: 0.15);
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: border, width: 0.5),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(icon, size: 14, color: fg.withValues(alpha: 0.8)),
const SizedBox(width: 4),
Text(label, style: TextStyle(fontSize: 12, color: fg.withValues(alpha: 0.8))),
]),
),
);
}
void _showDeleteConfirm(_StrollItem item) {
final colors = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: colors.surface, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
content: Text('确定要删除"${item.title}"吗?删除后可在回收站恢复。',
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))),
ElevatedButton(
onPressed: () { Navigator.pop(ctx); _deleteItem(item); },
style: ElevatedButton.styleFrom(backgroundColor: colors.error, foregroundColor: colors.onError, 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 _buildEmptyState(ColorScheme colors) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(width: 80, height: 80,
decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(20)),
child: Icon(Icons.explore_outlined, size: 40, color: colors.onSurface.withValues(alpha: 0.2))),
const SizedBox(height: 20),
Text('还没有内容', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500, color: colors.onSurface.withValues(alpha: 0.4))),
const SizedBox(height: 8),
Text('去添加一些影视、书籍或笔记吧', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.25))),
],
),
);
}
}
class _StrollItem {
final String type;
final dynamic data;
final String id;
final String title;
final String subtitle;
final String detail;
final String? imagePath;
final IconData icon;
final String label;
final double? rating;
final DateTime createdAt;
final List<String> tags;
final Color color;
_StrollItem({
required this.type,
required this.data,
required this.id,
required this.title,
required this.subtitle,
required this.detail,
this.imagePath,
required this.icon,
required this.label,
this.rating,
required this.createdAt,
this.tags = const [],
required this.color,
});
}