generated from dellevin/template
优化项目结构
This commit is contained in:
435
lib/pages/explore/encounter_page.dart
Normal file
435
lib/pages/explore/encounter_page.dart
Normal 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;
|
||||
}
|
||||
444
lib/pages/explore/media_calendar_page.dart
Normal file
444
lib/pages/explore/media_calendar_page.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
400
lib/pages/explore/person_list_page.dart
Normal file
400
lib/pages/explore/person_list_page.dart
Normal 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});
|
||||
}
|
||||
993
lib/pages/explore/statistics_page.dart
Normal file
993
lib/pages/explore/statistics_page.dart
Normal 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,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
637
lib/pages/explore/stroll_page.dart
Normal file
637
lib/pages/explore/stroll_page.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user