generated from dellevin/template
436 lines
15 KiB
Dart
436 lines
15 KiB
Dart
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;
|
|
}
|