代码优化,功能新增

This commit is contained in:
DelLevin-Home
2026-06-26 23:46:37 +08:00
parent f7ef50a677
commit 45137b96d6
59 changed files with 5041 additions and 4937 deletions

View File

@@ -31,11 +31,15 @@ class _BookDetailPageState extends State<BookDetailPage> {
final ValueNotifier<bool> _draggingCover = ValueNotifier(false);
final GlobalKey _coverImageKey = GlobalKey();
double _coverImageHeight = 0.0;
final ValueNotifier<bool> _showTitle = ValueNotifier(false);
ScrollController? _overlayScrollController;
@override
void dispose() {
_coverOffset.dispose();
_draggingCover.dispose();
_showTitle.dispose();
_overlayScrollController?.dispose();
super.dispose();
}
@@ -66,25 +70,20 @@ class _BookDetailPageState extends State<BookDetailPage> {
backgroundColor: colors.surface,
body: Stack(
children: [
// 图片从导航栏下方开始,固定在顶部
Column(
children: [
SizedBox(height: topSafe + 48),
SizedBox(
height: 320,
width: double.infinity,
child: _buildCoverSection(book),
),
],
),
// 可滚动内容区域从图片底部开始
Positioned(
top: topSafe + 48 + 320,
left: 0, right: 0, bottom: 0,
// 整体可滚动(封面 + 内容一起滑动)
Padding(
padding: EdgeInsets.only(top: topSafe + 48),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 封面图
SizedBox(
height: 320,
width: double.infinity,
child: _buildCoverSection(book),
),
// 详细信息
_buildBasicInfo(book),
Divider(height: 0.5, thickness: 0.5, color: colors.outline),
_buildAuthorsSection(book),
@@ -113,6 +112,11 @@ class _BookDetailPageState extends State<BookDetailPage> {
final screenH = MediaQuery.of(context).size.height;
final hasCover = book.coverPath != null && book.coverPath!.isNotEmpty;
_overlayScrollController ??= ScrollController()..addListener(() {
final show = (_overlayScrollController?.offset ?? 0) > 10;
if (_showTitle.value != show) _showTitle.value = show;
});
return Scaffold(
body: Stack(
children: [
@@ -142,7 +146,7 @@ class _BookDetailPageState extends State<BookDetailPage> {
SafeArea(
child: Column(
children: [
// 顶部栏
// 顶部栏:只在滚动后显示标题
SizedBox(
height: 48,
child: Row(children: [
@@ -151,9 +155,19 @@ class _BookDetailPageState extends State<BookDetailPage> {
icon: const Icon(Icons.arrow_back_ios_new, color: Colors.white, size: 18),
onPressed: () => Navigator.pop(context),
),
const Spacer(),
Text(book.title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white),
maxLines: 1, overflow: TextOverflow.ellipsis),
ValueListenableBuilder<bool>(
valueListenable: _showTitle,
builder: (_, show, __) => AnimatedOpacity(
opacity: show ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.5),
child: Text(book.title,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white),
maxLines: 1, overflow: TextOverflow.ellipsis),
),
),
),
const Spacer(),
_buildStyleButton(),
]),
@@ -161,6 +175,7 @@ class _BookDetailPageState extends State<BookDetailPage> {
// 可滚动内容
Expanded(
child: SingleChildScrollView(
controller: _overlayScrollController,
padding: const EdgeInsets.fromLTRB(16, 8, 16, 100),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -485,33 +500,34 @@ class _BookDetailPageState extends State<BookDetailPage> {
void _showStylePicker() {
final colors = Theme.of(context).colorScheme;
const names = ['默认样式', '毛玻璃层叠'];
const icons = [Icons.article_outlined, Icons.blur_on_outlined];
const subtitles = ['标准封面顶部布局', '封面背景 + 毛玻璃卡片'];
showModalBottomSheet(
context: context,
backgroundColor: colors.surface,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (ctx) => Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('详情页样式', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)),
const SizedBox(height: 16),
Wrap(spacing: 10, runSpacing: 10, children: List.generate(names.length, (i) {
final selected = _detailStyle == i;
return GestureDetector(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(width: 36, height: 4, decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2))),
const SizedBox(height: 20),
Align(alignment: Alignment.centerLeft, child: Text('详情页样式', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface))),
const SizedBox(height: 12),
for (int i = 0; i < names.length; i++) ...[
if (i > 0) Divider(height: 0.5, color: colors.outlineVariant),
ListTile(
contentPadding: EdgeInsets.zero,
leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)),
child: Icon(icons[i], size: 20, color: _detailStyle == i ? colors.primary : colors.onSurface.withValues(alpha: 0.6))),
title: Text(names[i], style: TextStyle(fontSize: 13, fontWeight: _detailStyle == i ? FontWeight.w600 : FontWeight.w500, color: colors.onSurface)),
subtitle: Text(subtitles[i], style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))),
trailing: _detailStyle == i
? Icon(Icons.check_circle, size: 20, color: colors.primary)
: Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25)),
onTap: () { setState(() => _detailStyle = i); UserPrefs().setDetailPageStyle(i); Navigator.pop(ctx); },
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
decoration: BoxDecoration(
color: selected ? colors.primary : colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: selected ? colors.primary : colors.outline, width: 0.5),
),
child: Text(names[i], style: TextStyle(
fontSize: 14, fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
color: selected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.6),
)),
),
);
})),
),
],
const SizedBox(height: 12),
]),
),
);

File diff suppressed because it is too large Load Diff

View File

@@ -25,8 +25,6 @@ class _BookTabPageState extends State<BookTabPage> {
int _offset = 0;
int _lastStatusIndex = -1;
bool _initialized = false;
int _lastDataCount = -1;
DateTime? _lastUpdatedAt;
late ScrollController _scrollController;
AppProvider? _provider;
int _lastScrollSignal = 0;
@@ -42,8 +40,6 @@ class _BookTabPageState extends State<BookTabPage> {
final provider = context.read<AppProvider>();
_provider = provider;
provider.addListener(_onDataChanged);
_lastDataCount = provider.books.length;
if (provider.books.isNotEmpty) _lastUpdatedAt = provider.books.first.updatedAt;
_loadFirst();
});
}
@@ -67,15 +63,8 @@ class _BookTabPageState extends State<BookTabPage> {
}
}
final count = provider.books.length;
final latest = provider.books.isNotEmpty ? provider.books.first.updatedAt : null;
if (count != _lastDataCount || latest != _lastUpdatedAt) {
_lastDataCount = count;
_lastUpdatedAt = latest;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _loadFirst();
});
}
// 数据变化时刷新列表(排序/评分/新增等)
_loadFirst();
}
void _onScroll() {
@@ -91,7 +80,7 @@ class _BookTabPageState extends State<BookTabPage> {
_initialized = true;
final status = _statusMap[statusIdx] ?? 'read';
setState(() { _isLoading = true; _offset = 0; _hasMore = true; });
final list = await provider.loadBooksPaged(status: status, offset: 0);
final list = await provider.loadBooksPaged(status: status, offset: 0, sortMode: UserPrefs().bookSortMode);
if (!mounted) return;
setState(() { _items.clear(); _items.addAll(list); _offset = list.length; _hasMore = list.length >= 20; _isLoading = false; });
}
@@ -101,7 +90,7 @@ class _BookTabPageState extends State<BookTabPage> {
setState(() => _isLoading = true);
final provider = context.read<AppProvider>();
final status = _statusMap[provider.bookStatusIndex] ?? 'read';
final list = await provider.loadBooksPaged(status: status, offset: _offset);
final list = await provider.loadBooksPaged(status: status, offset: _offset, sortMode: UserPrefs().bookSortMode);
if (!mounted) return;
setState(() { _items.addAll(list); _offset += list.length; _hasMore = list.length >= 20; _isLoading = false; });
}

View File

@@ -296,6 +296,24 @@ class _MainContentPageState extends State<MainContentPage> {
provider.setMainTabIndex(tab.originalIndex);
Future.delayed(const Duration(milliseconds: 400), () => _isTabTap = false);
},
onLongPress: tab.label == '影视'
? () => _showSortMenu(context, '影视排序', UserPrefs().movieSortMode, [
(0, '按更新时间排序', Icons.update),
(1, '按创建时间排序', Icons.calendar_today_outlined),
(2, '按评分排序', Icons.star_outline),
], (v) { UserPrefs().setMovieSortMode(v); context.read<AppProvider>().loadMovies(); })
: tab.label == '阅读'
? () => _showSortMenu(context, '书籍排序', UserPrefs().bookSortMode, [
(0, '按更新时间排序', Icons.update),
(1, '按创建时间排序', Icons.calendar_today_outlined),
(2, '按评分排序', Icons.star_outline),
], (v) { UserPrefs().setBookSortMode(v); context.read<AppProvider>().loadBooks(); })
: tab.label == '笔记'
? () => _showSortMenu(context, '笔记排序', UserPrefs().noteSortMode, [
(0, '按更新时间排序', Icons.update),
(1, '按创建时间排序', Icons.calendar_today_outlined),
], (v) { UserPrefs().setNoteSortMode(v); context.read<AppProvider>().loadNotes(); })
: null,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [
@@ -337,7 +355,45 @@ class _MainContentPageState extends State<MainContentPage> {
);
}
// ─── PageView 内容区 ─────────────────────────────────
void _showSortMenu(BuildContext context, String title, int current, List<(int, String, IconData)> options, ValueChanged<int> onSelected) {
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(title, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)))),
const SizedBox(height: 8),
for (int i = 0; i < options.length; i++) ...[
if (i > 0) Divider(height: 0.5, indent: 20, endIndent: 20, color: colors.outlineVariant),
_sortOption(ctx, options[i].$1, options[i].$2, options[i].$3, current, colors, onSelected),
],
const SizedBox(height: 12),
]),
),
);
}
Widget _sortOption(BuildContext ctx, int value, String label, IconData icon, int current, ColorScheme colors, ValueChanged<int> onSelected) {
final selected = current == value;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)),
child: Icon(icon, size: 20, color: selected ? colors.primary : colors.onSurface.withValues(alpha: 0.6))),
title: Text(label, style: TextStyle(fontSize: 14, fontWeight: selected ? FontWeight.w600 : FontWeight.w400, color: colors.onSurface)),
trailing: selected ? Icon(Icons.check, size: 20, color: colors.primary) : null,
onTap: () {
Navigator.pop(ctx);
onSelected(value);
},
);
}
// ─── PageView 内容区 ───
Widget _buildTabContent() {
return Consumer<AppProvider>(
@@ -359,7 +415,11 @@ class _MainContentPageState extends State<MainContentPage> {
}
if (_isTabTap && _pageController.hasClients) {
_pageController.animateToPage(safeIndex, duration: const Duration(milliseconds: 350), curve: Curves.easeInOut);
_isTabTap = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_pageController.hasClients) return;
_pageController.animateToPage(safeIndex, duration: const Duration(milliseconds: 350), curve: Curves.easeInOut);
});
}
return PageView(

View File

@@ -0,0 +1,435 @@
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: 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(
constraints: const BoxConstraints(maxHeight: 200),
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)),
),
],
),
),
Flexible(
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: items.length,
separatorBuilder: (_, __) => Divider(height: 0.5, color: colors.outlineVariant),
itemBuilder: (_, i) {
final item = items[i];
return ListTile(
contentPadding: EdgeInsets.zero,
leading: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: SizedBox(
width: 40, height: 40,
child: Image(
image: FileImage(File(item.path)),
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
color: colors.surfaceContainerHighest,
child: Icon(Icons.image_outlined, size: 16, color: colors.onSurface.withValues(alpha: 0.2)),
),
),
),
),
title: Text(item.title, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface), maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: item.type == 'movie' ? const Color(0xFF4A90D9).withValues(alpha: 0.1) : const Color(0xFF7E57C2).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
item.type == 'movie' ? '影视' : '书籍',
style: TextStyle(fontSize: 11, color: item.type == 'movie' ? const Color(0xFF4A90D9) : const Color(0xFF7E57C2)),
),
),
onTap: () {
if (item.type == 'movie') {
Navigator.push(context, MaterialPageRoute(builder: (_) => MovieDetailPage(movie: item.data as Movie)));
} else {
Navigator.push(context, MaterialPageRoute(builder: (_) => BookDetailPage(book: item.data as Book)));
}
},
);
},
),
),
const SizedBox(height: 8),
],
),
);
}
void _showAddMenu(BuildContext context) {
final colors = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
backgroundColor: colors.surface,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (ctx) => SafeArea(
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(width: 36, height: 4, margin: const EdgeInsets.only(top: 12, bottom: 16),
decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2))),
Align(alignment: Alignment.centerLeft, child: Padding(padding: const EdgeInsets.symmetric(horizontal: 20),
child: Text('添加记录', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)))),
const SizedBox(height: 8),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)),
child: Icon(Icons.movie_outlined, size: 20, color: const Color(0xFF4A90D9))),
title: Text('添加影视', style: TextStyle(fontSize: 14, color: colors.onSurface)),
subtitle: Text('记录一部影视作品', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))),
trailing: Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25)),
onTap: () {
Navigator.pop(ctx);
Navigator.push(context, MaterialPageRoute(builder: (_) => const MovieFormPage()));
},
),
Divider(height: 0.5, indent: 20, endIndent: 20, color: colors.outlineVariant),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)),
child: Icon(Icons.menu_book_outlined, size: 20, color: const Color(0xFF7E57C2))),
title: Text('添加书籍', style: TextStyle(fontSize: 14, color: colors.onSurface)),
subtitle: Text('记录一本书籍', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))),
trailing: Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25)),
onTap: () {
Navigator.pop(ctx);
Navigator.push(context, MaterialPageRoute(builder: (_) => const BookFormPage()));
},
),
const SizedBox(height: 12),
]),
),
);
}
}
class _CalendarItem {
final String path;
final String title;
final String type;
final dynamic data;
_CalendarItem({
required this.path,
required this.title,
required this.type,
required this.data,
});
}

View File

@@ -35,6 +35,8 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
final ValueNotifier<bool> _draggingPoster = ValueNotifier(false);
final GlobalKey _posterImageKey = GlobalKey();
double _posterImageHeight = 0.0;
final ValueNotifier<bool> _showTitle = ValueNotifier(false);
ScrollController? _overlayScrollController;
@override
void initState() {
@@ -48,6 +50,8 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
void dispose() {
_posterOffset.dispose();
_draggingPoster.dispose();
_showTitle.dispose();
_overlayScrollController?.dispose();
super.dispose();
}
@@ -75,25 +79,20 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
backgroundColor: colors.surface,
body: Stack(
children: [
// 图片从导航栏下方开始,固定在顶部
Column(
children: [
SizedBox(height: topSafe + 48),
SizedBox(
height: 320,
width: double.infinity,
child: _buildPosterSection(movie),
),
],
),
// 可滚动内容区域从图片底部开始
Positioned(
top: topSafe + 48 + 320,
left: 0, right: 0, bottom: 0,
// 整体可滚动(图片 + 内容一起滑动)
Padding(
padding: EdgeInsets.only(top: topSafe + 48),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 封面图
SizedBox(
height: 320,
width: double.infinity,
child: _buildPosterSection(movie),
),
// 详细信息
_buildBasicInfo(movie),
Divider(height: 0.5, thickness: 0.5, color: colors.outline),
if (movie.directors.isNotEmpty)
@@ -154,6 +153,12 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
final screenH = MediaQuery.of(context).size.height;
final hasPoster = movie.posterPath != null && movie.posterPath!.isNotEmpty;
// 初始化滚动控制器(只创建一次)
_overlayScrollController ??= ScrollController()..addListener(() {
final show = (_overlayScrollController?.offset ?? 0) > 10;
if (_showTitle.value != show) _showTitle.value = show;
});
return Scaffold(
body: Stack(
children: [
@@ -180,6 +185,7 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
// 内容
SafeArea(
child: Column(children: [
// 顶部栏:只在滚动后显示标题
SizedBox(
height: 48,
child: Row(children: [
@@ -188,15 +194,26 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
icon: const Icon(Icons.arrow_back_ios_new, color: Colors.white, size: 18),
onPressed: () => Navigator.pop(context),
),
const Spacer(),
Text(movie.title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white),
maxLines: 1, overflow: TextOverflow.ellipsis),
ValueListenableBuilder<bool>(
valueListenable: _showTitle,
builder: (_, show, __) => AnimatedOpacity(
opacity: show ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.5),
child: Text(movie.title,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white),
maxLines: 1, overflow: TextOverflow.ellipsis),
),
),
),
const Spacer(),
_buildStyleButton(),
]),
),
Expanded(
child: SingleChildScrollView(
controller: _overlayScrollController,
padding: const EdgeInsets.fromLTRB(16, 8, 16, 100),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
_buildOverlayHeader(movie),
@@ -298,33 +315,34 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
void _showStylePicker() {
final colors = Theme.of(context).colorScheme;
const names = ['默认样式', '毛玻璃层叠'];
const icons = [Icons.article_outlined, Icons.blur_on_outlined];
const subtitles = ['标准封面顶部布局', '封面背景 + 毛玻璃卡片'];
showModalBottomSheet(
context: context,
backgroundColor: colors.surface,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (ctx) => Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('详情页样式', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)),
const SizedBox(height: 16),
Wrap(spacing: 10, runSpacing: 10, children: List.generate(names.length, (i) {
final selected = _detailStyle == i;
return GestureDetector(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(width: 36, height: 4, decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2))),
const SizedBox(height: 20),
Align(alignment: Alignment.centerLeft, child: Text('详情页样式', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface))),
const SizedBox(height: 12),
for (int i = 0; i < names.length; i++) ...[
if (i > 0) Divider(height: 0.5, color: colors.outlineVariant),
ListTile(
contentPadding: EdgeInsets.zero,
leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)),
child: Icon(icons[i], size: 20, color: _detailStyle == i ? colors.primary : colors.onSurface.withValues(alpha: 0.6))),
title: Text(names[i], style: TextStyle(fontSize: 13, fontWeight: _detailStyle == i ? FontWeight.w600 : FontWeight.w500, color: colors.onSurface)),
subtitle: Text(subtitles[i], style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))),
trailing: _detailStyle == i
? Icon(Icons.check_circle, size: 20, color: colors.primary)
: Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25)),
onTap: () { setState(() => _detailStyle = i); UserPrefs().setDetailPageStyle(i); Navigator.pop(ctx); },
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
decoration: BoxDecoration(
color: selected ? colors.primary : colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: selected ? colors.primary : colors.outline, width: 0.5),
),
child: Text(names[i], style: TextStyle(
fontSize: 14, fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
color: selected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.6),
)),
),
);
})),
),
],
const SizedBox(height: 12),
]),
),
);

File diff suppressed because it is too large Load Diff

View File

@@ -126,31 +126,34 @@ class _MovieSharePageState extends State<MovieSharePage> {
void _showStylePicker() {
final colors = Theme.of(context).colorScheme;
const icons = [Icons.image_outlined, Icons.confirmation_num_outlined, Icons.local_movies_outlined, Icons.card_giftcard_outlined];
const subtitles = ['简约海报风格', '经典复古票根', '电影票票根样式', '高端收藏版票根'];
showModalBottomSheet(
context: context,
backgroundColor: colors.surface,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (ctx) => Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('选择样式', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)),
const SizedBox(height: 16),
Wrap(spacing: 10, runSpacing: 10, children: List.generate(_styleNames.length, (i) {
final selected = _currentStyle == i;
return GestureDetector(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(width: 36, height: 4, decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2))),
const SizedBox(height: 20),
Align(alignment: Alignment.centerLeft, child: Text('选择样式', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface))),
const SizedBox(height: 12),
for (int i = 0; i < _styleNames.length; i++) ...[
if (i > 0) Divider(height: 0.5, color: colors.outlineVariant),
ListTile(
contentPadding: EdgeInsets.zero,
leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)),
child: Icon(icons[i], size: 20, color: _currentStyle == i ? colors.primary : colors.onSurface.withValues(alpha: 0.6))),
title: Text(_styleNames[i], style: TextStyle(fontSize: 13, fontWeight: _currentStyle == i ? FontWeight.w600 : FontWeight.w500, color: colors.onSurface)),
subtitle: Text(subtitles[i], style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))),
trailing: _currentStyle == i
? Icon(Icons.check_circle, size: 20, color: colors.primary)
: Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25)),
onTap: () { setState(() => _currentStyle = i); Navigator.pop(ctx); },
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
decoration: BoxDecoration(
color: selected ? colors.primary : colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: selected ? colors.primary : colors.outline, width: 0.5),
),
child: Text(_styleNames[i], style: TextStyle(fontSize: 14, fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
color: selected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.6))),
),
);
})),
),
],
const SizedBox(height: 12),
]),
),
);

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/data_models.dart';
import '../../providers/app_provider.dart';
import '../../utils/user_prefs.dart';
import '../../widgets/movie_status_bar.dart';
import '../../widgets/movie_list_item.dart';
import '../../widgets/animated_star_rating.dart';
@@ -21,10 +22,8 @@ class _MovieTabPageState extends State<MovieTabPage> {
bool _hasMore = true;
bool _isLoading = false;
int _offset = 0;
int _lastStatusIndex = -1;
bool _initialized = false;
int _lastDataCount = -1;
DateTime? _lastUpdatedAt;
int _lastStatusIndex = -1;
late ScrollController _scrollController;
AppProvider? _provider;
int _lastScrollSignal = 0;
@@ -39,8 +38,6 @@ class _MovieTabPageState extends State<MovieTabPage> {
final provider = context.read<AppProvider>();
_provider = provider;
provider.addListener(_onDataChanged);
_lastDataCount = provider.movies.length;
if (provider.movies.isNotEmpty) _lastUpdatedAt = provider.movies.first.updatedAt;
_loadFirst();
});
}
@@ -64,15 +61,8 @@ class _MovieTabPageState extends State<MovieTabPage> {
}
}
final count = provider.movies.length;
final latest = provider.movies.isNotEmpty ? provider.movies.first.updatedAt : null;
if (count != _lastDataCount || latest != _lastUpdatedAt) {
_lastDataCount = count;
_lastUpdatedAt = latest;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _loadFirst();
});
}
// 数据变化时刷新列表(排序/评分/新增等)
_loadFirst();
}
void _onScroll() {
@@ -90,7 +80,7 @@ class _MovieTabPageState extends State<MovieTabPage> {
_initialized = true;
final status = _statusMap[statusIdx] ?? 'watched';
setState(() { _isLoading = true; _offset = 0; _hasMore = true; });
final list = await provider.loadMoviesPaged(status: status, offset: 0);
final list = await provider.loadMoviesPaged(status: status, offset: 0, sortMode: UserPrefs().movieSortMode);
if (!mounted) return;
setState(() {
_items.clear();
@@ -106,7 +96,7 @@ class _MovieTabPageState extends State<MovieTabPage> {
setState(() => _isLoading = true);
final provider = context.read<AppProvider>();
final status = _statusMap[provider.movieStatusIndex] ?? 'watched';
final list = await provider.loadMoviesPaged(status: status, offset: _offset);
final list = await provider.loadMoviesPaged(status: status, offset: _offset, sortMode: UserPrefs().movieSortMode);
if (!mounted) return;
setState(() {
_items.addAll(list);

View File

@@ -30,6 +30,7 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
return Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(
titleSpacing: 0,
title: Text(
note.title.isNotEmpty
? note.title

View File

@@ -1,4 +1,5 @@
import 'dart:io';
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:image_picker/image_picker.dart';
@@ -9,6 +10,7 @@ import '../../models/data_models.dart';
import '../../utils/toast_util.dart';
import '../../utils/image_path_helper.dart';
import '../../widgets/fade_in_local_image.dart';
import '../../widgets/tag_side_panel.dart';
/// 添加/编辑笔记页面 - 极简书写界面
class NoteFormPage extends StatefulWidget {
@@ -30,6 +32,9 @@ class _NoteFormPageState extends State<NoteFormPage> {
final ImagePicker _picker = ImagePicker();
String? _tempNoteId; // 新建模式时使用的临时笔记ID
String _editorMode = 'edit'; // 'edit' | 'preview'
Timer? _autoSaveTimer;
String _saveStatus = ''; // '', 'saved'
Note? _savedNote; // 新建模式首次自动保存后的笔记引用
static const _weekdays = ['', '', '', '', '', '', ''];
@@ -44,15 +49,82 @@ class _NoteFormPageState extends State<NoteFormPage> {
_tags = note != null ? List.from(note.tags) : [];
_images = note != null ? List.from(note.images) : [];
_isEditing = note != null;
_titleController.addListener(_onTextChanged);
_contentController.addListener(_onTextChanged);
}
@override
void dispose() {
_autoSaveTimer?.cancel();
_titleController.dispose();
_contentController.dispose();
super.dispose();
}
void _onTextChanged() {
_autoSaveTimer?.cancel();
_autoSaveTimer = Timer(const Duration(seconds: 2), () {
if (mounted) _autoSave();
});
}
Future<void> _autoSave() async {
final content = _contentController.text.trim();
final title = _titleController.text.trim();
if (title.isEmpty && content.isEmpty) return;
try {
final now = DateTime.now();
if (_isEditing) {
final updatedNote = widget.note!.copyWith(
title: title,
content: content,
tags: _tags,
images: _images,
updatedAt: now,
);
await context.read<AppProvider>().updateNote(updatedNote);
} else if (_savedNote != null) {
final updatedNote = _savedNote!.copyWith(
title: title,
content: content,
tags: _tags,
images: _images,
updatedAt: now,
);
await context.read<AppProvider>().updateNote(updatedNote);
_savedNote = updatedNote;
} else {
final noteId = now.millisecondsSinceEpoch.toString();
List<String> finalImages = [];
if (_images.isNotEmpty) {
final oldNoteId = _tempNoteId ?? noteId;
finalImages = await _moveImagesToNewId(oldNoteId, noteId);
}
final newNote = Note(
id: noteId,
title: title,
content: content,
tags: _tags,
images: finalImages.isNotEmpty ? finalImages : _images,
createdAt: _createdAt,
updatedAt: now,
);
await context.read<AppProvider>().addNote(newNote);
_savedNote = newNote;
_isEditing = true;
}
if (mounted) {
setState(() => _saveStatus = 'saved');
Timer(const Duration(seconds: 3), () {
if (mounted && _saveStatus == 'saved') setState(() => _saveStatus = '');
});
}
} catch (_) {
// 自动保存失败静默处理
}
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
@@ -149,6 +221,19 @@ class _NoteFormPageState extends State<NoteFormPage> {
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: colors.onSurface),
),
),
if (_saveStatus == 'saved')
Padding(
padding: const EdgeInsets.only(right: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(width: 6, height: 6,
decoration: BoxDecoration(color: Colors.green, shape: BoxShape.circle)),
const SizedBox(width: 4),
Text('已保存', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))),
],
),
),
GestureDetector(
onTap: _saveNote,
child: Container(
@@ -501,7 +586,7 @@ class _NoteFormPageState extends State<NoteFormPage> {
Widget _buildAddTagButton() {
final colors = Theme.of(context).colorScheme;
return GestureDetector(
onTap: _showAddTagDialog,
onTap: _showTagPanel,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
@@ -520,184 +605,26 @@ class _NoteFormPageState extends State<NoteFormPage> {
);
}
/// 显示添加标签对话框
Future<void> _showAddTagDialog() async {
final controller = TextEditingController();
// 从 tags 表获取已有标签
/// 显示标签侧边面板
Future<void> _showTagPanel() async {
final provider = context.read<AppProvider>();
final tagRows = await provider.getTags('note_tag');
final allTags = tagRows.map((t) => t['name'] as String).toSet();
// 也从当前笔记内容中收集
for (final note in provider.notes) {
allTags.addAll(note.tags);
}
// 过滤掉已添加的标签
final availableTags = allTags.where((tag) => !_tags.contains(tag)).toList()..sort();
showDialog(
if (!mounted) return;
TagSidePanel.show(
context: context,
builder: (ctx) {
final colors = Theme.of(context).colorScheme;
return StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
backgroundColor: colors.surface,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
titlePadding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
title: Text(
'添加标签',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: colors.onSurface,
),
),
contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0),
content: SizedBox(
width: double.maxFinite,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 输入框
TextField(
controller: controller,
autofocus: true,
style: TextStyle(fontSize: 14, color: colors.onSurface),
cursorColor: colors.primary,
decoration: InputDecoration(
hintText: '输入新标签名称',
hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.3)),
filled: true,
fillColor: colors.surfaceContainerHigh,
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: colors.primary, width: 1),
),
suffixIcon: controller.text.isNotEmpty
? IconButton(
icon: Icon(Icons.clear, size: 16, color: colors.onSurface.withValues(alpha: 0.35)),
onPressed: () => controller.clear(),
)
: null,
),
onChanged: (_) => setDialogState(() {}),
onSubmitted: (value) {
_addTag(value);
controller.clear();
setDialogState(() {});
},
),
// 已有标签列表
if (availableTags.isNotEmpty) ...[
const SizedBox(height: 20),
Text(
'或选择已有标签',
style: TextStyle(
fontSize: 12,
color: colors.onSurface.withValues(alpha: 0.35),
),
),
const SizedBox(height: 12),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 180),
child: SingleChildScrollView(
child: Wrap(
spacing: 8,
runSpacing: 8,
children: availableTags.map((tag) {
return InkWell(
onTap: () {
_addTag(tag);
Navigator.pop(ctx);
},
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: colors.outline, width: 0.5),
),
child: Text(
tag,
style: TextStyle(
fontSize: 13,
color: colors.onSurface.withValues(alpha: 0.7),
),
),
),
);
}).toList(),
),
),
),
],
if (availableTags.isEmpty)
Padding(
padding: const EdgeInsets.only(top: 16, bottom: 8),
child: Center(
child: Text(
'暂无已有标签',
style: TextStyle(fontSize: 13, color: Colors.grey[400]),
),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
style: TextButton.styleFrom(
foregroundColor: colors.onSurface.withValues(alpha: 0.4),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('取消', style: TextStyle(fontSize: 14)),
),
ElevatedButton(
onPressed: () {
_addTag(controller.text);
Navigator.pop(ctx);
},
style: ElevatedButton.styleFrom(
backgroundColor: colors.primary,
foregroundColor: colors.onPrimary,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
),
child: const Text('添加', style: TextStyle(fontSize: 14)),
),
],
actionsPadding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
),
);
selectedTags: List.from(_tags),
allAvailableTags: allTags.toList()..sort(),
onTagsChanged: (newTags) {
setState(() => _tags = newTags);
},
);
}
/// 获取所有已有标签(从所有笔记中收集)
/// 添加标签
void _addTag(String tag) {
final trimmed = tag.trim();
if (trimmed.isNotEmpty && !_tags.contains(trimmed)) {
setState(() => _tags.add(trimmed));
}
}
/// 标题输入行
Widget _buildTitleInput(ColorScheme colors) {
return Padding(
@@ -729,42 +656,15 @@ class _NoteFormPageState extends State<NoteFormPage> {
return false;
}
/// 离开确认
/// 离开确认(自动保存后直接返回)
Future<bool> _confirmLeave() async {
if (!_hasContent()) return true;
final colors = Theme.of(context).colorScheme;
final result = await showDialog<bool>(
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('当前内容未保存,确定要离开吗?',
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
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),
),
);
return result ?? false;
_autoSaveTimer?.cancel();
if (_hasContent()) await _autoSave();
return true;
}
Future<void> _saveNote() async {
_autoSaveTimer?.cancel();
final content = _contentController.text.trim();
final title = _titleController.text.trim();

View File

@@ -24,10 +24,7 @@ class _NoteTabPageState extends State<NoteTabPage> {
AppProvider? _provider;
int _layoutStyle = 0;
bool _initialized = false;
int _lastDataCount = -1;
DateTime? _lastUpdatedAt;
int _lastScrollSignal = 0;
String? _selectedTag;
@override
void initState() {
@@ -38,8 +35,6 @@ class _NoteTabPageState extends State<NoteTabPage> {
final provider = context.read<AppProvider>();
_provider = provider;
provider.addListener(_onDataChanged);
_lastDataCount = provider.notes.length;
if (provider.notes.isNotEmpty) _lastUpdatedAt = provider.notes.first.updatedAt;
_loadFirst();
});
}
@@ -55,7 +50,6 @@ class _NoteTabPageState extends State<NoteTabPage> {
if (!_initialized || !mounted) return;
final provider = context.read<AppProvider>();
// 检查回到顶部信号
if (provider.scrollToTopSignal != _lastScrollSignal && provider.scrollToTopSignal > 0) {
_lastScrollSignal = provider.scrollToTopSignal;
if (_scrollController.hasClients) {
@@ -63,15 +57,8 @@ class _NoteTabPageState extends State<NoteTabPage> {
}
}
final count = provider.notes.length;
final latest = provider.notes.isNotEmpty ? provider.notes.first.updatedAt : null;
if (count != _lastDataCount || latest != _lastUpdatedAt) {
_lastDataCount = count;
_lastUpdatedAt = latest;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _loadFirst();
});
}
// 数据变化时刷新列表(排序/评分/新增等)
_loadFirst();
}
void _onScroll() {
@@ -83,12 +70,8 @@ class _NoteTabPageState extends State<NoteTabPage> {
Future<void> _loadFirst() async {
_initialized = true;
setState(() { _isLoading = true; _offset = 0; _hasMore = true; });
List<Note> list = await context.read<AppProvider>().loadNotesPaged(offset: 0);
if (_selectedTag != null) {
final all = context.read<AppProvider>().notes.where((n) => !n.isDeleted && n.tags.contains(_selectedTag)).toList()
..sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
list = all.take(20).toList();
}
final sortMode = UserPrefs().noteSortMode;
final list = await context.read<AppProvider>().loadNotesPaged(offset: 0, sortMode: sortMode);
if (!mounted) return;
setState(() { _items.clear(); _items.addAll(list); _offset = list.length; _hasMore = list.length >= 20; _isLoading = false; });
}
@@ -96,14 +79,8 @@ class _NoteTabPageState extends State<NoteTabPage> {
Future<void> _loadMore() async {
if (_isLoading || !_hasMore) return;
setState(() => _isLoading = true);
List<Note> list;
if (_selectedTag != null) {
final all = context.read<AppProvider>().notes.where((n) => !n.isDeleted && n.tags.contains(_selectedTag)).toList()
..sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
list = all.skip(_offset).take(20).toList();
} else {
list = await context.read<AppProvider>().loadNotesPaged(offset: _offset);
}
final sortMode = UserPrefs().noteSortMode;
final list = await context.read<AppProvider>().loadNotesPaged(offset: _offset, sortMode: sortMode);
if (!mounted) return;
setState(() { _items.addAll(list); _offset += list.length; _hasMore = list.length >= 20; _isLoading = false; });
}
@@ -118,20 +95,11 @@ class _NoteTabPageState extends State<NoteTabPage> {
final colors = Theme.of(context).colorScheme;
return Consumer<AppProvider>(builder: (context, provider, _) {
if (_items.isEmpty && _isLoading) return _buildSkeleton();
if (_items.isEmpty && _selectedTag == null) {
if (_items.isEmpty) {
return RefreshIndicator(onRefresh: _refresh, color: colors.primary, backgroundColor: colors.surface,
child: ListView(physics: const AlwaysScrollableScrollPhysics(), children: [_buildEmptyState(context)]));
}
return Column(
children: [
_buildTagBar(provider),
Expanded(
child: _items.isEmpty
? Center(child: Text('没有"$_selectedTag"相关的笔记', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4))))
: _buildContent(),
),
],
);
return _buildContent();
});
}
@@ -141,53 +109,6 @@ class _NoteTabPageState extends State<NoteTabPage> {
return _buildListView();
}
Widget _buildTagBar(AppProvider provider) {
final colors = Theme.of(context).colorScheme;
final allTags = <String>{};
for (final n in provider.notes.where((n) => !n.isDeleted)) {
allTags.addAll(n.tags);
}
if (allTags.isEmpty) return const SizedBox.shrink();
final tags = allTags.toList()..sort();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: colors.outlineVariant, width: 0.5)),
),
child: SizedBox(
height: 32,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: tags.length,
separatorBuilder: (_, __) => const SizedBox(width: 6),
itemBuilder: (_, i) {
final tag = tags[i];
final selected = _selectedTag == tag;
return GestureDetector(
onTap: () {
setState(() => _selectedTag = selected ? null : tag);
_loadFirst();
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: selected ? colors.primary : colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
alignment: Alignment.center,
child: Text(tag, style: TextStyle(
fontSize: 12,
color: selected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.6),
)),
),
);
},
),
),
);
}
Widget _buildSkeleton() {
switch (_layoutStyle) {
case 1: return _buildWaterfallSkeleton();
@@ -246,7 +167,7 @@ class _NoteTabPageState extends State<NoteTabPage> {
final colors = Theme.of(context).colorScheme;
return GestureDetector(
onTap: () => Navigator.pushNamed(context, '/note-detail', arguments: note).then((_) => _loadFirst()),
onLongPress: () => _showDeleteDialog(context, note),
onLongPress: () => _showNoteActions(note),
child: IntrinsicHeight(child: Row(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
SizedBox(width: 40, child: Column(children: [
Container(width: 10, height: 10,
@@ -256,7 +177,10 @@ class _NoteTabPageState extends State<NoteTabPage> {
Expanded(child: Container(margin: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.all(14),
decoration: BoxDecoration(color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(12)),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
Text(_formatFullDate(note.updatedAt), style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))),
Row(children: [
Expanded(child: Text(_formatFullDate(note.updatedAt), style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4)))),
if (note.isPinned) Icon(Icons.push_pin, size: 14, color: colors.primary),
]),
if (note.title.isNotEmpty) ...[const SizedBox(height: 6),
Text(note.title, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface), maxLines: 1, overflow: TextOverflow.ellipsis),
],
@@ -305,7 +229,7 @@ class _NoteTabPageState extends State<NoteTabPage> {
final extraCount = images.length - 1;
return GestureDetector(
onTap: () => Navigator.pushNamed(context, '/note-detail', arguments: note).then((_) => _loadFirst()),
onLongPress: () => _showDeleteDialog(context, note),
onLongPress: () => _showNoteActions(note),
child: Container(margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(10),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.04), blurRadius: 6, offset: const Offset(0, 2))]),
@@ -344,9 +268,7 @@ class _NoteTabPageState extends State<NoteTabPage> {
]),
),
);
}
String _getPreviewText(Note note) {
} String _getPreviewText(Note note) {
final text = note.content.replaceAll(RegExp(r'[#*\[\]\(\)]'), '').trim();
return text.isEmpty ? '(无内容)' : text;
}
@@ -368,23 +290,72 @@ class _NoteTabPageState extends State<NoteTabPage> {
);
}
void _showDeleteDialog(BuildContext context, Note note) {
void _showNoteActions(Note note) {
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('确定要删除这条笔记吗?删除后可在回收站恢复。',
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: () async { await context.read<AppProvider>().removeNote(note.id); Navigator.pop(ctx); _loadFirst(); },
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),
));
showModalBottomSheet(
context: context,
backgroundColor: colors.surface,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (ctx) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(width: 36, height: 4, decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2))),
const SizedBox(height: 16),
ListTile(
contentPadding: EdgeInsets.zero,
leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)),
child: Icon(note.isPinned ? Icons.push_pin_outlined : Icons.push_pin, size: 20, color: colors.onSurface.withValues(alpha: 0.6))),
title: Text(note.isPinned ? '取消置顶' : '置顶', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface)),
subtitle: Text(note.isPinned ? '取消置顶后按时间排序' : '置顶后始终显示在最前', 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);
context.read<AppProvider>().toggleNotePin(note.id, !note.isPinned);
},
),
Divider(height: 0.5, color: colors.outlineVariant),
ListTile(
contentPadding: EdgeInsets.zero,
leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)),
child: Icon(Icons.delete_outline, size: 20, color: colors.error)),
title: Text('删除', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.error)),
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);
_showDeleteDialog(note);
},
),
const SizedBox(height: 12),
]),
),
);
}
void _showDeleteDialog(Note note) {
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('确定要删除这条笔记吗?删除后可在回收站恢复。',
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: () async { await context.read<AppProvider>().removeNote(note.id); Navigator.pop(ctx); _loadFirst(); },
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(BuildContext context) {

View File

@@ -158,7 +158,9 @@ class _OnlineSearchPageState extends State<OnlineSearchPage> {
}
} catch (e) {
if (mounted) {
final msg = e.toString().contains('TimeoutException') ? '搜索超时,请稍后重试' : '搜索失败,请检查网络';
final msg = e.toString().contains('TimeoutException')
? '搜索超时,请稍后重试'
: '搜索失败,请检查网络';
ToastUtil.show(context, msg);
}
}
@@ -226,7 +228,9 @@ class _OnlineSearchPageState extends State<OnlineSearchPage> {
}
} catch (e) {
if (mounted) {
final msg = e.toString().contains('TimeoutException') ? '搜索超时,请稍后重试' : '搜索失败,请检查网络';
final msg = e.toString().contains('TimeoutException')
? '搜索超时,请稍后重试'
: '搜索失败,请检查网络';
ToastUtil.show(context, msg);
}
}
@@ -871,7 +875,7 @@ class _OnlineSearchPageState extends State<OnlineSearchPage> {
Widget _buildHistoryPanel(ColorScheme colors) {
if (_history.isEmpty)
return _buildEmptyState(colors, '搜索你想看的影视作品', Icons.movie_outlined);
return _buildEmptyState(colors, '搜索你想看的影视/书籍作品', Icons.manage_search_outlined);
return ListView(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
children: [

View File

@@ -0,0 +1,372 @@
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();
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
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);
}
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('角色信息')),
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),
};
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),
};
// 按类型分组作品
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) {
bookItems.add(_WorkItem(title: b.title, roles: const ['作者'], 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});
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,13 +3,13 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/app_provider.dart';
import '../models/data_models.dart';
import '../widgets/animated_star_rating.dart';
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});
@@ -20,13 +20,14 @@ class StrollPage extends StatefulWidget {
class _StrollPageState extends State<StrollPage> {
final _random = Random();
final List<_StrollItem> _items = [];
final Set<String> _seenIds = {};
late PageController _pageController;
int _currentIndex = 0;
String _filter = 'all'; // all / movie / book / note
@override
void initState() {
super.initState();
_pageController = PageController(viewportFraction: 0.85);
_pageController = PageController(viewportFraction: 0.78);
_loadBatch(5);
}
@@ -36,99 +37,123 @@ class _StrollPageState extends State<StrollPage> {
super.dispose();
}
// ─── 数据加载 ───
void _loadBatch(int count) {
final provider = context.read<AppProvider>();
final movies = provider.movies.where((m) => !m.isDeleted).toList();
final books = provider.books.where((b) => !b.isDeleted).toList();
final notes = provider.notes.where((n) => !n.isDeleted).toList();
final categories = <String, List<dynamic>>{};
if (movies.isNotEmpty) categories['movie'] = movies;
if (books.isNotEmpty) categories['book'] = books;
if (notes.isNotEmpty) categories['note'] = notes;
if (categories.isEmpty) return;
final categoryKeys = categories.keys.toList();
for (int i = 0; i < count; i++) {
final pickedCategory = categoryKeys[_random.nextInt(categoryKeys.length)];
_StrollItem? item;
switch (pickedCategory) {
case 'movie':
final m = movies[_random.nextInt(movies.length)];
item = _StrollItem(
type: 'movie', data: m,
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,
color: const Color(0xFF4A90D9),
);
case 'book':
final b = books[_random.nextInt(books.length)];
item = _StrollItem(
type: 'book', data: b,
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,
color: const Color(0xFF7E57C2),
);
case 'note':
final n = notes[_random.nextInt(notes.length)];
item = _StrollItem(
type: 'note', data: n,
title: n.title.isNotEmpty ? n.title : '随手记',
subtitle: n.tags.take(3).join(' · '),
detail: n.content,
imagePath: n.images.isNotEmpty ? n.images.first : null,
icon: Icons.note_outlined, label: '笔记',
createdAt: n.createdAt,
color: const Color(0xFF66BB6A),
);
// 按类别分池
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 (item != null) _items.add(item);
}
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();
_currentIndex = 0;
_seenIds.clear();
_loadBatch(5);
});
}
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)));
}
}
// ─── 辅助方法 ───
String _movieDetail(Movie m) {
final parts = <String>[];
if (m.genres.isNotEmpty) parts.add(m.genres.take(3).join(' / '));
if (m.summary != null && m.summary!.isNotEmpty) {
parts.add(m.summary!.length > 80 ? '${m.summary!.substring(0, 80)}...' : m.summary!);
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.genres.isNotEmpty) parts.add(b.genres.take(3).join(' / '));
if (b.publisher != null && b.publisher!.isNotEmpty) parts.add(b.publisher!);
if (b.summary != null && b.summary!.isNotEmpty) {
parts.add(b.summary!.length > 80 ? '${b.summary!.substring(0, 80)}...' : b.summary!);
parts.add(b.summary!.length > 100 ? '${b.summary!.substring(0, 100)}...' : b.summary!);
}
return parts.join('\n');
}
@@ -142,16 +167,39 @@ class _StrollPageState extends State<StrollPage> {
return '刚刚';
}
String _actionText(_StrollItem item) {
final ago = _timeAgoText(item.createdAt);
switch (item.type) {
case 'movie': return '$ago';
case 'book': return '$ago 读过';
case 'note': return '$ago 写下';
default: return ago;
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;
@@ -159,70 +207,123 @@ class _StrollPageState extends State<StrollPage> {
return Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(
title: const Text('漫步'),
actions: [
if (hasContent)
Padding(
padding: const EdgeInsets.only(right: 4),
child: Center(
child: Text('${_currentIndex + 1}/${_items.length}',
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35))),
),
),
Padding(
padding: const EdgeInsets.only(right: 12),
child: GestureDetector(
onTap: _reshuffle,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
decoration: BoxDecoration(
color: colors.primary,
borderRadius: BorderRadius.circular(18),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.casino_outlined, size: 14, color: colors.onPrimary),
const SizedBox(width: 5),
Text('随机', style: TextStyle(fontSize: 12, color: colors.onPrimary, fontWeight: FontWeight.w500)),
],
),
),
),
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),
);
},
),
),
),
],
),
body: !hasContent
? Center(
child: Text('还没有任何内容\n去添加一些吧',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 15, color: colors.onSurface.withValues(alpha: 0.3), height: 1.6)))
: PageView.builder(
controller: _pageController,
onPageChanged: (index) {
setState(() => _currentIndex = index);
// 接近末尾时追加新条目
if (index >= _items.length - 2) {
setState(() => _loadBatch(3));
);
}
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);
});
}
},
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.1).clamp(0.85, 1.0);
}
return Transform.scale(scale: scale, child: child);
},
child: _buildCard(_items[index], colors),
);
},
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(),
),
);
}
@@ -231,125 +332,282 @@ class _StrollPageState extends State<StrollPage> {
return GestureDetector(
onTap: () => _openDetail(item),
child: Container(
margin: const EdgeInsets.symmetric(vertical: 24, horizontal: 6),
decoration: BoxDecoration(
color: colors.surfaceContainerLowest,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(color: Colors.black.withValues(alpha: 0.08), blurRadius: 24, offset: const Offset(0, 8)),
],
),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
// 封面/图片区域
if (hasImage)
Expanded(
flex: 5,
child: SizedBox(
width: double.infinity,
child: FadeInLocalImage(path: item.imagePath, fit: BoxFit.cover),
),
)
else
Expanded(
flex: 3,
child: Container(
width: double.infinity,
color: colors.surfaceContainerHigh,
child: Center(child: Icon(item.icon, size: 56, color: item.color.withValues(alpha: 0.2))),
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: 56, 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: 56, 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(
flex: 4,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 16),
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 类型标签 + 评分
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: item.color.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(item.icon, size: 12, color: item.color),
const SizedBox(width: 4),
Text(item.label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: item.color)),
],
),
// 标签
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(),
),
const Spacer(),
if (item.rating != null)
AnimatedStarRating(rating: item.rating!, starSize: 14, showNumber: true),
],
),
const SizedBox(height: 10),
),
// 标题
Text(item.title,
maxLines: 2, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: colors.onSurface, height: 1.3)),
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: 13, color: colors.onSurface.withValues(alpha: 0.4))),
Text(item.subtitle, maxLines: 1, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.5))),
],
const SizedBox(height: 8),
// 详情
if (item.detail.isNotEmpty)
Expanded(
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
),
child: Text(item.detail, maxLines: 3, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6), height: 1.7)),
),
),
const SizedBox(height: 8),
// 时间 + 点击提示
Row(
children: [
Text(_actionText(item), style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.25))),
const Spacer(),
Icon(Icons.arrow_forward_ios, size: 12, color: colors.onSurface.withValues(alpha: 0.15)),
],
),
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;
@@ -358,11 +616,13 @@ class _StrollItem {
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,
@@ -371,6 +631,7 @@ class _StrollItem {
required this.label,
this.rating,
required this.createdAt,
this.tags = const [],
required this.color,
});
}

View File

@@ -100,11 +100,6 @@ class _TagManagementPageState extends State<TagManagementPage> {
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final tags = _tagCache[_currentType] ?? [];
final isSearching = _searchQuery.isNotEmpty;
final usedCount = tags.where((t) => (_usageCounts[t['name']] ?? 0) > 0 && (t['is_hidden'] as int?) != 1).length;
final unusedCount = tags.where((t) => (_usageCounts[t['name']] ?? 0) == 0 && (t['is_hidden'] as int?) != 1).length;
final hiddenCount = tags.where((t) => (t['is_hidden'] as int?) == 1).length;
return Scaffold(
backgroundColor: colors.surfaceContainerHigh,
@@ -114,80 +109,11 @@ class _TagManagementPageState extends State<TagManagementPage> {
child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: colors.primary)))
: IconButton(icon: const Icon(Icons.sync, size: 20), tooltip: '从数据中同步标签', onPressed: _syncTags),
]),
body: Column(
children: [
const SizedBox(height: 8),
// 搜索栏
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Container(
height: 36,
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: colors.outlineVariant, width: 0.5),
),
child: Row(
children: [
const SizedBox(width: 10),
Icon(Icons.search, size: 16, color: colors.onSurface.withValues(alpha: 0.3)),
const SizedBox(width: 6),
Expanded(
child: TextField(
controller: _searchController,
style: TextStyle(fontSize: 13, color: colors.onSurface),
decoration: InputDecoration(
hintText: '搜索标签',
hintStyle: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.3)),
isDense: true,
contentPadding: EdgeInsets.zero,
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: Padding(
padding: const EdgeInsets.only(right: 8),
child: Icon(Icons.close, size: 15, color: colors.onSurface.withValues(alpha: 0.3)),
),
),
if (_searchQuery.isEmpty) const SizedBox(width: 10),
],
),
),
),
const SizedBox(height: 12),
// 统计卡片
if (!isSearching)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(children: [
_buildStatChip(Icons.check_circle_outline, '已使用', usedCount, colors),
const SizedBox(width: 8),
_buildStatChip(Icons.radio_button_unchecked, '未使用', unusedCount, colors),
const SizedBox(width: 8),
_buildStatChip(Icons.visibility_off_outlined, '隐藏', hiddenCount, colors),
]),
),
SizedBox(height: isSearching ? 0 : 12),
// 标签列表
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOut, switchOutCurve: Curves.easeIn,
child: _buildTagList(_currentType),
),
),
],
body: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: _buildTagList(_currentType),
),
floatingActionButton: Column(
mainAxisSize: MainAxisSize.min,
@@ -287,21 +213,39 @@ class _TagManagementPageState extends State<TagManagementPage> {
Widget _buildTagList(String type) {
final tags = _tagCache[type] ?? [];
if (tags.isEmpty) return _buildEmptyState(type);
final colors = Theme.of(context).colorScheme;
final isSearching = _searchQuery.isNotEmpty;
if (tags.isEmpty && !isSearching) {
return SingleChildScrollView(
key: ValueKey('empty_$type'),
padding: const EdgeInsets.fromLTRB(20, 4, 20, 80),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
_buildSearchBar(colors),
const SizedBox(height: 40),
_buildEmptyState(type),
]),
);
}
// 搜索模式
if (isSearching) {
final filtered = tags.where((t) => (t['name'] as String).toLowerCase().contains(_searchQuery.toLowerCase())).toList()
..sort((a, b) => (_usageCounts[b['name']] ?? 0).compareTo(_usageCounts[a['name']] ?? 0));
if (filtered.isEmpty) {
return Center(child: Text('没有找到"$_searchQuery"相关标签', style: TextStyle(fontSize: 13, color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4))));
}
return SingleChildScrollView(
key: ValueKey('search_$type'),
padding: const EdgeInsets.fromLTRB(20, 0, 20, 80),
child: Wrap(spacing: 8, runSpacing: 6, children: filtered.map(_buildTagChip).toList()),
padding: const EdgeInsets.fromLTRB(20, 4, 20, 80),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
_buildSearchBar(colors),
const SizedBox(height: 12),
if (filtered.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Center(child: Text('没有找到"$_searchQuery"相关标签', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)))),
)
else
Wrap(spacing: 8, runSpacing: 6, children: filtered.map(_buildTagChip).toList()),
]),
);
}
@@ -316,30 +260,89 @@ class _TagManagementPageState extends State<TagManagementPage> {
}
used.sort((a, b) => (_usageCounts[b['name']] ?? 0).compareTo(_usageCounts[a['name']] ?? 0));
final colors = Theme.of(context).colorScheme;
return SingleChildScrollView(
key: ValueKey(type),
padding: const EdgeInsets.fromLTRB(20, 0, 20, 80),
padding: const EdgeInsets.fromLTRB(20, 4, 20, 80),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (used.isNotEmpty) ...[
_buildGroupHeader('已使用', used.length, colors),
const SizedBox(height: 6),
Wrap(spacing: 8, runSpacing: 6, children: used.map(_buildTagChip).toList()),
const SizedBox(height: 16),
],
if (unused.isNotEmpty) ...[
_buildGroupHeader('未使用', unused.length, colors),
const SizedBox(height: 6),
Wrap(spacing: 8, runSpacing: 6, children: unused.map(_buildTagChip).toList()),
const SizedBox(height: 16),
],
if (hidden.isNotEmpty) ...[
_buildGroupHeader('隐藏', hidden.length, colors),
const SizedBox(height: 6),
Wrap(spacing: 8, runSpacing: 6, children: hidden.map(_buildTagChip).toList()),
],
_buildSearchBar(colors),
// 统计栏
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 8),
child: Row(children: [
_buildStatChip(Icons.check_circle_outline, '已使用', used.length, colors),
const SizedBox(width: 8),
_buildStatChip(Icons.radio_button_unchecked, '未使用', unused.length, colors),
const SizedBox(width: 8),
_buildStatChip(Icons.visibility_off_outlined, '隐藏', hidden.length, colors),
]),
),
_buildGroupHeader('已使用', used.length, colors),
const SizedBox(height: 6),
used.isNotEmpty
? Wrap(spacing: 8, runSpacing: 6, children: used.map(_buildTagChip).toList())
: _buildEmptyGroup('暂无已使用标签', colors),
const SizedBox(height: 16),
_buildGroupHeader('未使用', unused.length, colors),
const SizedBox(height: 6),
unused.isNotEmpty
? Wrap(spacing: 8, runSpacing: 6, children: unused.map(_buildTagChip).toList())
: _buildEmptyGroup('暂无未使用标签', colors),
const SizedBox(height: 16),
_buildGroupHeader('隐藏', hidden.length, colors),
const SizedBox(height: 6),
hidden.isNotEmpty
? Wrap(spacing: 8, runSpacing: 6, children: hidden.map(_buildTagChip).toList())
: _buildEmptyGroup('暂无隐藏标签', colors),
],
),
);
}
Widget _buildSearchBar(ColorScheme colors) {
return Container(
height: 40,
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: colors.outlineVariant, width: 0.5),
),
child: Row(
children: [
const SizedBox(width: 12),
Icon(Icons.search_rounded, size: 18, color: colors.onSurface.withValues(alpha: 0.35)),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _searchController,
style: TextStyle(fontSize: 14, 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: 10),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: 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: 8),
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(color: colors.surfaceContainerHighest, shape: BoxShape.circle),
child: Icon(Icons.close_rounded, size: 14, color: colors.onSurface.withValues(alpha: 0.4)),
),
)
else
const SizedBox(width: 12),
],
),
);
@@ -358,6 +361,13 @@ class _TagManagementPageState extends State<TagManagementPage> {
);
}
Widget _buildEmptyGroup(String text, ColorScheme colors) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(text, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.3))),
);
}
Widget _buildTagChip(Map<String, dynamic> tag) {
final colors = Theme.of(context).colorScheme;
final name = tag['name'] as String;
@@ -480,6 +490,10 @@ class _TagManagementPageState extends State<TagManagementPage> {
await _loadTags(_currentType);
},
),
_menuAction(Icons.open_in_new_outlined, '查看相关${_typeLabels[_currentIndex].replaceAll('类型', '').replaceAll('标签', '')}', colors, () {
Navigator.pop(ctx);
_showTagItems(name);
}),
_menuAction(Icons.edit_outlined, '重命名', colors, () {
Navigator.pop(ctx);
_showRenameDialog(tag);
@@ -517,6 +531,77 @@ class _TagManagementPageState extends State<TagManagementPage> {
);
}
void _showTagItems(String tagName) {
final provider = context.read<AppProvider>();
final colors = Theme.of(context).colorScheme;
List<({String title, String? subtitle, String type})> items = [];
if (_currentType == 'movie_genre') {
for (final m in provider.movies.where((m) => !m.isDeleted && m.genres.contains(tagName))) {
items.add((title: m.title, subtitle: m.directors.take(2).join(' / '), type: '影视'));
}
} else if (_currentType == 'book_genre') {
for (final b in provider.books.where((b) => !b.isDeleted && b.genres.contains(tagName))) {
items.add((title: b.title, subtitle: b.authors.take(2).join(' / '), type: '书籍'));
}
} else {
for (final n in provider.notes.where((n) => !n.isDeleted && n.tags.contains(tagName))) {
items.add((title: n.title.isNotEmpty ? n.title : '随手记', subtitle: null, type: '笔记'));
}
}
showModalBottomSheet(
context: context,
backgroundColor: colors.surface,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (ctx) => SafeArea(
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.only(bottom: 24),
children: [
Center(child: 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)))),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Text('$tagName${items.length}', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)),
),
const SizedBox(height: 8),
if (items.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Center(child: Text('暂无相关内容', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)))),
)
else
...items.asMap().entries.map((entry) {
final item = entry.value;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (entry.key > 0) Divider(height: 0.5, color: colors.outlineVariant),
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(item.title, style: TextStyle(fontSize: 14, color: colors.onSurface)),
subtitle: item.subtitle != null && item.subtitle!.isNotEmpty
? Text(item.subtitle!, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4)))
: null,
trailing: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(4)),
child: Text(item.type, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.5))),
),
),
],
),
);
}),
],
),
),
);
}
// ─── 添加标签 ──────────────────────────────────────────────────────────
void _showAddDialog() {