Files
MookNote/lib/pages/home/home_page.dart
2026-07-14 23:18:19 +08:00

4305 lines
184 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../providers/app_provider.dart';
import '../../models/data_models.dart';
import '../../utils/user_prefs.dart';
import '../../utils/responsive.dart';
import '../../utils/toast_util.dart';
import '../../widgets/custom_drawer.dart';
import '../../widgets/bottom_nav_bar.dart';
import '../../widgets/add_sheet.dart';
import '../../widgets/add_type_selector.dart';
import '../../widgets/fade_in_local_image.dart';
import '../../pages/epub_reader/epub_library_page.dart';
import '../../pages/movies/movie_detail_page.dart';
import '../../pages/book/book_detail_page.dart';
import '../../pages/note/note_detail_page.dart';
import '../../services/sync/backup_service.dart';
import '../../services/sync/webdav_service.dart';
import '../../pages/profile/settings_page.dart';
import 'main_content_page.dart';
import '../online_search/search_page.dart';
import '../online_search/online_search_page.dart';
import '../profile/profile_page.dart';
/// 主页 - 包含底部导航,可切换主页/我的
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
final PageController _pageController = PageController();
bool _isSwitchingPage = false;
int _lastNavIndex = 0;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_lastNavIndex = context.read<AppProvider>().bottomNavIndex;
});
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
void _onNavIndexChanged(AppProvider provider) {
final currentPage = provider.bottomNavIndex == 0 ? 0 : 1;
if (currentPage == _lastNavIndex) return;
_lastNavIndex = currentPage;
if (!_pageController.hasClients) return;
if (_pageController.page?.round() == currentPage) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_isSwitchingPage = true;
_pageController.jumpToPage(currentPage);
Future.delayed(const Duration(milliseconds: 300), () {
if (mounted) {
_isSwitchingPage = false;
provider.setBottomNavVisible(true);
}
});
});
}
@override
Widget build(BuildContext context) {
if (Breakpoint.isDesktop(context)) {
return _buildDesktopLayout(context);
}
if (Breakpoint.isTablet(context)) {
return _buildTabletLayout(context);
}
return _buildPhoneLayout(context);
}
// ─── 桌面布局(三栏:图标导航 | 列表面板 | 内容区) ──────
Widget _buildDesktopLayout(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Consumer<AppProvider>(
builder: (context, provider, child) {
_onNavIndexChanged(provider);
return Scaffold(
body: Row(
children: [
// 第一栏:图标导航
_DesktopIconRail(
mainTabIndex: provider.mainTabIndex,
onTabSelected: (index) {
provider.setMainTabIndex(index);
},
),
VerticalDivider(width: 1, thickness: 1, color: colors.outlineVariant),
// 第二栏:列表面板(搜索 + 列表)
SizedBox(
width: 300,
child: _DesktopListPanel(
mainTabIndex: provider.mainTabIndex,
),
),
VerticalDivider(width: 1, thickness: 1, color: colors.outlineVariant),
// 第三栏:内容区
Expanded(child: _buildPageView(provider)),
],
),
);
},
);
}
// ─── 平板布局 ──────────────────────────────────────────
Widget _buildTabletLayout(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Consumer<AppProvider>(
builder: (context, provider, child) {
_onNavIndexChanged(provider);
return Scaffold(
body: Row(
children: [
SizedBox(
width: 260,
child: Material(
color: colors.surfaceContainerHigh,
child: const CustomDrawer(embedded: true),
),
),
NavigationRail(
selectedIndex: provider.bottomNavIndex == 0 ? 0 : 1,
backgroundColor: colors.surface,
labelType: NavigationRailLabelType.all,
leading: Padding(
padding: const EdgeInsets.only(top: 8, bottom: 12),
child: FloatingActionButton.small(
onPressed: () => showAddSheet(context, provider),
child: const Icon(Icons.add),
),
),
onDestinationSelected: (index) {
provider.setBottomNavIndex(index == 0 ? 0 : 2);
},
destinations: const [
NavigationRailDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: Text('首页'),
),
NavigationRailDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person),
label: Text('我的'),
),
],
),
Expanded(child: _buildPageView(provider)),
],
),
);
},
);
}
// ─── 手机布局 ──────────────────────────────────────────
Widget _buildPhoneLayout(BuildContext context) {
return Scaffold(
drawer: context.watch<AppProvider>().bottomNavIndex != 1
? const CustomDrawer()
: null,
body: Consumer<AppProvider>(
builder: (context, provider, child) {
_onNavIndexChanged(provider);
return NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (_isSwitchingPage) return false;
if (notification is ScrollUpdateNotification) {
final delta = notification.scrollDelta;
if (delta != null && delta.abs() > 2) {
final userPrefs = UserPrefs();
if (!userPrefs.hideBottomNavOnScroll) return false;
if (delta < 0) {
provider.setBottomNavVisible(true);
} else {
provider.setBottomNavVisible(false);
}
}
}
return false;
},
child: Stack(
children: [
_buildPageView(provider),
// 底部导航栏区域
Positioned(
left: 0,
right: 0,
bottom: 0,
child: AnimatedSlide(
offset: provider.bottomNavVisible
? Offset.zero
: const Offset(-1, 0),
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
child: const CustomBottomNavBar(),
),
),
if (!provider.bottomNavVisible)
Positioned(
left: 0,
bottom: MediaQuery.of(context).padding.bottom + 20,
child: GestureDetector(
onTap: () => provider.setBottomNavVisible(true),
onHorizontalDragEnd: (details) {
if (details.primaryVelocity != null &&
details.primaryVelocity! > 0) {
provider.setBottomNavVisible(true);
}
},
child: Container(
width: 44,
height: 56,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.horizontal(
right: Radius.circular(28)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 20,
offset: const Offset(0, 4),
spreadRadius: 0,
),
],
),
child: Center(
child: Icon(
Icons.chevron_right,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4),
size: 24,
),
),
),
),
),
],
),
);
},
),
);
}
Widget _buildPageView(AppProvider provider) {
return PageView(
controller: _pageController,
physics: const NeverScrollableScrollPhysics(),
onPageChanged: (index) {
if (index == 0) {
provider.setBottomNavIndex(0);
} else if (index == 1) {
provider.setBottomNavIndex(2);
}
},
children: [
const MainContentPage(),
const ProfilePage(),
],
);
}
}
// ─── 第一栏:图标导航栏 ──────────────────────────────────
class _DesktopIconRail extends StatelessWidget {
final int mainTabIndex;
final ValueChanged<int> onTabSelected;
const _DesktopIconRail({
required this.mainTabIndex,
required this.onTabSelected,
});
static const _categoryMeta = [
(Icons.movie_outlined, Icons.movie, '影视', 0, Color(0xFF2563EB)),
(Icons.menu_book_outlined, Icons.menu_book, '阅读', 1, Color(0xFF16A34A)),
(Icons.note_outlined, Icons.note, '笔记', 2, Color(0xFF9333EA)),
(Icons.sports_esports_outlined, Icons.sports_esports, '游戏', 3, Color(0xFFEA580C)),
];
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final userPrefs = UserPrefs();
final tabs = _categoryMeta.where((t) {
switch (t.$4) {
case 0: return userPrefs.showMovieTab;
case 1: return userPrefs.showBookTab;
case 2: return userPrefs.showNoteTab;
case 3: return userPrefs.showGameTab;
default: return false;
}
}).toList();
return Material(
color: colors.surface,
child: SizedBox(
width: 160,
child: Column(
children: [
SizedBox(height: MediaQuery.of(context).padding.top + 8),
// 头像 + 昵称 + 座右铭
_buildProfileHeader(context),
const SizedBox(height: 10),
// 添加 + 搜索
_IconRailItem(icon: Icons.add_circle_outline, activeIcon: Icons.add_circle, label: '添加', accentColor: colors.primary, selected: false, onTap: () {
final provider = context.read<AppProvider>();
if (Breakpoint.isDesktop(context)) {
showAddTypeSelector(context).then((type) {
if (type != null) provider.startAddingType(type);
});
} else {
showAddSheet(context, provider);
}
}),
_IconRailItem(icon: Icons.search, activeIcon: Icons.search, label: '搜索', accentColor: colors.primary, selected: false, onTap: () => _showSearchDialog(context)),
const SizedBox(height: 2),
// 分类图标
...tabs.map((t) {
final selected = mainTabIndex == t.$4;
return _IconRailItem(
icon: t.$1,
activeIcon: t.$2,
label: t.$3,
accentColor: t.$5,
selected: selected,
onTap: () => onTabSelected(t.$4),
);
}),
const Divider(height: 24, indent: 12, endIndent: 12),
// 探索 + 工具(可滚动)
Expanded(
child: SingleChildScrollView(
child: Column(
children: [
_IconRailItem(icon: Icons.favorite_border, activeIcon: Icons.favorite, label: '统计', accentColor: colors.primary, selected: false, onTap: () => _showEncounterDialog(context)),
_IconRailItem(icon: Icons.explore_outlined, activeIcon: Icons.explore, label: '漫步', accentColor: colors.primary, selected: false, onTap: () => _showStrollDialog(context)),
_IconRailItem(icon: Icons.calendar_month_outlined, activeIcon: Icons.calendar_month, label: '日历', accentColor: colors.primary, selected: false, onTap: () => _showCalendarDialog(context)),
const Divider(height: 24, indent: 12, endIndent: 12),
_IconRailItem(icon: Icons.people_outline, activeIcon: Icons.people, label: '角色', accentColor: colors.primary, selected: false, onTap: () => _showPersonDialog(context)),
_IconRailItem(icon: Icons.label_outline, activeIcon: Icons.label, label: '标签', accentColor: colors.primary, selected: false, onTap: () => _showTagDialog(context)),
_IconRailItem(icon: Icons.auto_stories_outlined, activeIcon: Icons.auto_stories, label: 'EPUB', accentColor: colors.primary, selected: false, onTap: () => _push(context, const EpubLibraryPage())),
_IconRailItem(icon: Icons.backup_outlined, activeIcon: Icons.backup, label: '备份', accentColor: colors.primary, selected: false, onTap: () => _showBackupDialog(context)),
_IconRailItem(icon: Icons.delete_outline, activeIcon: Icons.delete, label: '回收', accentColor: colors.primary, selected: false, onTap: () => _showRecycleBinDialog(context)),
_IconRailItem(icon: Icons.feedback_outlined, activeIcon: Icons.feedback, label: '反馈', accentColor: colors.primary, selected: false, onTap: () => _showFeedbackDialog(context)),
],
),
),
),
// 设置(固定底部)
_IconRailItem(icon: Icons.settings_outlined, activeIcon: Icons.settings, label: '设置', accentColor: colors.primary, selected: false, onTap: () => _push(context, const SettingsPage())),
const SizedBox(height: 8),
],
),
),
);
}
Widget _buildProfileHeader(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final userPrefs = UserPrefs();
final avatarPath = userPrefs.avatarPath;
final nickname = userPrefs.nickname;
final motto = userPrefs.motto;
return GestureDetector(
onTap: () => _push(context, const SettingsPage()),
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colors.surfaceContainerHighest,
border: Border.all(color: colors.outlineVariant, width: 0.5),
),
clipBehavior: Clip.antiAlias,
child: avatarPath != null && avatarPath.isNotEmpty
? FadeInLocalImage(path: avatarPath, fit: BoxFit.cover,
errorWidget: Icon(Icons.person_outline, size: 16, color: colors.onSurface.withValues(alpha: 0.3)))
: Icon(Icons.person_outline, size: 16, color: colors.onSurface.withValues(alpha: 0.3)),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(nickname, maxLines: 1, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.onSurface)),
const SizedBox(height: 1),
Text(motto, maxLines: 1, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.4))),
],
),
),
],
),
),
);
}
void _push(BuildContext context, Widget page) {
Navigator.push(context, MaterialPageRoute(builder: (_) => page));
}
void _showSearchDialog(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final dialogHeight = (MediaQuery.of(context).size.height * 0.82).clamp(560.0, 820.0);
showDialog(
context: context,
builder: (dialogCtx) => Dialog(
backgroundColor: colors.surface,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: SizedBox(
width: 640,
height: dialogHeight,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: _SearchDialog(dialogContext: dialogCtx),
),
),
),
);
}
void _showFeedbackDialog(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final email = 'dellevin99@gmail.com';
final qqGroup = '1087203310';
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: colors.surface,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
title: Text('BUG反馈', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)),
content: SizedBox(
width: 400,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_feedbackRow(ctx, Icons.email_outlined, '作者邮箱', email, colors),
const SizedBox(height: 12),
_feedbackRow(ctx, Icons.group_outlined, 'QQ 群', qqGroup, colors),
],
),
),
),
);
}
Widget _feedbackRow(BuildContext ctx, IconData icon, String title, String value, ColorScheme colors) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colors.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Icon(icon, size: 18, color: colors.primary.withValues(alpha: 0.8)),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.5))),
const SizedBox(height: 1),
Text(value, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface)),
],
),
),
GestureDetector(
onTap: () {
Clipboard.setData(ClipboardData(text: value));
ToastUtil.show(ctx, '已复制');
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: colors.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(6),
),
child: Text('复制', style: TextStyle(fontSize: 12, color: colors.primary, fontWeight: FontWeight.w600)),
),
),
],
),
);
}
void _showBackupDialog(BuildContext context) {
showDialog(
context: context,
builder: (_) => const _BackupChoiceDialog(),
);
}
void _showEncounterDialog(BuildContext context) {
showDialog(context: context, builder: (_) => const _EncounterDialog());
}
void _showStrollDialog(BuildContext context) {
Navigator.of(context).push(_NoSwipeDialogRoute(builder: (_) => const _StrollDialog()));
}
void _showCalendarDialog(BuildContext context) {
showDialog(context: context, builder: (_) => const _CalendarDialog());
}
void _showPersonDialog(BuildContext context) {
showDialog(
context: context,
builder: (_) => const _PersonListDialog(),
);
}
void _showTagDialog(BuildContext context) {
showDialog(
context: context,
builder: (_) => const _TagManagementDialog(),
);
}
void _showRecycleBinDialog(BuildContext context) {
showDialog(
context: context,
builder: (_) => const _RecycleBinDialog(),
);
}
}
// ─── 统计弹窗 ──────────────────────────────────────────
class _EncounterDialog extends StatelessWidget {
const _EncounterDialog();
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final userPrefs = UserPrefs();
final firstUse = userPrefs.firstUseDate;
final now = DateTime.now();
final days = DateTime(now.year, now.month, now.day)
.difference(DateTime(firstUse.year, firstUse.month, firstUse.day))
.inDays + 1;
return AlertDialog(
backgroundColor: colors.surface,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
titlePadding: const EdgeInsets.fromLTRB(24, 20, 24, 0),
contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0),
title: const Text('统计'),
content: SizedBox(
width: 360,
height: 440,
child: Consumer<AppProvider>(
builder: (context, provider, child) {
final movies = provider.movies.where((m) => !m.isDeleted).toList();
final books = provider.books.where((b) => !b.isDeleted).toList();
final notes = provider.notes.where((n) => !n.isDeleted).toList();
final noteWords = notes.fold<int>(0, (sum, n) => sum + n.content.length);
int imageCount = 0;
for (final m in movies) { if (m.posterPath != null && m.posterPath!.isNotEmpty) imageCount++; }
for (final b in books) { if (b.coverPath != null && b.coverPath!.isNotEmpty) imageCount++; }
for (final n in notes) { imageCount += n.images.length; }
final totalRecords = movies.length + books.length + notes.length;
return Column(
children: [
// 相遇天数
const SizedBox(height: 16),
Text('与你', style: TextStyle(fontSize: 28, fontWeight: FontWeight.w700, color: colors.onSurface, letterSpacing: 4)),
const SizedBox(height: 8),
RichText(text: TextSpan(children: [
TextSpan(text: '相遇的第', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w400, color: colors.onSurface.withValues(alpha: 0.5))),
TextSpan(text: '$days', style: TextStyle(fontSize: 28, fontWeight: FontWeight.w700, color: colors.primary)),
TextSpan(text: '', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w400, color: colors.onSurface.withValues(alpha: 0.5))),
]), textAlign: TextAlign.center),
const SizedBox(height: 4),
Text('${firstUse.year}${firstUse.month}${firstUse.day}日 — ${now.year}${now.month}${now.day}',
style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))),
const Spacer(),
Divider(color: colors.outlineVariant, thickness: 0.5),
const SizedBox(height: 16),
Text('已记录', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.5), letterSpacing: 2)),
const SizedBox(height: 12),
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [
_recordItem(context, '$totalRecords', '条记录', colors),
_recordItem(context, _formatCount(noteWords), '文字', colors),
_recordItem(context, '$imageCount', '张图片', colors),
]),
const SizedBox(height: 16),
Divider(color: colors.outlineVariant, thickness: 0.5),
const SizedBox(height: 16),
// 城市天际线动画
SizedBox(height: 52, child: _CityScape(colors: colors)),
const SizedBox(height: 8),
],
);
},
),
),
);
}
String _formatCount(int count) {
if (count >= 10000) return '${(count / 10000).toStringAsFixed(1)}';
if (count >= 1000) return '${(count / 1000).toStringAsFixed(1)}k';
return '$count';
}
Widget _recordItem(BuildContext context, String value, String label, ColorScheme colors) {
return Column(children: [
Text(value, style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.primary)),
const SizedBox(height: 4),
Text(label, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))),
]);
}
}
// ─── 城市天际线动画 ──────────────────────────────────────
class _CityScape extends StatefulWidget {
final ColorScheme colors;
const _CityScape({required this.colors});
@override
State<_CityScape> createState() => _CityScapeState();
}
class _CityScapeState extends State<_CityScape> with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: const Duration(seconds: 20))..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, _) => CustomPaint(size: const Size(double.infinity, 52), painter: _CityPainter(_controller.value, widget.colors)),
);
}
}
class _CityPainter extends CustomPainter {
final double t;
final ColorScheme colors;
_CityPainter(this.t, this.colors);
static const _buildings = <(double, double, bool)>[
(0.30, 16, false), (0.48, 10, true), (0.22, 20, false), (0.55, 10, true),
(0.35, 14, false), (0.42, 10, true), (0.25, 18, false),
];
static const _plants = <(double, double, int)>[
(0.35, 10, 0), (0.50, 8, 1), (0.22, 14, 2), (0.45, 8, 0),
(0.30, 12, 1), (0.20, 10, 2), (0.52, 8, 1), (0.28, 14, 2),
];
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..style = PaintingStyle.fill;
final groundY = size.height - 2;
_drawStars(canvas, paint, size, t);
_drawBuildings(canvas, paint, size, groundY, t * 0.6);
_drawPlants(canvas, paint, size, groundY, t * 1.0);
paint.color = colors.onSurface.withValues(alpha: 0.10);
canvas.drawRect(Rect.fromLTWH(0, groundY, size.width, 1), paint);
}
void _drawBuildings(Canvas canvas, Paint paint, Size size, double groundY, double scrollT) {
double totalW = 0;
for (final b in _buildings) { totalW += b.$2 + 4; }
final offset = (scrollT * totalW) % totalW;
double x = -offset;
int i = 0;
while (x < size.width + 20) {
final (hR, w, spire) = _buildings[i % _buildings.length];
final h = hR * (size.height - 8);
final bx = x;
final by = groundY - h;
if (bx + w > -10 && bx < size.width + 10) {
final a = 0.06 + hR * 0.05;
paint.color = colors.onSurface.withValues(alpha: a);
canvas.drawRect(Rect.fromLTWH(bx, by, w, h), paint);
if (h > 18) {
paint.color = colors.onSurface.withValues(alpha: 0.04);
for (int r = 0; r < ((h - 6) / 5).floor(); r++) {
for (int c = 0; c < ((w - 4) / 4).floor(); c++) {
if ((i * 13 + r * 7 + c * 11) % 4 == 0) continue;
canvas.drawRect(Rect.fromLTWH(bx + 3 + c * 4.0, by + 4 + r * 5.0, 2, 2), paint);
}
}
}
if (spire) {
paint.color = colors.onSurface.withValues(alpha: a);
final sh = h * 0.18;
canvas.drawPath(Path()..moveTo(bx + w / 2 - 2, by)..lineTo(bx + w / 2, by - sh)..lineTo(bx + w / 2 + 2, by)..close(), paint);
}
if (!spire && hR > 0.4 && i % 3 == 0) {
paint.color = colors.onSurface.withValues(alpha: a * 0.5);
canvas.drawRect(Rect.fromLTWH(bx + w / 2 - 0.5, by - 6, 1, 6), paint);
canvas.drawCircle(Offset(bx + w / 2, by - 6), 1.2, paint);
}
}
x += w + 16;
i++;
}
}
void _drawPlants(Canvas canvas, Paint paint, Size size, double groundY, double scrollT) {
double totalW = 0;
for (final p in _plants) { totalW += p.$2 + 6; }
final offset = (scrollT * totalW) % totalW;
double x = -offset;
int i = 0;
while (x < size.width + 20) {
final (hR, w, type) = _plants[i % _plants.length];
final h = hR * (size.height - 10);
final bx = x;
final by = groundY;
if (bx + w > -10 && bx < size.width + 10) {
final alpha = 0.18 + hR * 0.10;
if (type == 0) {
final trunkH = h * 0.4;
final crownR = w * 0.45;
paint.color = colors.onSurface.withValues(alpha: alpha * 0.7);
canvas.drawRect(Rect.fromLTWH(bx + w / 2 - 1.5, by - trunkH, 3, trunkH), paint);
paint.color = colors.onSurface.withValues(alpha: alpha);
canvas.drawOval(Rect.fromCenter(center: Offset(bx + w / 2, by - trunkH - crownR * 0.6), width: crownR * 2, height: crownR * 1.6), paint);
} else if (type == 1) {
final trunkH = h * 0.25;
paint.color = colors.onSurface.withValues(alpha: alpha * 0.7);
canvas.drawRect(Rect.fromLTWH(bx + w / 2 - 1.5, by - trunkH, 3, trunkH), paint);
paint.color = colors.onSurface.withValues(alpha: alpha);
for (int layer = 0; layer < 3; layer++) {
final layerW = w * (1.0 - layer * 0.2);
final layerBottom = by - trunkH - layer * (h * 0.2);
final layerTop = layerBottom - h * 0.28;
canvas.drawPath(Path()..moveTo(bx + w / 2 - layerW / 2, layerBottom)..lineTo(bx + w / 2, layerTop)..lineTo(bx + w / 2 + layerW / 2, layerBottom)..close(), paint);
}
} else {
paint.color = colors.onSurface.withValues(alpha: alpha);
canvas.drawOval(Rect.fromLTWH(bx, by - h, w, h), paint);
paint.color = colors.onSurface.withValues(alpha: alpha * 0.8);
canvas.drawOval(Rect.fromLTWH(bx + w * 0.2, by - h * 0.7, w * 0.6, h * 0.6), paint);
}
}
x += w + 14;
i++;
}
}
void _drawStars(Canvas canvas, Paint paint, Size size, double t) {
const stars = [
(12.0, 5.0, 1.2), (38.0, 12.0, 0.8), (65.0, 3.0, 1.0),
(95.0, 16.0, 1.4), (130.0, 7.0, 0.9), (165.0, 14.0, 1.1),
(200.0, 4.0, 1.3), (235.0, 18.0, 0.7), (270.0, 9.0, 1.0),
(310.0, 2.0, 1.2), (345.0, 15.0, 0.9), (380.0, 6.0, 1.1),
(420.0, 11.0, 0.8), (460.0, 3.0, 1.0), (500.0, 17.0, 1.3),
];
for (int i = 0; i < stars.length; i++) {
final (sx, sy, r) = stars[i];
if (sx > size.width) continue;
final flicker = 0.15 + 0.12 * sin(t * 2 * pi + i * 1.1);
paint.color = colors.onSurface.withValues(alpha: flicker);
canvas.drawCircle(Offset(sx, sy), r, paint);
}
}
@override
bool shouldRepaint(_CityPainter old) => old.t != t;
}
// ─── 不拦截滑动手势的弹窗路由 ──────────────────────────────
class _NoSwipeDialogRoute<T> extends PageRoute<T> {
final WidgetBuilder builder;
_NoSwipeDialogRoute({required this.builder});
@override
bool get popGestureEnabled => false; // 关键:禁止拖拽关闭,不拦截水平手势
@override
bool get barrierDismissible => true;
@override
Color? get barrierColor => Colors.black54;
@override
String? get barrierLabel => 'Dismiss';
@override
Duration get transitionDuration => const Duration(milliseconds: 200);
@override
Duration get reverseTransitionDuration => const Duration(milliseconds: 150);
@override
Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
return builder(context);
}
@override
Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
return FadeTransition(opacity: animation, child: child);
}
@override
bool get opaque => false;
@override
bool get maintainState => true;
}
// ─── 漫步弹窗 ──────────────────────────────────────────
class _StrollDialog extends StatefulWidget {
const _StrollDialog();
@override
State<_StrollDialog> createState() => _StrollDialogState();
}
class _StrollDialogState extends State<_StrollDialog> {
final _random = Random();
final List<_StrollItem> _items = [];
final Set<String> _seenIds = {};
late PageController _pageController;
String _filter = 'all';
@override
void initState() {
super.initState();
_pageController = PageController();
_loadBatch(5);
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
void _loadBatch(int count) {
final provider = context.read<AppProvider>();
final moviePool = <_StrollItem>[];
final bookPool = <_StrollItem>[];
final notePool = <_StrollItem>[];
if (_filter == 'all' || _filter == 'movie') {
for (final m in provider.movies.where((m) => !m.isDeleted)) {
moviePool.add(_StrollItem(type: 'movie', data: m, id: 'm_${m.id}', title: m.title,
subtitle: m.alternateTitles.take(2).join(' / '), detail: _movieDetail(m), imagePath: m.posterPath,
icon: Icons.movie_outlined, label: '影视', rating: m.rating, createdAt: m.createdAt,
tags: m.genres.take(3).toList(), color: const Color(0xFF4A90D9)));
}
}
if (_filter == 'all' || _filter == 'book') {
for (final b in provider.books.where((b) => !b.isDeleted)) {
bookPool.add(_StrollItem(type: 'book', data: b, id: 'b_${b.id}', title: b.title,
subtitle: b.authors.take(2).join(' / '), detail: _bookDetail(b), imagePath: b.coverPath,
icon: Icons.menu_book_outlined, label: '书籍', rating: b.rating, createdAt: b.createdAt,
tags: b.genres.take(3).toList(), color: const Color(0xFF7E57C2)));
}
}
if (_filter == 'all' || _filter == 'note') {
for (final n in provider.notes.where((n) => !n.isDeleted)) {
notePool.add(_StrollItem(type: 'note', data: n, id: 'n_${n.id}', title: n.title.isNotEmpty ? n.title : '随手记',
subtitle: n.tags.take(3).join(' · '), detail: n.content, imagePath: n.images.isNotEmpty ? n.images.first : null,
icon: Icons.note_outlined, label: '笔记', createdAt: n.createdAt,
tags: n.tags.take(3).toList(), color: const Color(0xFF66BB6A)));
}
}
final pools = <List<_StrollItem>>[];
if (moviePool.isNotEmpty) pools.add(moviePool);
if (bookPool.isNotEmpty) pools.add(bookPool);
if (notePool.isNotEmpty) pools.add(notePool);
if (pools.isEmpty) return;
final target = _items.length + count;
int attempts = 0;
while (_items.length < target && attempts < count * 20) {
attempts++;
final pool = _filter == 'all' ? pools[_random.nextInt(pools.length)] : pools.first;
final item = _weightedPick(pool);
if (item != null && !_seenIds.contains(item.id)) { _seenIds.add(item.id); _items.add(item); }
}
}
_StrollItem? _weightedPick(List<_StrollItem> pool) {
if (pool.isEmpty) return null;
final weights = pool.map((item) => (item.rating ?? 5.0).clamp(1.0, 10.0)).toList();
final total = weights.reduce((a, b) => a + b);
var roll = _random.nextDouble() * total;
for (int i = 0; i < pool.length; i++) { roll -= weights[i]; if (roll <= 0) return pool[i]; }
return pool.last;
}
void _reshuffle() { setState(() { _items.clear(); _seenIds.clear(); _loadBatch(5); }); }
String _movieDetail(Movie m) {
if (m.summary != null && m.summary!.isNotEmpty) return m.summary!.length > 100 ? '${m.summary!.substring(0, 100)}...' : m.summary!;
return '';
}
String _bookDetail(Book b) {
final parts = <String>[];
if (b.publisher != null && b.publisher!.isNotEmpty) parts.add(b.publisher!);
if (b.summary != null && b.summary!.isNotEmpty) parts.add(b.summary!.length > 100 ? '${b.summary!.substring(0, 100)}...' : b.summary!);
return parts.join('\n');
}
String _timeAgoText(DateTime date) {
final diff = DateTime.now().difference(date);
if (diff.inDays >= 365) return '${(diff.inDays / 365).floor()}年前';
if (diff.inDays >= 30) return '${(diff.inDays / 30).floor()}个月前';
if (diff.inDays > 0) return '${diff.inDays}天前';
if (diff.inHours > 0) return '${diff.inHours}小时前';
return '刚刚';
}
String _actionVerb(String type) {
switch (type) {
case 'movie': return '看过';
case 'book': return '读过';
case 'note': return '写下';
default: return '';
}
}
void _openDetail(_StrollItem item) {
// 先 pop 弹窗,再 push 详情页
final navigator = Navigator.of(context);
navigator.pop();
switch (item.type) {
case 'movie': navigator.push(MaterialPageRoute(builder: (_) => MovieDetailPage(movie: item.data as Movie)));
case 'book': navigator.push(MaterialPageRoute(builder: (_) => BookDetailPage(book: item.data as Book)));
case 'note': navigator.push(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;
return Dialog(
backgroundColor: colors.surface,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
child: SizedBox(
width: 520,
height: 620,
child: Column(
children: [
// 标题栏
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 16, 0),
child: Row(children: [
const Text('漫步', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
const Spacer(),
...[('all', '全部'), ('movie', '影视'), ('book', '书籍'), ('note', '笔记')].map((f) => Padding(
padding: const EdgeInsets.only(left: 4),
child: GestureDetector(
onTap: () { if (_filter != f.$1) setState(() { _filter = f.$1; _items.clear(); _seenIds.clear(); _loadBatch(5); }); },
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: _filter == f.$1 ? colors.primary : colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(12)),
child: Text(f.$2, style: TextStyle(fontSize: 11, fontWeight: _filter == f.$1 ? FontWeight.w600 : FontWeight.normal,
color: _filter == f.$1 ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5))),
),
),
)),
const SizedBox(width: 4),
GestureDetector(
onTap: _reshuffle,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(14)),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.casino_outlined, size: 13, color: colors.onPrimary),
const SizedBox(width: 3),
Text('随机', style: TextStyle(fontSize: 11, color: colors.onPrimary, fontWeight: FontWeight.w500)),
]),
),
),
]),
),
// 内容
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: _items.isEmpty
? Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.explore_outlined, size: 40, color: colors.onSurface.withValues(alpha: 0.2)),
const SizedBox(height: 12),
Text('还没有内容', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4))),
]))
: PageView.builder(
controller: _pageController,
onPageChanged: (index) { if (index >= _items.length - 2) setState(() => _loadBatch(3)); },
itemCount: _items.length,
itemBuilder: (context, index) {
return _buildCard(_items[index], colors);
},
),
),
),
],
),
),
);
}
Widget _buildCard(_StrollItem item, ColorScheme colors) {
final hasImage = item.imagePath != null && item.imagePath!.isNotEmpty;
return GestureDetector(
onTap: () => _openDetail(item),
child: hasImage ? _buildImmersiveCard(item, colors) : _buildContentCard(item, colors),
);
}
Widget _buildImmersiveCard(_StrollItem item, ColorScheme colors) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 16, offset: const Offset(0, 6))]),
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: 12, left: 12, right: 12, child: _buildTopBadges(item)),
Positioned(left: 16, right: 16, bottom: 16, child: _buildBottomContent(item, Colors.white)),
]),
);
}
Widget _buildContentCard(_StrollItem item, ColorScheme colors) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(16),
border: Border.all(color: colors.outlineVariant, width: 0.5),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 12, offset: const Offset(0, 4))]),
child: Padding(padding: const EdgeInsets.all(16), child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTopBadges(item, textColor: colors.onSurface, bgColor: item.color.withValues(alpha: 0.1)),
const SizedBox(height: 12),
if (item.tags.isNotEmpty) Padding(padding: const EdgeInsets.only(bottom: 8),
child: Wrap(spacing: 6, children: item.tags.take(3).map((tag) => Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(color: item.color.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(12)),
child: Text(tag, style: TextStyle(fontSize: 11, color: item.color)),
)).toList())),
Text(item.title, maxLines: 2, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: colors.onSurface, height: 1.3)),
if (item.subtitle.isNotEmpty) ...[
const SizedBox(height: 3),
Text(item.subtitle, maxLines: 1, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.5))),
],
if (item.detail.isNotEmpty) ...[
const SizedBox(height: 8),
Expanded(child: SingleChildScrollView(child: Text(item.detail, maxLines: 5, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.55), height: 1.6)))),
],
const SizedBox(height: 8),
Row(children: [
Text('${_timeAgoText(item.createdAt)} ${_actionVerb(item.type)}',
style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))),
const Spacer(),
_actionBtn(Icons.visibility_outlined, '查看', () => _openDetail(item), colors: colors),
const SizedBox(width: 6),
_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: 8, vertical: 4),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(16)),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(item.icon, size: 13, color: fg), const SizedBox(width: 3),
Text(item.label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: fg)),
])),
const Spacer(),
if (item.rating != null) Container(padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(16)),
child: Row(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.star, size: 13, color: Color(0xFFFFB800)), const SizedBox(width: 2),
Text(item.rating!.toStringAsFixed(1), style: TextStyle(fontSize: 11, 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: 8),
child: Wrap(spacing: 6, children: item.tags.take(3).map((tag) => Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white.withValues(alpha: 0.2), width: 0.5)),
child: Text(tag, style: TextStyle(fontSize: 10, color: Colors.white.withValues(alpha: 0.8))),
)).toList())),
Text(item.title, maxLines: 2, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: textColor, height: 1.3)),
if (item.subtitle.isNotEmpty) ...[
const SizedBox(height: 3),
Text(item.subtitle, maxLines: 1, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 13, color: textColor.withValues(alpha: 0.6))),
],
if (item.detail.isNotEmpty) ...[
const SizedBox(height: 8),
Text(item.detail, maxLines: 2, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: textColor.withValues(alpha: 0.5), height: 1.5)),
],
const SizedBox(height: 12),
Row(children: [
Text('${_timeAgoText(item.createdAt)} ${_actionVerb(item.type)}',
style: TextStyle(fontSize: 11, color: textColor.withValues(alpha: 0.4))),
const Spacer(),
_actionBtn(Icons.visibility_outlined, '查看', () => _openDetail(item)),
const SizedBox(width: 8),
_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: 10, vertical: 5),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(16), border: Border.all(color: border, width: 0.5)),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(icon, size: 13, color: fg.withValues(alpha: 0.8)), const SizedBox(width: 3),
Text(label, style: TextStyle(fontSize: 11, 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))),
child: const Text('删除')),
],
));
}
}
class _StrollItem {
final String type;
final dynamic data;
final String id;
final String title;
final String subtitle;
final String detail;
final String? imagePath;
final IconData icon;
final String label;
final double? rating;
final DateTime createdAt;
final List<String> tags;
final Color color;
_StrollItem({required this.type, required this.data, required this.id, required this.title,
required this.subtitle, required this.detail, this.imagePath, required this.icon,
required this.label, this.rating, required this.createdAt, this.tags = const [], required this.color});
}
// ─── 书影日历弹窗 ──────────────────────────────────────────
class _CalendarDialog extends StatefulWidget {
const _CalendarDialog();
@override
State<_CalendarDialog> createState() => _CalendarDialogState();
}
class _CalendarDialogState extends State<_CalendarDialog> {
late DateTime _currentMonth;
DateTime? _selectedDay;
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, () => []).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, () => []).add(_CalendarItem(path: b.coverPath!, title: b.title, type: 'book', data: b));
}
_dayItems = map;
}
@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 AlertDialog(
backgroundColor: colors.surface,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
titlePadding: const EdgeInsets.fromLTRB(24, 20, 24, 0),
contentPadding: const EdgeInsets.fromLTRB(0, 8, 0, 0),
title: Row(children: [
const Text('书影日历'),
const Spacer(),
IconButton(onPressed: () => setState(() { _currentMonth = DateTime(_currentMonth.year, _currentMonth.month - 1); _selectedDay = null; }),
icon: Icon(Icons.chevron_left, size: 20, color: colors.onSurface.withValues(alpha: 0.6)), padding: EdgeInsets.zero, constraints: const BoxConstraints()),
Text('${_currentMonth.year}.${_currentMonth.month.toString().padLeft(2, '0')}',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface)),
IconButton(onPressed: () => setState(() { _currentMonth = DateTime(_currentMonth.year, _currentMonth.month + 1); _selectedDay = null; }),
icon: Icon(Icons.chevron_right, size: 20, color: colors.onSurface.withValues(alpha: 0.6)), padding: EdgeInsets.zero, constraints: const BoxConstraints()),
]),
content: SizedBox(
width: 420,
height: 520,
child: Column(
children: [
// 星期头
Padding(padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(children: ['', '', '', '', '', '', ''].map((d) =>
Expanded(child: Center(child: Text(d, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: colors.onSurface.withValues(alpha: 0.35))))),
).toList())),
// 日历网格
_buildCalendarGrid(colors, today),
// 选中日期详情
if (_selectedDay != null) ...[
Divider(height: 0.5, color: colors.outlineVariant),
Expanded(child: _buildSelectedDayDetail(colors)),
],
],
),
),
);
}
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) => SizedBox(
height: 52,
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(1.5),
decoration: BoxDecoration(
color: isSelected ? colors.primary.withValues(alpha: 0.08) : hasItems ? colors.surfaceContainerHigh : null,
borderRadius: BorderRadius.circular(8),
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
? ClipRRect(borderRadius: BorderRadius.circular(7), child: Stack(fit: StackFit.expand, children: [
Image(image: FileImage(File(items.first.path)), fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(color: colors.surfaceContainerHighest,
child: Icon(Icons.image_outlined, size: 14, color: colors.onSurface.withValues(alpha: 0.2)))),
Positioned(left: 0, right: 0, bottom: 0,
child: Container(padding: const EdgeInsets.fromLTRB(3, 10, 3, 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: 9, fontWeight: isToday ? FontWeight.w700 : FontWeight.w500,
color: isToday ? const Color(0xFFFFD54F) : Colors.white)))),
if (items.length > 1) Positioned(top: 2, right: 2,
child: Container(padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.6), borderRadius: BorderRadius.circular(4)),
child: Text('+${items.length - 1}', style: const TextStyle(fontSize: 8, fontWeight: FontWeight.w600, color: Colors.white)))),
]))
: Center(child: Text('$day', style: TextStyle(fontSize: 12,
fontWeight: isToday ? FontWeight.w600 : FontWeight.normal,
color: isToday ? colors.primary : colors.onSurface.withValues(alpha: 0.35)))),
),
);
}
Widget _buildSelectedDayDetail(ColorScheme colors) {
final items = _dayItems[_selectedDay] ?? [];
if (items.isEmpty) {
return Padding(padding: const EdgeInsets.all(16),
child: Text('${_selectedDay!.month}${_selectedDay!.day}日 暂无记录',
style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4))));
}
return Column(mainAxisSize: MainAxisSize.min, children: [
Padding(padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Row(children: [
Text('${_selectedDay!.month}${_selectedDay!.day}', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.6))),
const SizedBox(width: 6),
Text('${items.length}条记录', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35))),
])),
Expanded(child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: items.length,
separatorBuilder: (_, __) => Divider(height: 0.5, color: colors.outlineVariant),
itemBuilder: (_, i) {
final item = items[i];
return ListTile(
contentPadding: EdgeInsets.zero,
leading: ClipRRect(borderRadius: BorderRadius.circular(6),
child: SizedBox(width: 36, height: 36,
child: Image(image: FileImage(File(item.path)), fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(color: colors.surfaceContainerHighest,
child: Icon(Icons.image_outlined, size: 14, 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: 6, 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: 10, color: item.type == 'movie' ? const Color(0xFF4A90D9) : const Color(0xFF7E57C2)))),
onTap: () {
final nav = Navigator.of(context);
nav.pop();
if (item.type == 'movie') {
nav.push(MaterialPageRoute(builder: (_) => MovieDetailPage(movie: item.data as Movie)));
} else {
nav.push(MaterialPageRoute(builder: (_) => BookDetailPage(book: item.data as Book)));
}
},
);
},
)),
]);
}
}
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});
}
// ─── 角色信息弹窗 ──────────────────────────────────────────
class _PersonListDialog extends StatefulWidget {
const _PersonListDialog();
@override
State<_PersonListDialog> createState() => _PersonListDialogState();
}
class _PersonListDialogState extends State<_PersonListDialog> {
String _filter = 'all';
String _searchQuery = '';
final _searchController = TextEditingController();
bool _loading = false;
_PersonEntry? _selectedPerson;
@override
void initState() {
super.initState();
_refresh();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _refresh() async {
setState(() => _loading = true);
final provider = context.read<AppProvider>();
await Future.wait([provider.loadMovies(), provider.loadBooks()]);
if (mounted) setState(() => _loading = false);
}
List<_PersonEntry> _buildPersons() {
final provider = context.read<AppProvider>();
final map = <String, _PersonEntry>{};
void addRole(String name, String role, {Movie? movie, Book? book}) {
if (name.trim().isEmpty) return;
final key = name.trim();
map.putIfAbsent(key, () => _PersonEntry(name: key));
map[key]!.roles.add(role);
if (movie != null && !map[key]!.movies.any((m) => m.id == movie.id)) map[key]!.movies.add(movie);
if (book != null && !map[key]!.books.any((b) => b.id == book.id)) map[key]!.books.add(book);
}
for (final m in provider.movies.where((m) => !m.isDeleted)) {
for (final d in m.directors) addRole(d, '导演', movie: m);
for (final w in m.writers) addRole(w, '编剧', movie: m);
for (final a in m.actors) addRole(a, '主演', movie: m);
}
for (final b in provider.books.where((b) => !b.isDeleted)) {
for (final a in b.authors) addRole(a, '作者', book: b);
for (final t in b.translators) addRole(t, '译者', book: b);
}
var list = map.values.toList();
list.sort((a, b) => a.name.compareTo(b.name));
return list;
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final allPersons = _buildPersons();
var filtered = allPersons.where((p) {
if (_filter != 'all' && !p.roles.contains(_filter)) return false;
if (_searchQuery.isNotEmpty && !p.name.toLowerCase().contains(_searchQuery.toLowerCase())) return false;
return true;
}).toList();
return AlertDialog(
backgroundColor: colors.surface,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
titlePadding: const EdgeInsets.fromLTRB(24, 20, 24, 0),
contentPadding: const EdgeInsets.fromLTRB(0, 8, 0, 0),
title: Row(children: [
const Text('角色信息'),
const Spacer(),
if (_loading)
SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: colors.primary))
else
IconButton(icon: const Icon(Icons.refresh, size: 20), onPressed: _refresh, padding: EdgeInsets.zero, constraints: const BoxConstraints()),
]),
content: SizedBox(
width: 480,
height: 480,
child: _selectedPerson != null
? _buildPersonDetail(_selectedPerson!, colors)
: Column(
children: [
// 搜索栏
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Container(
height: 38,
decoration: BoxDecoration(color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(19)),
child: Row(children: [
const SizedBox(width: 14),
Icon(Icons.search, size: 18, color: colors.onSurface.withValues(alpha: 0.3)),
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: 12, color: colors.onSurface.withValues(alpha: 0.3)),
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 10),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
),
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.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: 14),
]),
),
),
// 角色筛选
Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 4),
child: Row(children: [
for (final f in ['all', '导演', '编剧', '主演', '作者', '译者'])
Padding(
padding: const EdgeInsets.only(right: 5),
child: GestureDetector(
onTap: () => setState(() => _filter = f),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: _filter == f ? colors.primary : colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
),
child: Text(f == 'all' ? '全部' : f,
style: TextStyle(fontSize: 11, fontWeight: _filter == f ? FontWeight.w600 : FontWeight.normal,
color: _filter == f ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5))),
),
),
),
]),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 4),
child: Align(alignment: Alignment.centerLeft, child: Text('${filtered.length}', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.35)))),
),
// 列表
Expanded(
child: filtered.isEmpty
? Center(child: Text('暂无数据', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.3))))
: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 20),
itemCount: filtered.length,
separatorBuilder: (_, __) => Divider(height: 0.5, color: colors.outlineVariant),
itemBuilder: (_, i) => _buildPersonTile(filtered[i], colors),
),
),
],
),
),
);
}
Widget _buildPersonTile(_PersonEntry person, ColorScheme colors) {
final roleColors = {
'导演': const Color(0xFF4A90D9),
'编剧': const Color(0xFF009688),
'主演': const Color(0xFFE91E63),
'作者': const Color(0xFF7E57C2),
'译者': const Color(0xFFFF7043),
};
final totalWorks = person.movies.length + person.books.length;
return ListTile(
contentPadding: const EdgeInsets.symmetric(vertical: 2),
leading: CircleAvatar(
radius: 18,
backgroundColor: colors.surfaceContainerHighest,
child: Text(person.name.isNotEmpty ? person.name[0] : '?',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.5))),
),
title: Text(person.name, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.onSurface)),
subtitle: Padding(
padding: const EdgeInsets.only(top: 3),
child: Wrap(spacing: 4, runSpacing: 3, children: [
for (final role in person.roles.toSet())
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(color: (roleColors[role] ?? colors.outline).withValues(alpha: 0.1), borderRadius: BorderRadius.circular(3)),
child: Text(role, style: TextStyle(fontSize: 9, color: roleColors[role] ?? colors.onSurface)),
),
Text('$totalWorks 部作品', style: TextStyle(fontSize: 9, color: colors.onSurface.withValues(alpha: 0.35))),
]),
),
trailing: Icon(Icons.chevron_right, size: 16, color: colors.onSurface.withValues(alpha: 0.25)),
onTap: () => setState(() => _selectedPerson = person),
);
}
Widget _buildPersonDetail(_PersonEntry person, ColorScheme colors) {
final roleColors = {
'导演': const Color(0xFF4A90D9),
'编剧': const Color(0xFF009688),
'主演': const Color(0xFFE91E63),
'作者': const Color(0xFF7E57C2),
'译者': const Color(0xFFFF7043),
};
final movieItems = <_WorkItem>[];
final bookItems = <_WorkItem>[];
for (final m in person.movies) {
final roles = <String>[];
if (m.directors.contains(person.name)) roles.add('导演');
if (m.writers.contains(person.name)) roles.add('编剧');
if (m.actors.contains(person.name)) roles.add('主演');
movieItems.add(_WorkItem(title: m.title, roles: roles, path: m.posterPath, data: m));
}
for (final b in person.books) {
final roles = <String>[];
if (b.authors.contains(person.name)) roles.add('作者');
if (b.translators.contains(person.name)) roles.add('译者');
bookItems.add(_WorkItem(title: b.title, roles: roles, path: b.coverPath, data: b));
}
return Column(
children: [
// 返回按钮 + 标题
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 16, 8),
child: Row(children: [
IconButton(
icon: const Icon(Icons.arrow_back, size: 20),
onPressed: () => setState(() => _selectedPerson = null),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: 8),
Text(person.name, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)),
]),
),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
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(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(item, colors, isMovie: false),
],
],
),
),
],
);
}
Widget _buildWorkTile(_WorkItem item, ColorScheme colors, {required bool isMovie}) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: InkWell(
onTap: () {
Navigator.pop(context);
if (isMovie) {
Navigator.of(context, rootNavigator: true).push(MaterialPageRoute(builder: (_) => MovieDetailPage(movie: item.data as Movie)));
} else {
Navigator.of(context, rootNavigator: true).push(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
? FadeInLocalImage(path: item.path!, fit: BoxFit.cover,
errorWidget: 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});
}
// ─── 标签管理弹窗 ──────────────────────────────────────────
class _TagManagementDialog extends StatefulWidget {
const _TagManagementDialog();
@override
State<_TagManagementDialog> createState() => _TagManagementDialogState();
}
class _TagManagementDialogState extends State<_TagManagementDialog> {
int _currentIndex = 0;
bool _isSyncing = false;
static const _tabTypes = ['movie_genre', 'book_genre', 'note_tag', 'game_genre'];
static const _typeLabels = ['影视类型', '书籍类型', '笔记标签', '游戏类型'];
static const _typeIcons = [Icons.movie_outlined, Icons.menu_book_outlined, Icons.note_outlined, Icons.sports_esports_outlined];
final Map<String, List<Map<String, dynamic>>> _tagCache = {};
Map<String, int> _usageCounts = {};
String? _newlyAddedTagId;
String _searchQuery = '';
final _searchController = TextEditingController();
@override
void initState() {
super.initState();
_loadTags(_tabTypes[0]);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_updateUsageCounts();
}
void _updateUsageCounts() {
final provider = context.read<AppProvider>();
final counts = <String, int>{};
for (final m in provider.movies.where((m) => !m.isDeleted)) { for (final g in m.genres) counts[g] = (counts[g] ?? 0) + 1; }
for (final b in provider.books.where((b) => !b.isDeleted)) { for (final g in b.genres) counts[g] = (counts[g] ?? 0) + 1; }
for (final n in provider.notes.where((n) => !n.isDeleted)) { for (final t in n.tags) counts[t] = (counts[t] ?? 0) + 1; }
for (final g in provider.games.where((g) => !g.isDeleted)) { for (final genre in g.genres) counts[genre] = (counts[genre] ?? 0) + 1; }
_usageCounts = counts;
}
Future<void> _loadTags(String type) async {
final provider = context.read<AppProvider>();
final tags = await provider.getTags(type);
if (mounted) setState(() => _tagCache[type] = tags);
}
Future<void> _syncTags() async {
setState(() => _isSyncing = true);
try {
final provider = context.read<AppProvider>();
final count = await provider.syncTagsFromData();
if (mounted) {
ToastUtil.show(context, count > 0 ? '已同步 $count 个新标签' : '标签已是最新');
await _loadTags(_currentType);
_updateUsageCounts();
}
} finally {
if (mounted) setState(() => _isSyncing = false);
}
}
String get _currentType => _tabTypes[_currentIndex];
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return AlertDialog(
backgroundColor: colors.surface,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
titlePadding: const EdgeInsets.fromLTRB(24, 20, 24, 0),
contentPadding: const EdgeInsets.fromLTRB(0, 8, 0, 0),
title: Row(children: [
const Text('标签管理'),
const Spacer(),
// Tab 切换
Container(
decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)),
child: Row(children: [
for (int i = 0; i < _tabTypes.length; i++)
_tabButton(colors, _typeLabels[i], i),
]),
),
const SizedBox(width: 4),
if (_isSyncing)
SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: colors.primary))
else
IconButton(icon: const Icon(Icons.sync, size: 20), tooltip: '从数据中同步标签', onPressed: _syncTags, padding: EdgeInsets.zero, constraints: const BoxConstraints()),
]),
content: SizedBox(
width: 520,
height: 440,
child: Column(
children: [
// 搜索栏
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Container(
height: 38,
decoration: BoxDecoration(color: colors.surfaceContainerHigh, 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,
),
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),
]),
),
),
// 标签列表
Expanded(child: _buildTagList(_currentType)),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('关闭'),
),
ElevatedButton(
onPressed: _showAddDialog,
style: ElevatedButton.styleFrom(backgroundColor: colors.primary, foregroundColor: colors.onPrimary, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8)),
child: Text('添加${_typeLabels[_currentIndex]}', style: const TextStyle(fontSize: 13)),
),
],
);
}
Widget _tabButton(ColorScheme colors, String label, int index) {
final active = _currentIndex == index;
return GestureDetector(
onTap: () { setState(() => _currentIndex = index); _loadTags(_tabTypes[index]); },
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
decoration: BoxDecoration(color: active ? colors.primary : Colors.transparent, borderRadius: BorderRadius.circular(6)),
child: Text(label.replaceAll('类型', '').replaceAll('标签', ''),
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: active ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5))),
),
);
}
Widget _buildTagList(String type) {
final tags = _tagCache[type] ?? [];
final colors = Theme.of(context).colorScheme;
final isSearching = _searchQuery.isNotEmpty;
if (tags.isEmpty && !isSearching) {
return Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(_typeIcons[_currentIndex], size: 28, color: colors.onSurface.withValues(alpha: 0.15)),
const SizedBox(height: 8),
Text('暂无${_typeLabels[_currentIndex]}', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.3))),
]),
);
}
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: colors.onSurface.withValues(alpha: 0.4))));
}
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
child: Wrap(spacing: 8, runSpacing: 6, children: filtered.map(_buildTagChip).toList()),
);
}
// 分组模式
final used = <Map<String, dynamic>>[];
final unused = <Map<String, dynamic>>[];
final hidden = <Map<String, dynamic>>[];
for (final t in tags) {
if ((t['is_hidden'] as int?) == 1) { hidden.add(t); continue; }
if ((_usageCounts[t['name']] ?? 0) > 0) { used.add(t); continue; }
unused.add(t);
}
used.sort((a, b) => (_usageCounts[b['name']] ?? 0).compareTo(_usageCounts[a['name']] ?? 0));
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
// 统计
Row(children: [
_statChip(Icons.check_circle_outline, '已使用', used.length, colors),
const SizedBox(width: 8),
_statChip(Icons.radio_button_unchecked, '未使用', unused.length, colors),
const SizedBox(width: 8),
_statChip(Icons.visibility_off_outlined, '隐藏', hidden.length, colors),
]),
const SizedBox(height: 10),
_groupHeader('已使用', used.length, colors),
const SizedBox(height: 4),
used.isNotEmpty ? Wrap(spacing: 8, runSpacing: 6, children: used.map(_buildTagChip).toList()) : _emptyGroup('暂无', colors),
const SizedBox(height: 12),
_groupHeader('未使用', unused.length, colors),
const SizedBox(height: 4),
unused.isNotEmpty ? Wrap(spacing: 8, runSpacing: 6, children: unused.map(_buildTagChip).toList()) : _emptyGroup('暂无', colors),
const SizedBox(height: 12),
_groupHeader('隐藏', hidden.length, colors),
const SizedBox(height: 4),
hidden.isNotEmpty ? Wrap(spacing: 8, runSpacing: 6, children: hidden.map(_buildTagChip).toList()) : _emptyGroup('暂无', colors),
]),
);
}
Widget _statChip(IconData icon, String label, int count, ColorScheme colors) {
return Expanded(
child: Container(
padding: const EdgeInsets.symmetric(vertical: 6),
decoration: BoxDecoration(color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(6)),
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(icon, size: 12, color: colors.onSurface.withValues(alpha: 0.5)),
const SizedBox(width: 4),
Text('$count', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colors.onSurface)),
const SizedBox(width: 2),
Text(label, style: TextStyle(fontSize: 9, color: colors.onSurface.withValues(alpha: 0.4))),
]),
),
);
}
Widget _groupHeader(String label, int count, ColorScheme colors) {
return Row(children: [
Text(label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.5))),
const SizedBox(width: 4),
Text('$count', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))),
]);
}
Widget _emptyGroup(String text, ColorScheme colors) {
return Padding(padding: const EdgeInsets.symmetric(vertical: 4), child: Text(text, style: TextStyle(fontSize: 11, 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;
final count = _usageCounts[name] ?? 0;
final tagId = tag['id'] as String;
final isNew = tagId == _newlyAddedTagId;
final isHidden = (tag['is_hidden'] as int?) == 1;
return GestureDetector(
onTap: () => _showTagMenu(tag),
onLongPress: () => _showTagMenu(tag),
child: Opacity(
opacity: isHidden ? 0.4 : 1.0,
child: isNew
? _NewTagHighlight(child: _tagChipContent(name, count, colors, isHidden: isHidden))
: _tagChipContent(name, count, colors, isHidden: isHidden),
),
);
}
Widget _tagChipContent(String name, int count, ColorScheme colors, {bool isHidden = false}) {
return Container(
margin: const EdgeInsets.only(bottom: 4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(20)),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Text(name, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: colors.onSurface, decoration: isHidden ? TextDecoration.lineThrough : null)),
if (count > 0) ...[
const SizedBox(width: 5),
Text('$count', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.35))),
],
]),
);
}
void _showTagMenu(Map<String, dynamic> tag) {
final colors = Theme.of(context).colorScheme;
final name = tag['name'] as String;
final isHidden = (tag['is_hidden'] as int?) == 1;
showModalBottomSheet(
context: context,
backgroundColor: colors.surface,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
builder: (ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Center(child: Container(width: 40, height: 4, margin: const EdgeInsets.only(bottom: 20),
decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2)))),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(12)),
child: Row(children: [
Expanded(child: Text(name, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface))),
if (isHidden) Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(color: colors.outlineVariant, borderRadius: BorderRadius.circular(4)),
child: Text('已隐藏', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.5))),
),
]),
),
const SizedBox(height: 16),
_menuAction(isHidden ? Icons.visibility_outlined : Icons.visibility_off_outlined, isHidden ? '取消隐藏' : '隐藏', colors, () async {
Navigator.pop(ctx);
await context.read<AppProvider>().toggleTagHidden(tag['id'] as String);
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); }),
_menuAction(Icons.delete_outline, '删除', colors, () { Navigator.pop(ctx); _showDeleteDialog(tag); }, isDestructive: true),
]),
),
),
);
}
Widget _menuAction(IconData icon, String title, ColorScheme colors, VoidCallback onTap, {bool isDestructive = false}) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 4),
child: Row(children: [
Icon(icon, size: 20, color: isDestructive ? const Color(0xFFE53935) : colors.onSurface.withValues(alpha: 0.7)),
const SizedBox(width: 14),
Text(title, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: isDestructive ? const Color(0xFFE53935) : colors.onSurface)),
]),
),
);
}
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 if (_currentType == 'game_genre') {
for (final g in provider.games.where((g) => !g.isDeleted && g.genres.contains(tagName))) items.add((title: g.title, subtitle: g.platforms.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() {
final colors = Theme.of(context).colorScheme;
final controller = TextEditingController();
final type = _currentType;
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: colors.surface, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: Text('添加${_typeLabels[_currentIndex]}', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
content: TextField(
controller: controller, autofocus: true, style: TextStyle(fontSize: 15, color: colors.onSurface),
decoration: InputDecoration(
hintText: '输入标签名称', hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.35)),
filled: true, fillColor: colors.surfaceContainerHigh, contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: colors.primary, width: 1)),
),
onSubmitted: (value) => _doAddTag(ctx, controller.text.trim(), type),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.4)))),
ElevatedButton(onPressed: () => _doAddTag(ctx, controller.text.trim(), type),
style: ElevatedButton.styleFrom(backgroundColor: colors.primary, foregroundColor: colors.onPrimary, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
child: const Text('添加')),
],
),
);
}
Future<void> _doAddTag(BuildContext ctx, String name, String type) async {
if (name.isEmpty) return;
try {
final provider = context.read<AppProvider>();
final newId = await provider.addTag(name, type);
if (!mounted) return;
if (ctx.mounted) { Navigator.pop(ctx); ToastUtil.show(context, '添加成功'); }
setState(() => _newlyAddedTagId = newId);
Timer(const Duration(milliseconds: 1500), () { if (mounted) setState(() => _newlyAddedTagId = null); });
await _loadTags(type);
} catch (e) {
if (ctx.mounted) ToastUtil.show(ctx, '添加失败:该标签已存在');
}
}
void _showRenameDialog(Map<String, dynamic> tag) {
final colors = Theme.of(context).colorScheme;
final controller = TextEditingController(text: tag['name'] as String);
final tagId = tag['id'] as String;
final type = tag['type'] as String;
final oldName = tag['name'] as String;
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: colors.surface, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: Text('重命名标签', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
content: TextField(
controller: controller, autofocus: true, style: TextStyle(fontSize: 15, color: colors.onSurface),
decoration: InputDecoration(
hintText: '输入新名称', hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.35)),
filled: true, fillColor: colors.surfaceContainerHigh, contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: colors.primary, width: 1)),
),
onSubmitted: (value) => _doRenameTag(ctx, tagId, value.trim(), type, oldName),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.4)))),
ElevatedButton(onPressed: () => _doRenameTag(ctx, tagId, controller.text.trim(), type, oldName),
style: ElevatedButton.styleFrom(backgroundColor: colors.primary, foregroundColor: colors.onPrimary, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
child: const Text('确定')),
],
),
);
}
Future<void> _doRenameTag(BuildContext ctx, String tagId, String newName, String type, String oldName) async {
if (newName.isEmpty || newName == oldName) { if (ctx.mounted) Navigator.pop(ctx); return; }
final success = await context.read<AppProvider>().renameTag(tagId, newName, type);
if (ctx.mounted) { Navigator.pop(ctx); ToastUtil.show(context, success ? '重命名成功' : '重命名失败:标签名已存在'); }
if (success) await _loadTags(type);
}
void _showDeleteDialog(Map<String, dynamic> tag) {
final tagId = tag['id'] as String;
final type = tag['type'] as String;
final name = tag['name'] as String;
String? selectedAction = 'deleteOnly';
String? selectedReplacement;
bool showAdvanced = false;
final otherTags = (_tagCache[type] ?? []).where((t) => t['id'] != tagId).map((t) => t['name'] as String).toList();
showDialog(
context: context,
builder: (ctx) => StatefulBuilder(builder: (ctx, setDialogState) {
final bc = Theme.of(ctx).colorScheme;
return AlertDialog(
backgroundColor: bc.surface, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: Row(children: [
Container(padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(color: bc.surfaceContainerHighest, borderRadius: BorderRadius.circular(12)),
child: Text(name, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: bc.onSurface.withValues(alpha: 0.6)))),
const SizedBox(width: 10),
Text('删除标签', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: bc.onSurface)),
]),
content: SizedBox(width: double.maxFinite, child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: MediaQuery.of(ctx).size.height * 0.45),
child: SingleChildScrollView(child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
const SizedBox(height: 4),
_buildDeleteOption(value: 'deleteOnly', groupValue: selectedAction, onChanged: (v) => setDialogState(() { selectedAction = v; selectedReplacement = null; }),
title: '仅删除标签', subtitle: '保留已有条目上的标签名,不影响数据', colors: bc),
const SizedBox(height: 8),
GestureDetector(onTap: () => setDialogState(() => showAdvanced = !showAdvanced),
child: Row(children: [Text('更多选项', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: bc.primary)),
Icon(showAdvanced ? Icons.expand_less : Icons.expand_more, size: 16, color: bc.primary)])),
if (showAdvanced) ...[
const SizedBox(height: 10),
_buildDeleteOption(value: 'remove', groupValue: selectedAction, onChanged: (v) => setDialogState(() { selectedAction = v; selectedReplacement = null; }),
title: '从所有条目中移除', subtitle: '彻底清除该标签在所有条目中的记录', colors: bc),
const SizedBox(height: 4),
_buildDeleteOption(value: 'replace', groupValue: selectedAction, onChanged: (v) => setDialogState(() { selectedAction = v; selectedReplacement = null; }),
title: '替换为其他标签', subtitle: '选择一个已有标签替代', colors: bc),
if (selectedAction == 'replace')
Padding(padding: const EdgeInsets.only(left: 40, top: 10),
child: otherTags.isNotEmpty
? Wrap(spacing: 8, runSpacing: 8, children: otherTags.map((t) {
final isSelected = selectedReplacement == t;
return GestureDetector(onTap: () => setDialogState(() => selectedReplacement = isSelected ? null : t),
child: Container(padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
decoration: BoxDecoration(color: isSelected ? bc.primary : bc.surfaceContainerHighest, borderRadius: BorderRadius.circular(16),
border: Border.all(color: isSelected ? bc.primary : bc.outlineVariant, width: 0.5)),
child: Text(t, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: isSelected ? bc.onPrimary : bc.onSurface.withValues(alpha: 0.7)))));
}).toList())
: Container(padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(color: bc.surfaceContainerHigh, borderRadius: BorderRadius.circular(12)),
child: Text('无其他标签可替换', style: TextStyle(fontSize: 13, color: bc.onSurface.withValues(alpha: 0.35)))),
),
],
])),
)),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: Text('取消', style: TextStyle(color: bc.onSurface.withValues(alpha: 0.4)))),
ElevatedButton(onPressed: () {
if (selectedAction == 'replace' && (selectedReplacement == null || selectedReplacement!.isEmpty)) return;
Navigator.pop(ctx, {'action': selectedAction, 'replacement': selectedReplacement});
}, style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFFE53935), foregroundColor: Colors.white, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
child: const Text('删除')),
],
);
}),
).then((result) async {
if (result == null) return;
final action = result['action'] as String;
final replacement = result['replacement'] as String?;
if (!mounted) return;
final provider = context.read<AppProvider>();
if (action == 'deleteOnly') { await provider.deleteTagOnly(tagId, type); }
else { await provider.deleteTag(tagId, type, replacementName: replacement); }
if (!mounted) return;
ToastUtil.show(context, '删除成功');
await _loadTags(type);
});
}
Widget _buildDeleteOption({required String value, required String? groupValue, required ValueChanged<String?> onChanged,
required String title, String? subtitle, required ColorScheme colors}) {
final selected = value == groupValue;
return GestureDetector(
onTap: () => onChanged(value),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(color: selected ? colors.surfaceContainerHigh : colors.surface, borderRadius: BorderRadius.circular(12),
border: Border.all(color: selected ? colors.primary : colors.outlineVariant, width: selected ? 1 : 0.5)),
child: Row(children: [
Container(width: 18, height: 18, decoration: BoxDecoration(shape: BoxShape.circle,
border: Border.all(color: selected ? colors.primary : colors.onSurface.withValues(alpha: 0.25), width: selected ? 5 : 1.5))),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
Text(title, style: TextStyle(fontSize: 14, fontWeight: selected ? FontWeight.w500 : FontWeight.normal, color: selected ? colors.onSurface : colors.onSurface.withValues(alpha: 0.6))),
if (subtitle != null) Padding(padding: const EdgeInsets.only(top: 2), child: Text(subtitle, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.35)))),
])),
]),
),
);
}
}
// ─── 新标签高亮动画 ──────────────────────────────────────────
class _NewTagHighlight extends StatefulWidget {
final Widget child;
const _NewTagHighlight({required this.child});
@override
State<_NewTagHighlight> createState() => _NewTagHighlightState();
}
class _NewTagHighlightState extends State<_NewTagHighlight> with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 1500));
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
final opacity = _controller.value < 0.3
? (_controller.value / 0.3).clamp(0.0, 1.0)
: (1.0 - (_controller.value - 0.3) / 0.7).clamp(0.0, 1.0);
final scale = 1.0 + 0.06 * (1.0 - _controller.value);
return Transform.scale(
scale: scale,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: colors.primary.withValues(alpha: 0.12 * opacity),
border: Border.all(color: colors.primary.withValues(alpha: 0.3 * opacity), width: 1),
),
child: widget.child,
),
);
},
);
}
}
// ─── 备份选择弹窗 ──────────────────────────────────────────
class _BackupChoiceDialog extends StatefulWidget {
const _BackupChoiceDialog();
@override
State<_BackupChoiceDialog> createState() => _BackupChoiceDialogState();
}
class _BackupChoiceDialogState extends State<_BackupChoiceDialog> {
int _tabIndex = 0;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return AlertDialog(
backgroundColor: colors.surface,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
titlePadding: const EdgeInsets.fromLTRB(24, 20, 24, 0),
contentPadding: const EdgeInsets.fromLTRB(0, 12, 0, 0),
title: Row(children: [
const Text('备份'),
const Spacer(),
// Tab 切换
Container(
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Row(children: [
_tabButton(colors, '本地备份', 0),
_tabButton(colors, 'WebDAV', 1),
]),
),
]),
content: SizedBox(
width: 480,
height: 440,
child: _tabIndex == 0
? const _LocalBackupContent()
: const _WebDAVBackupContent(),
),
);
}
Widget _tabButton(ColorScheme colors, String label, int index) {
final active = _tabIndex == index;
return GestureDetector(
onTap: () => setState(() => _tabIndex = index),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: active ? colors.primary : Colors.transparent,
borderRadius: BorderRadius.circular(6),
),
child: Text(label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: active ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5),
),
),
),
);
}
}
// ─── 本地备份内容(嵌入弹窗) ──────────────────────────────
class _LocalBackupContent extends StatefulWidget {
const _LocalBackupContent();
@override
State<_LocalBackupContent> createState() => _LocalBackupContentState();
}
class _LocalBackupContentState extends State<_LocalBackupContent> {
bool _isExporting = false;
bool _isImporting = false;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
children: [
_buildActionCard(
colors: colors,
title: '导出数据',
description: '将所有数据导出为 zip 文件,可用于备份或迁移到其他设备',
icon: Icons.upload_outlined,
buttonText: '导出',
isLoading: _isExporting,
onTap: _exportData,
),
const SizedBox(height: 8),
_buildActionCard(
colors: colors,
title: '导入数据',
description: '从备份文件导入数据,将覆盖当前所有数据',
icon: Icons.download_outlined,
buttonText: '导入',
isLoading: _isImporting,
onTap: _importData,
isDestructive: true,
),
const SizedBox(height: 20),
_buildInfoSection(colors),
],
);
}
Widget _buildActionCard({
required ColorScheme colors,
required String title,
required String description,
required IconData icon,
required String buttonText,
required bool isLoading,
required VoidCallback onTap,
bool isDestructive = false,
}) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Container(
width: 32, height: 32,
decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)),
child: Icon(icon, size: 18, color: isDestructive ? Colors.red : colors.onSurface.withValues(alpha: 0.6)),
),
const SizedBox(width: 10),
Expanded(child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface)),
const SizedBox(height: 1),
Text(description, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4), height: 1.3)),
],
)),
]),
const SizedBox(height: 12),
GestureDetector(
onTap: isLoading ? null : onTap,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: isLoading ? colors.onSurface.withValues(alpha: 0.25) : colors.primary,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: isLoading
? SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, valueColor: AlwaysStoppedAnimation(colors.onPrimary)))
: Text(buttonText, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onPrimary)),
),
),
),
],
),
);
}
Widget _buildInfoSection(ColorScheme colors) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(12)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Container(width: 32, height: 32, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)),
child: Icon(Icons.info_outline, size: 18, color: colors.onSurface.withValues(alpha: 0.6))),
const SizedBox(width: 10),
Text('使用说明', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface)),
]),
const SizedBox(height: 12),
_infoItem(colors, '导出数据会生成一个 .zip 文件,包含所有数据和图片'),
const SizedBox(height: 8),
_infoItem(colors, '选择保存路径后,可以通过微信、邮件等方式发送备份文件'),
const SizedBox(height: 8),
_infoItem(colors, '在新设备上选择导入数据,选择备份文件即可恢复'),
const SizedBox(height: 8),
_infoItem(colors, '导入数据会完全覆盖当前设备的数据,请谨慎操作'),
],
),
);
}
Widget _infoItem(ColorScheme colors, String text) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(padding: const EdgeInsets.only(top: 8), child: Icon(Icons.circle, size: 4, color: colors.onSurface.withValues(alpha: 0.25))),
const SizedBox(width: 8),
Expanded(child: Text(text, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.5), height: 1.5))),
],
);
}
Future<void> _exportData() async {
setState(() => _isExporting = true);
try {
final result = await BackupService.instance.exportDataWithImages();
if (!mounted) return;
if (result.cancelled) {
ToastUtil.show(context, '已取消导出');
} else if (result.success) {
ToastUtil.show(context,'导出成功');
} else {
ToastUtil.show(context,result.errorMessage ?? '导出失败');
}
} catch (e) {
if (mounted) ToastUtil.show(context,'导出失败: $e');
} finally {
if (mounted) setState(() => _isExporting = false);
}
}
Future<void> _importData() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) {
final colors = Theme.of(ctx).colorScheme;
return AlertDialog(
backgroundColor: colors.surface, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: Row(children: [
Container(width: 40, height: 40, decoration: BoxDecoration(color: Colors.red.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(10)),
child: const Icon(Icons.warning_amber_rounded, color: Colors.red, size: 22)),
const SizedBox(width: 12),
Text('确认导入', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
]),
content: Padding(padding: const EdgeInsets.only(top: 16),
child: Text('导入数据将覆盖当前所有数据,此操作不可恢复。',
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.6))),
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))),
child: const Text('确认导入', style: TextStyle(fontWeight: FontWeight.w600))),
],
);
},
);
if (confirmed != true) return;
setState(() => _isImporting = true);
try {
final result = await BackupService.instance.importData();
if (!mounted) return;
if (result.cancelled) {
ToastUtil.show(context,'已取消导入');
} else if (result.success) {
await context.read<AppProvider>().loadMovies();
await context.read<AppProvider>().loadBooks();
await context.read<AppProvider>().loadNotes();
await context.read<AppProvider>().loadGames();
if (!mounted) return;
ToastUtil.show(context,'导入成功');
} else {
ToastUtil.show(context,result.errorMessage ?? '导入失败');
}
} catch (e) {
if (mounted) ToastUtil.show(context,'导入失败: $e');
} finally {
if (mounted) setState(() => _isImporting = false);
}
}
}
// ─── WebDAV 备份内容(嵌入弹窗) ──────────────────────────
class _WebDAVBackupContent extends StatefulWidget {
const _WebDAVBackupContent();
@override
State<_WebDAVBackupContent> createState() => _WebDAVBackupContentState();
}
class _WebDAVBackupContentState extends State<_WebDAVBackupContent> {
final _urlController = TextEditingController();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
final _pathController = TextEditingController(text: '/mooknote');
bool _isLoading = false;
bool _isConfigured = false;
bool _obscurePassword = true;
String _syncStep = '';
DateTime? _remoteModifiedTime;
int? _remoteFileSize;
bool _isLoadingRemoteInfo = false;
@override
void initState() {
super.initState();
_loadConfig();
}
@override
void dispose() {
_urlController.dispose();
_usernameController.dispose();
_passwordController.dispose();
_pathController.dispose();
super.dispose();
}
Future<void> _loadConfig() async {
final config = await WebDAVService.instance.getConfig();
if (config != null) {
setState(() {
_urlController.text = config['url'] ?? '';
_usernameController.text = config['username'] ?? '';
_passwordController.text = config['password'] ?? '';
_pathController.text = config['path'] ?? '/mooknote';
_isConfigured = true;
});
_loadRemoteInfo();
}
}
Future<void> _loadRemoteInfo() async {
setState(() => _isLoadingRemoteInfo = true);
final info = await WebDAVService.instance.getRemoteBackupInfo();
if (mounted) {
setState(() {
_remoteModifiedTime = info?['modifiedTime'] as DateTime?;
_remoteFileSize = info?['size'] as int?;
_isLoadingRemoteInfo = false;
});
}
}
Future<void> _saveConfig() async {
final url = _urlController.text.trim();
final username = _usernameController.text.trim();
final password = _passwordController.text;
final path = _pathController.text.trim();
if (url.isEmpty) { ToastUtil.show(context,'请输入服务器地址'); return; }
if (username.isEmpty) { ToastUtil.show(context,'请输入用户名'); return; }
if (password.isEmpty) { ToastUtil.show(context,'请输入密码'); return; }
setState(() => _isLoading = true);
try {
final result = await WebDAVService.instance.testConnection(url: url, username: username, password: password, path: path);
if (!mounted) return;
if (result['success'] == true) {
await WebDAVService.instance.saveConfig(url: url, username: username, password: password, path: path);
setState(() => _isConfigured = true);
_loadRemoteInfo();
ToastUtil.show(context,result['message'] ?? '连接成功,配置已保存');
} else {
ToastUtil.show(context,result['message'] ?? '连接失败,请检查配置');
}
} catch (e) {
if (mounted) ToastUtil.show(context,'连接失败: $e');
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
Future<void> _syncData(SyncDirection direction) async {
setState(() => _isLoading = true);
try {
SyncResult result;
if (direction == SyncDirection.upload) {
setState(() => _syncStep = '正在打包数据...');
await Future.delayed(Duration.zero);
final exportResult = await WebDAVService.instance.exportLocalData();
if (!exportResult.success || exportResult.zipPath == null) {
if (mounted) { setState(() { _isLoading = false; _syncStep = ''; }); ToastUtil.show(context,exportResult.errorMessage ?? '创建备份失败'); }
return;
}
if (!mounted) return;
setState(() => _syncStep = '正在上传到云端...');
await Future.delayed(Duration.zero);
result = await WebDAVService.instance.uploadExportedData(exportResult);
} else {
setState(() => _syncStep = '正在从云端下载...');
await Future.delayed(Duration.zero);
result = await WebDAVService.instance.syncData(direction: SyncDirection.download);
}
if (!mounted) return;
if (result.success) {
_loadRemoteInfo();
if (result.needReload) {
final provider = context.read<AppProvider>();
await provider.loadMovies();
await provider.loadBooks();
await provider.loadNotes();
await provider.loadGames();
}
ToastUtil.show(context,'同步成功');
} else {
ToastUtil.show(context,result.message);
}
} catch (e) {
if (mounted) ToastUtil.show(context,'同步失败: $e');
} finally {
if (mounted) setState(() { _isLoading = false; _syncStep = ''; });
}
}
Future<void> _confirmSync(SyncDirection direction) async {
final isUpload = direction == SyncDirection.upload;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) {
final colors = Theme.of(ctx).colorScheme;
return AlertDialog(
backgroundColor: colors.surface, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: Text(isUpload ? '确认上传' : '确认下载', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
content: Text(isUpload ? '该操作会覆盖远程数据,请谨慎操作' : '该操作会拉取远程数据覆盖本地数据,请谨慎操作',
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.6)),
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.primary, foregroundColor: colors.onPrimary, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
child: const Text('确定')),
],
);
},
);
if (confirmed == true) _syncData(direction);
}
Future<void> _clearConfig() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) {
final colors = Theme.of(ctx).colorScheme;
return AlertDialog(
backgroundColor: colors.surface, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: Row(children: [
Container(width: 40, height: 40, decoration: BoxDecoration(color: Colors.red.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(10)),
child: const Icon(Icons.warning_amber_rounded, color: Colors.red, size: 22)),
const SizedBox(width: 12),
Text('清除配置', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
]),
content: Padding(padding: const EdgeInsets.only(top: 16),
child: Text('确定要清除 WebDAV 配置吗?', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.6))),
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))),
child: const Text('清除', style: TextStyle(fontWeight: FontWeight.w600))),
],
);
},
);
if (confirmed == true) {
await WebDAVService.instance.clearConfig();
setState(() {
_urlController.clear();
_usernameController.clear();
_passwordController.clear();
_pathController.text = '/mooknote';
_isConfigured = false;
});
if (mounted) ToastUtil.show(context,'配置已清除');
}
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
children: [
if (_isConfigured) _buildConnectedBanner(colors),
if (_isConfigured) ...[
_buildRemoteInfoCard(colors),
const SizedBox(height: 16),
],
_buildSectionLabel(colors, '服务器配置'),
const SizedBox(height: 10),
_buildInput(colors: colors, controller: _urlController, hint: '服务器地址,如 https://dav.example.com', icon: Icons.link),
const SizedBox(height: 8),
_buildInput(colors: colors, controller: _usernameController, hint: '用户名', icon: Icons.person_outline),
const SizedBox(height: 8),
_buildInput(colors: colors, controller: _passwordController, hint: '密码', icon: Icons.lock_outline,
obscure: _obscurePassword,
suffix: GestureDetector(
onTap: () => setState(() => _obscurePassword = !_obscurePassword),
child: Icon(_obscurePassword ? Icons.visibility_off : Icons.visibility, size: 20, color: colors.onSurface.withValues(alpha: 0.3)),
),
),
const SizedBox(height: 8),
_buildInput(colors: colors, controller: _pathController, hint: '同步路径,如 /mooknote', icon: Icons.folder_outlined),
const SizedBox(height: 14),
_buildBtn(colors, '测试并保存', onTap: _isLoading ? null : _saveConfig),
if (_isConfigured) ...[
const SizedBox(height: 8),
Center(child: GestureDetector(
onTap: _isLoading ? null : _clearConfig,
child: Padding(padding: const EdgeInsets.symmetric(vertical: 4),
child: Text('清除配置', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.35)))),
)),
],
const SizedBox(height: 20),
_buildTips(colors),
],
);
}
Widget _buildConnectedBanner(ColorScheme colors) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)),
child: Row(children: [
Container(width: 6, height: 6, decoration: const BoxDecoration(color: Color(0xFF4CAF50), shape: BoxShape.circle)),
const SizedBox(width: 10),
Expanded(child: Text(_urlController.text, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6)), maxLines: 1, overflow: TextOverflow.ellipsis)),
Text('已连接', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
]),
);
}
Widget _buildSectionLabel(ColorScheme colors, String text) {
return Text(text, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.4), letterSpacing: 0.5));
}
Widget _buildInput({required ColorScheme colors, required TextEditingController controller, required String hint, required IconData icon, bool obscure = false, Widget? suffix}) {
return TextField(
controller: controller,
obscureText: obscure,
style: TextStyle(fontSize: 14, color: colors.onSurface),
decoration: InputDecoration(
hintText: hint,
hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.3)),
prefixIcon: Padding(padding: const EdgeInsets.only(left: 4, right: 8), child: Icon(icon, size: 20, color: colors.onSurface.withValues(alpha: 0.3))),
prefixIconConstraints: const BoxConstraints(minWidth: 44),
suffixIcon: suffix != null ? Padding(padding: const EdgeInsets.only(right: 8), child: suffix) : null,
filled: true,
fillColor: colors.surfaceContainerHigh,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: colors.primary, width: 1)),
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
),
);
}
Widget _buildBtn(ColorScheme colors, String text, {VoidCallback? onTap}) {
final disabled = onTap == null;
return GestureDetector(
onTap: onTap,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(color: disabled ? colors.onSurface.withValues(alpha: 0.15) : colors.primary, borderRadius: BorderRadius.circular(8)),
child: Center(child: Text(text, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: colors.onPrimary))),
),
);
}
Widget _buildRemoteInfoCard(ColorScheme colors) {
String timeText;
String sizeText = '';
if (_isLoadingRemoteInfo) {
timeText = '加载中...';
} else if (_remoteModifiedTime != null) {
final dt = _remoteModifiedTime!;
timeText = '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
if (_remoteFileSize != null) sizeText = _formatFileSize(_remoteFileSize!);
} else {
timeText = '暂无备份文件';
}
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(12),
border: Border.all(color: colors.outlineVariant.withValues(alpha: 0.5), width: 0.5)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Icon(Icons.cloud_outlined, size: 18, color: colors.primary),
const SizedBox(width: 8),
Text('云端备份', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface)),
const Spacer(),
if (_isLoadingRemoteInfo)
SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: colors.primary))
else
GestureDetector(onTap: _loadRemoteInfo, child: Icon(Icons.refresh, size: 18, color: colors.onSurface.withValues(alpha: 0.4))),
]),
const SizedBox(height: 10),
Row(children: [
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('上传时间', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))),
const SizedBox(height: 3),
Text(timeText, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface)),
])),
if (sizeText.isNotEmpty)
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('文件大小', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))),
const SizedBox(height: 3),
Text(sizeText, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface)),
])),
]),
const SizedBox(height: 14),
const Divider(height: 0.5, color: Color(0xFFE0E0E0)),
const SizedBox(height: 10),
if (_isLoading) ...[
SizedBox(width: double.infinity, child: LinearProgressIndicator(backgroundColor: colors.surfaceContainerHighest, color: colors.primary, minHeight: 3, borderRadius: BorderRadius.circular(1.5))),
const SizedBox(height: 8),
Text(_syncStep, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6))),
] else ...[
Row(children: [
Expanded(child: _buildBtn(colors, '上传', onTap: _isLoading ? null : () => _confirmSync(SyncDirection.upload))),
const SizedBox(width: 12),
Expanded(child: _buildBtn(colors, '下载', onTap: _isLoading ? null : () => _confirmSync(SyncDirection.download))),
]),
],
],
),
);
}
String _formatFileSize(int bytes) {
if (bytes < 1024) return '${bytes}B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)}KB';
if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)}MB';
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)}GB';
}
Widget _buildTips(ColorScheme colors) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(10)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('支持的服务', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.4), letterSpacing: 0.5)),
const SizedBox(height: 10),
_tip(colors, '坚果云、Nextcloud、AList 等 WebDAV 服务'),
_tip(colors, '服务器地址需包含 https://'),
_tip(colors, '首次同步可能需要较长时间'),
],
),
);
}
Widget _tip(ColorScheme colors, String text) {
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(padding: const EdgeInsets.only(top: 8), child: Icon(Icons.circle, size: 4, color: colors.onSurface.withValues(alpha: 0.25))),
const SizedBox(width: 8),
Expanded(child: Text(text, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.5), height: 1.5))),
],
),
);
}
}
// ─── 回收站弹窗 ──────────────────────────────────────────
enum _BinItemType { movie, book, note, game, movieReview, bookReview, bookExcerpt, gameReview }
class _BinItem {
final _BinItemType type;
final String id;
final String title;
final String subtitle;
final IconData icon;
final String typeLabel;
_BinItem.movie(Movie m)
: type = _BinItemType.movie, id = m.id, title = m.title,
subtitle = '删除于 ${m.updatedAt.year}.${m.updatedAt.month.toString().padLeft(2, '0')}.${m.updatedAt.day.toString().padLeft(2, '0')}',
icon = Icons.movie_outlined, typeLabel = '影视';
_BinItem.book(Book b)
: type = _BinItemType.book, id = b.id, title = b.title,
subtitle = '删除于 ${b.updatedAt.year}.${b.updatedAt.month.toString().padLeft(2, '0')}.${b.updatedAt.day.toString().padLeft(2, '0')}',
icon = Icons.menu_book_outlined, typeLabel = '书籍';
_BinItem.note(Note n)
: type = _BinItemType.note, id = n.id, title = n.title.isNotEmpty ? n.title : n.summary,
subtitle = '删除于 ${n.updatedAt.year}.${n.updatedAt.month.toString().padLeft(2, '0')}.${n.updatedAt.day.toString().padLeft(2, '0')}',
icon = Icons.description_outlined, typeLabel = '笔记';
_BinItem.game(Game g)
: type = _BinItemType.game, id = g.id, title = g.title,
subtitle = '删除于 ${g.updatedAt.year}.${g.updatedAt.month.toString().padLeft(2, '0')}.${g.updatedAt.day.toString().padLeft(2, '0')}',
icon = Icons.sports_esports_outlined, typeLabel = '游戏';
_BinItem.movieReview(MovieReview r)
: type = _BinItemType.movieReview, id = r.id, title = r.content.isNotEmpty ? r.content : '影评',
subtitle = '删除于 ${r.updatedAt.year}.${r.updatedAt.month.toString().padLeft(2, '0')}.${r.updatedAt.day.toString().padLeft(2, '0')}',
icon = Icons.rate_review_outlined, typeLabel = '影评';
_BinItem.bookReview(BookReview r)
: type = _BinItemType.bookReview, id = r.id, title = r.content.isNotEmpty ? r.content : '书评',
subtitle = '删除于 ${r.updatedAt.year}.${r.updatedAt.month.toString().padLeft(2, '0')}.${r.updatedAt.day.toString().padLeft(2, '0')}',
icon = Icons.rate_review_outlined, typeLabel = '书评';
_BinItem.bookExcerpt(BookExcerpt e)
: type = _BinItemType.bookExcerpt, id = e.id, title = e.content.isNotEmpty ? e.content : '摘抄',
subtitle = '删除于 ${e.updatedAt.year}.${e.updatedAt.month.toString().padLeft(2, '0')}.${e.updatedAt.day.toString().padLeft(2, '0')}',
icon = Icons.format_quote_outlined, typeLabel = '书摘';
_BinItem.gameReview(GameReview r)
: type = _BinItemType.gameReview, id = r.id, title = r.content.isNotEmpty ? r.content : '游戏评价',
subtitle = '删除于 ${r.updatedAt.year}.${r.updatedAt.month.toString().padLeft(2, '0')}.${r.updatedAt.day.toString().padLeft(2, '0')}',
icon = Icons.rate_review_outlined, typeLabel = '游戏评价';
}
class _RecycleBinDialog extends StatefulWidget {
const _RecycleBinDialog();
@override
State<_RecycleBinDialog> createState() => _RecycleBinDialogState();
}
class _RecycleBinDialogState extends State<_RecycleBinDialog> {
List<_BinItem> _allItems = [];
_BinItemType? _filterType;
bool _isLoading = true;
List<_BinItem> get _filteredItems =>
_filterType == null ? _allItems : _allItems.where((i) => i.type == _filterType).toList();
@override
void initState() {
super.initState();
_loadDeletedItems();
}
Future<void> _loadDeletedItems() async {
setState(() => _isLoading = true);
final provider = context.read<AppProvider>();
final movies = await provider.getDeletedMovies();
final books = await provider.getDeletedBooks();
final notes = await provider.getDeletedNotes();
final games = await provider.getDeletedGames();
final movieReviews = await provider.getDeletedMovieReviews();
final bookReviews = await provider.getDeletedBookReviews();
final bookExcerpts = await provider.getDeletedBookExcerpts();
final gameReviews = await provider.getDeletedGameReviews();
if (!mounted) return;
setState(() {
_allItems = [
for (final m in movies) _BinItem.movie(m),
for (final b in books) _BinItem.book(b),
for (final n in notes) _BinItem.note(n),
for (final g in games) _BinItem.game(g),
for (final r in movieReviews) _BinItem.movieReview(r),
for (final r in bookReviews) _BinItem.bookReview(r),
for (final e in bookExcerpts) _BinItem.bookExcerpt(e),
for (final r in gameReviews) _BinItem.gameReview(r),
];
_isLoading = false;
});
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return AlertDialog(
backgroundColor: colors.surface,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
title: Row(children: [
const Text('回收站'),
const Spacer(),
if (_allItems.isNotEmpty)
TextButton(
onPressed: _showClearAllDialog,
style: TextButton.styleFrom(
foregroundColor: colors.error,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
minimumSize: Size.zero,
),
child: const Text('清空', style: TextStyle(fontSize: 12)),
),
]),
content: SizedBox(
width: 400,
height: 480,
child: _isLoading
? Center(child: CircularProgressIndicator(strokeWidth: 2, color: colors.primary))
: Column(children: [
if (_allItems.isNotEmpty) _buildFilterRow(colors),
Expanded(
child: _filteredItems.isEmpty
? _buildEmptyState(colors)
: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: _filteredItems.length,
itemBuilder: (_, i) => _buildItem(_filteredItems[i], colors),
),
),
]),
),
);
}
Widget _buildFilterRow(ColorScheme colors) {
return Container(
padding: const EdgeInsets.only(bottom: 8),
child: Wrap(spacing: 6, runSpacing: 6, children: [
_filterChip('全部', null, colors),
_filterChip('影视', _BinItemType.movie, colors),
_filterChip('书籍', _BinItemType.book, colors),
_filterChip('笔记', _BinItemType.note, colors),
_filterChip('游戏', _BinItemType.game, colors),
_filterChip('影评', _BinItemType.movieReview, colors),
_filterChip('书评', _BinItemType.bookReview, colors),
_filterChip('书摘', _BinItemType.bookExcerpt, colors),
_filterChip('游戏评价', _BinItemType.gameReview, colors),
]),
);
}
Widget _filterChip(String label, _BinItemType? type, ColorScheme colors) {
final active = _filterType == type;
final count = type == null ? _allItems.length : _allItems.where((i) => i.type == type).length;
return GestureDetector(
onTap: () => setState(() => _filterType = type),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: active ? colors.primary : colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
),
child: Text('$label · $count',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500,
color: active ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5))),
),
);
}
Widget _buildItem(_BinItem item, ColorScheme colors) {
return Container(
margin: const EdgeInsets.symmetric(vertical: 3),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: colors.outlineVariant, width: 0.5),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Row(children: [
Container(
width: 32, height: 32,
decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(6),
border: Border.all(color: colors.outlineVariant, width: 0.5)),
child: Icon(item.icon, size: 16, color: colors.onSurface.withValues(alpha: 0.5)),
),
const SizedBox(width: 10),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
Row(children: [
Expanded(child: Text(item.title,
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface, height: 1.3),
maxLines: 1, overflow: TextOverflow.ellipsis)),
const SizedBox(width: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(3),
border: Border.all(color: colors.outlineVariant, width: 0.5)),
child: Text(item.typeLabel,
style: TextStyle(fontSize: 9, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.4))),
),
]),
const SizedBox(height: 2),
Text(item.subtitle, style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.35))),
])),
const SizedBox(width: 6),
_actionBtn(Icons.restore, colors.primary, () => _restore(item)),
const SizedBox(width: 4),
_actionBtn(Icons.delete_outline, colors.error, () => _permanentDelete(item)),
]),
),
);
}
Widget _actionBtn(IconData icon, Color color, VoidCallback onTap) {
final colors = Theme.of(context).colorScheme;
return GestureDetector(
onTap: onTap,
child: Container(
width: 28, height: 28,
decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(6),
border: Border.all(color: colors.outlineVariant, width: 0.5)),
child: Icon(icon, size: 14, color: color),
),
);
}
Widget _buildEmptyState(ColorScheme colors) {
return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(Icons.delete_outline, size: 40, color: colors.onSurface.withValues(alpha: 0.15)),
const SizedBox(height: 12),
Text(_filterType == null ? '回收站是空的' : '没有删除的项目',
style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.35))),
]));
}
Future<void> _restore(_BinItem item) async {
final provider = context.read<AppProvider>();
switch (item.type) {
case _BinItemType.movie: await provider.restoreMovie(item.id);
case _BinItemType.book: await provider.restoreBook(item.id);
case _BinItemType.note: await provider.restoreNote(item.id);
case _BinItemType.game: await provider.restoreGame(item.id);
case _BinItemType.movieReview: await provider.restoreMovieReview(item.id);
case _BinItemType.bookReview: await provider.restoreBookReview(item.id);
case _BinItemType.bookExcerpt: await provider.restoreBookExcerpt(item.id);
case _BinItemType.gameReview: await provider.restoreGameReview(item.id);
}
if (mounted) {
ToastUtil.show(context, '${item.typeLabel}已恢复');
_loadDeletedItems();
}
}
Future<void> _permanentDelete(_BinItem item) async {
final colors = Theme.of(context).colorScheme;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: colors.surface, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: Text('确认删除', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)),
content: Text('确定要彻底删除吗?此操作不可恢复。',
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6))),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false),
style: TextButton.styleFrom(foregroundColor: colors.onSurface.withValues(alpha: 0.6)),
child: const Text('取消')),
ElevatedButton(onPressed: () => Navigator.pop(ctx, true),
style: ElevatedButton.styleFrom(backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
child: const Text('删除')),
],
),
);
if (confirmed != true || !mounted) return;
final provider = context.read<AppProvider>();
switch (item.type) {
case _BinItemType.movie: await provider.permanentDeleteMovie(item.id);
case _BinItemType.book: await provider.permanentDeleteBook(item.id);
case _BinItemType.note: await provider.permanentDeleteNote(item.id);
case _BinItemType.game: await provider.permanentDeleteGame(item.id);
case _BinItemType.movieReview: await provider.permanentDeleteMovieReview(item.id);
case _BinItemType.bookReview: await provider.permanentDeleteBookReview(item.id);
case _BinItemType.bookExcerpt: await provider.permanentDeleteBookExcerpt(item.id);
case _BinItemType.gameReview: await provider.permanentDeleteGameReview(item.id);
}
if (mounted) {
ToastUtil.show(context, '已彻底删除');
_loadDeletedItems();
}
}
void _showClearAllDialog() {
final colors = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: colors.surface, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: Row(children: [
const Icon(Icons.warning_amber_rounded, color: Colors.red, size: 20),
const SizedBox(width: 8),
Text('清空回收站', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)),
]),
content: Text('所有项目将被彻底删除,此操作不可恢复。',
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6))),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx),
style: TextButton.styleFrom(foregroundColor: colors.onSurface.withValues(alpha: 0.6)),
child: const Text('取消')),
ElevatedButton(
onPressed: () async {
Navigator.pop(ctx);
await context.read<AppProvider>().clearRecycleBin();
if (mounted) {
ToastUtil.show(context, '回收站已清空');
_loadDeletedItems();
}
},
style: ElevatedButton.styleFrom(backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
child: const Text('清空')),
],
),
);
}
}
/// 搜索弹窗:本地搜索 / 增强搜索(在线)切换
class _SearchDialog extends StatefulWidget {
final BuildContext dialogContext;
const _SearchDialog({required this.dialogContext});
@override
State<_SearchDialog> createState() => _SearchDialogState();
}
class _SearchDialogState extends State<_SearchDialog> {
final _localNavKey = GlobalKey<NavigatorState>();
final _onlineNavKey = GlobalKey<NavigatorState>();
bool _isOnline = false;
NavigatorState? get _activeNav =>
(_isOnline ? _onlineNavKey : _localNavKey).currentState;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final showToggle = UserPrefs().enhancedSearchEnabled;
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
final nav = _activeNav;
if (nav != null && nav.canPop()) {
nav.pop();
} else {
Navigator.of(widget.dialogContext).pop();
}
},
child: Column(
children: [
if (showToggle) _buildToggle(colors),
Expanded(
child: IndexedStack(
index: _isOnline ? 1 : 0,
children: [
_buildNav(_localNavKey, const SearchPage()),
_buildNav(_onlineNavKey, const OnlineSearchPage()),
],
),
),
],
),
);
}
Widget _buildNav(Key key, Widget page) {
return Navigator(
key: key,
onGenerateRoute: (_) => MaterialPageRoute(builder: (_) => page),
);
}
Widget _buildToggle(ColorScheme colors) {
return Container(
margin: const EdgeInsets.fromLTRB(12, 10, 12, 6),
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: colors.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Expanded(
child: _toggleBtn('本地搜索', !_isOnline, () {
if (!mounted) return;
setState(() => _isOnline = false);
}, colors),
),
Expanded(
child: _toggleBtn('增强搜索', _isOnline, () {
if (!mounted) return;
setState(() => _isOnline = true);
}, colors),
),
],
),
);
}
Widget _toggleBtn(String label, bool selected, VoidCallback onTap, ColorScheme colors) {
return Material(
color: selected ? colors.onSurface : Colors.transparent,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 7),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
selected ? Icons.search_rounded : Icons.search_outlined,
size: 14,
color: selected ? colors.surface : colors.onSurface.withValues(alpha: 0.5),
),
const SizedBox(width: 5),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
color: selected ? colors.surface : colors.onSurface.withValues(alpha: 0.55),
),
),
],
),
),
),
);
}
}
class _IconRailItem extends StatelessWidget {
final IconData icon;
final IconData activeIcon;
final String label;
final Color accentColor;
final bool selected;
final VoidCallback onTap;
const _IconRailItem({
required this.icon,
required this.activeIcon,
required this.label,
required this.accentColor,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 1),
child: Material(
color: selected ? accentColor.withValues(alpha: 0.12) : Colors.transparent,
borderRadius: BorderRadius.circular(10),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Row(
children: [
Icon(
selected ? activeIcon : icon,
size: 18,
color: selected ? accentColor : colors.onSurface.withValues(alpha: 0.5),
),
const SizedBox(width: 10),
Text(
label,
style: TextStyle(
fontSize: 12,
color: selected ? accentColor : colors.onSurface.withValues(alpha: 0.55),
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
),
),
],
),
),
),
),
);
}
}
// ─── 第二栏:列表面板(搜索 + 当前分类列表) ──────────────
class _DesktopListPanel extends StatefulWidget {
final int mainTabIndex;
const _DesktopListPanel({required this.mainTabIndex});
@override
State<_DesktopListPanel> createState() => _DesktopListPanelState();
}
class _DesktopListPanelState extends State<_DesktopListPanel> {
final TextEditingController _searchCtrl = TextEditingController();
String _keyword = '';
Timer? _debounce;
/// 状态筛选null=全部,否则按各实体 status 值过滤
String? _statusFilter;
@override
void didUpdateWidget(covariant _DesktopListPanel oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.mainTabIndex != widget.mainTabIndex) {
_statusFilter = null;
}
}
@override
void dispose() {
_searchCtrl.dispose();
_debounce?.cancel();
super.dispose();
}
void _onSearchChanged(String value) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 250), () {
if (mounted) setState(() => _keyword = value.trim().toLowerCase());
});
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Column(
children: [
// 顶部搜索栏
SizedBox(height: MediaQuery.of(context).padding.top),
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
child: Row(
children: [
Expanded(
child: SizedBox(
height: 36,
child: TextField(
controller: _searchCtrl,
onChanged: _onSearchChanged,
style: TextStyle(fontSize: 13, color: colors.onSurface),
decoration: InputDecoration(
hintText: '搜索...',
hintStyle: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.35)),
prefixIcon: Icon(Icons.search, size: 18, color: colors.onSurface.withValues(alpha: 0.4)),
suffixIcon: _keyword.isNotEmpty
? GestureDetector(
onTap: () {
_searchCtrl.clear();
setState(() => _keyword = '');
},
child: Icon(Icons.close, size: 16, color: colors.onSurface.withValues(alpha: 0.4)),
)
: null,
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 0),
filled: true,
fillColor: colors.surfaceContainerHighest.withValues(alpha: 0.5),
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.withValues(alpha: 0.3), width: 1),
),
),
),
),
),
const SizedBox(width: 8),
_buildSortButton(context),
],
),
),
const SizedBox(height: 4),
// 列表
Expanded(
child: _keyword.isNotEmpty
? _buildSearchResults(context)
: _buildCategoryList(context),
),
],
);
}
// ─── 分类列表 ──────────────────────────────────────────
Widget _buildCategoryList(BuildContext context) {
switch (widget.mainTabIndex) {
case 0: return _buildMovieList(context);
case 1: return _buildBookList(context);
case 2: return _buildNoteList(context);
case 3: return _buildGameList(context);
default: return const SizedBox.shrink();
}
}
// ─── 排序按钮(对应 Android 版长按 tab 弹出的排序菜单)────────────
Widget _buildSortButton(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final (currentSort, sortOptions, _, onSortSelected) = _sortConfig(context);
final statusOptions = _statusFilterOptions();
return PopupMenuButton<String>(
tooltip: '排序与筛选',
position: PopupMenuPosition.under,
color: colors.surface,
elevation: 4,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
itemBuilder: (ctx) => [
// 排序区
PopupMenuItem<String>(
value: '__sort_header__',
enabled: false,
height: 28,
child: Text('排序', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.4))),
),
for (final (value, text, icon) in sortOptions)
PopupMenuItem<String>(
value: 'sort:$value',
child: Row(children: [
Icon(icon, size: 16, color: currentSort == value ? colors.primary : colors.onSurface.withValues(alpha: 0.6)),
const SizedBox(width: 10),
Text(text, style: TextStyle(fontSize: 13, color: currentSort == value ? colors.primary : colors.onSurface)),
const Spacer(),
if (currentSort == value)
Icon(Icons.check, size: 14, color: colors.primary),
]),
),
// 状态筛选区(仅影视/阅读/游戏)
if (statusOptions != null) ...[
const PopupMenuDivider(height: 8),
PopupMenuItem<String>(
value: '__filter_header__',
enabled: false,
height: 28,
child: Text('状态筛选', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.4))),
),
for (final (value, text, icon) in statusOptions)
PopupMenuItem<String>(
value: 'filter:$value',
child: Row(children: [
Icon(icon, size: 16, color: _statusFilter == value ? colors.primary : colors.onSurface.withValues(alpha: 0.6)),
const SizedBox(width: 10),
Text(text, style: TextStyle(fontSize: 13, color: _statusFilter == value ? colors.primary : colors.onSurface)),
const Spacer(),
if (_statusFilter == value)
Icon(Icons.check, size: 14, color: colors.primary),
]),
),
],
],
onSelected: (v) {
if (v.startsWith('sort:')) {
final sortVal = int.parse(v.substring(5));
if (sortVal != currentSort) onSortSelected(sortVal);
} else if (v.startsWith('filter:')) {
final filterVal = v.substring(7);
setState(() => _statusFilter = filterVal.isEmpty ? null : filterVal);
}
},
child: Container(
height: 36,
width: 36,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(10),
),
child: Icon(Icons.sort, size: 18, color: colors.onSurface.withValues(alpha: 0.6)),
),
);
}
/// 各分类的状态筛选选项,笔记返回 null无状态筛选
List<(String, String, IconData)>? _statusFilterOptions() {
switch (widget.mainTabIndex) {
case 0: // 影视
return [
('', '全部', Icons.select_all),
('watched', '已看', Icons.visibility),
('watching', '在看', Icons.play_circle_outline),
('want_to_watch', '想看', Icons.bookmark_outline),
];
case 1: // 阅读
return [
('', '全部', Icons.select_all),
('read', '已读', Icons.visibility),
('reading', '在读', Icons.play_circle_outline),
('want_to_read', '想读', Icons.bookmark_outline),
('abandoned', '弃读', Icons.block),
];
case 3: // 游戏
return [
('', '全部', Icons.select_all),
('completed', '通关', Icons.visibility),
('playing', '在玩', Icons.play_circle_outline),
('want_to_play', '想玩', Icons.bookmark_outline),
('abandoned', '弃游', Icons.block),
];
default:
return null;
}
}
(int, List<(int, String, IconData)>, String, ValueChanged<int>) _sortConfig(BuildContext context) {
final provider = context.read<AppProvider>();
switch (widget.mainTabIndex) {
case 0:
return (
UserPrefs().movieSortMode,
[(0, '按更新时间', Icons.update), (1, '按创建时间', Icons.calendar_today_outlined), (2, '按评分', Icons.star_outline)],
'影视排序',
(v) { UserPrefs().setMovieSortMode(v); provider.loadMovies(); },
);
case 1:
return (
UserPrefs().bookSortMode,
[(0, '按更新时间', Icons.update), (1, '按创建时间', Icons.calendar_today_outlined), (2, '按评分', Icons.star_outline)],
'书籍排序',
(v) { UserPrefs().setBookSortMode(v); provider.loadBooks(); },
);
case 2:
return (
UserPrefs().noteSortMode,
[(0, '按更新时间', Icons.update), (1, '按创建时间', Icons.calendar_today_outlined)],
'笔记排序',
(v) { UserPrefs().setNoteSortMode(v); provider.loadNotes(); },
);
case 3:
return (
UserPrefs().gameSortMode,
[(0, '按更新时间', Icons.update), (1, '按创建时间', Icons.calendar_today_outlined), (2, '按评分', Icons.star_outline)],
'游戏排序',
(v) { UserPrefs().setGameSortMode(v); provider.loadGames(); },
);
default:
return (0, <(int, String, IconData)>[], '', (_) {});
}
}
Widget _buildMovieList(BuildContext context) {
return Consumer<AppProvider>(
builder: (context, provider, _) {
final items = provider.movies.where((m) => !m.isDeleted && (_statusFilter == null || m.status == _statusFilter)).toList();
if (items.isEmpty) return _buildEmpty('暂无影视记录', Icons.movie_outlined);
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: items.length,
itemBuilder: (_, i) {
final m = items[i];
final (label, color) = _movieStatus(m.status);
return _CompactListItem(
title: m.title,
subtitle: _movieSubtitle(m),
imagePath: m.posterPath,
accentColor: const Color(0xFF2563EB),
icon: Icons.movie_outlined,
selected: provider.selectedMovie?.id == m.id,
statusLabel: label,
statusColor: color,
onTap: () => provider.selectMovie(m),
);
},
);
},
);
}
Widget _buildBookList(BuildContext context) {
return Consumer<AppProvider>(
builder: (context, provider, _) {
final items = provider.books.where((b) => !b.isDeleted && (_statusFilter == null || b.status == _statusFilter)).toList();
if (items.isEmpty) return _buildEmpty('暂无阅读记录', Icons.menu_book_outlined);
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: items.length,
itemBuilder: (_, i) {
final b = items[i];
final (label, color) = _bookStatus(b.status);
return _CompactListItem(
title: b.title,
subtitle: b.authors.isNotEmpty ? b.authors.join(', ') : null,
imagePath: b.coverPath,
accentColor: const Color(0xFF16A34A),
icon: Icons.menu_book_outlined,
selected: provider.selectedBook?.id == b.id,
statusLabel: label,
statusColor: color,
onTap: () => provider.selectBook(b),
);
},
);
},
);
}
Widget _buildNoteList(BuildContext context) {
return Consumer<AppProvider>(
builder: (context, provider, _) {
final items = provider.notes.where((n) => !n.isDeleted).toList();
if (items.isEmpty) return _buildEmpty('暂无笔记记录', Icons.note_outlined);
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: items.length,
itemBuilder: (_, i) => _CompactListItem(
title: items[i].title.isNotEmpty ? items[i].title : '随手记',
subtitle: items[i].content.length > 40 ? '${items[i].content.substring(0, 40)}...' : (items[i].content.isNotEmpty ? items[i].content : null),
imagePath: null,
accentColor: const Color(0xFF9333EA),
icon: Icons.note_outlined,
selected: provider.selectedNote?.id == items[i].id,
onTap: () {
provider.selectNote(items[i]);
},
),
);
},
);
}
Widget _buildGameList(BuildContext context) {
return Consumer<AppProvider>(
builder: (context, provider, _) {
final items = provider.games.where((g) => !g.isDeleted && (_statusFilter == null || g.status == _statusFilter)).toList();
if (items.isEmpty) return _buildEmpty('暂无游戏记录', Icons.sports_esports_outlined);
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: items.length,
itemBuilder: (_, i) {
final g = items[i];
final (label, color) = _gameStatus(g.status);
return _CompactListItem(
title: g.title,
subtitle: g.platforms.isNotEmpty ? g.platforms.join(', ') : null,
imagePath: g.coverPath,
accentColor: const Color(0xFFEA580C),
icon: Icons.sports_esports_outlined,
selected: provider.selectedGame?.id == g.id,
statusLabel: label,
statusColor: color,
onTap: () => provider.selectGame(g),
);
},
);
},
);
}
String? _movieSubtitle(Movie m) {
final parts = <String>[];
if (m.directors.isNotEmpty) parts.add(m.directors.join(', '));
if ((m.rating ?? 0) > 0) parts.add('${m.rating}');
return parts.isNotEmpty ? parts.join(' · ') : null;
}
(String, Color) _movieStatus(String status) => switch (status) {
'watched' => ('已看', const Color(0xFF16A34A)),
'watching' => ('在看', const Color(0xFF2563EB)),
'want_to_watch'=> ('想看', const Color(0xFF9CA3AF)),
_ => ('已看', const Color(0xFF16A34A)),
};
(String, Color) _bookStatus(String status) => switch (status) {
'read' => ('已读', const Color(0xFF16A34A)),
'reading' => ('在读', const Color(0xFF2563EB)),
'want_to_read' => ('想读', const Color(0xFF9CA3AF)),
'abandoned' => ('弃读', const Color(0xFFEF4444)),
_ => ('已读', const Color(0xFF16A34A)),
};
(String, Color) _gameStatus(String status) => switch (status) {
'completed' => ('通关', const Color(0xFF16A34A)),
'playing' => ('在玩', const Color(0xFF2563EB)),
'want_to_play' => ('想玩', const Color(0xFF9CA3AF)),
'abandoned' => ('弃游', const Color(0xFFEF4444)),
_ => ('通关', const Color(0xFF16A34A)),
};
// ─── 搜索结果 ──────────────────────────────────────────
Widget _buildSearchResults(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final provider = context.watch<AppProvider>();
final movies = provider.movies.where((m) => !m.isDeleted && _matchMovie(m, _keyword)).toList();
final books = provider.books.where((b) => !b.isDeleted && _matchBook(b, _keyword)).toList();
final notes = provider.notes.where((n) => !n.isDeleted && _matchNote(n, _keyword)).toList();
final games = provider.games.where((g) => !g.isDeleted && _matchGame(g, _keyword)).toList();
final totalCount = movies.length + books.length + notes.length + games.length;
if (totalCount == 0) {
return Center(
child: Text('未找到相关结果', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.35))),
);
}
return ListView(
padding: const EdgeInsets.symmetric(vertical: 4),
children: [
if (movies.isNotEmpty) ...[
_SearchGroupHeader(label: '影视', count: movies.length, color: const Color(0xFF2563EB)),
...movies.map((m) {
final (label, color) = _movieStatus(m.status);
return _CompactListItem(
title: m.title,
subtitle: _movieSubtitle(m),
imagePath: m.posterPath,
accentColor: const Color(0xFF2563EB),
icon: Icons.movie_outlined,
selected: provider.selectedMovie?.id == m.id,
statusLabel: label,
statusColor: color,
onTap: () {
provider.setMainTabIndex(0);
provider.selectMovie(m);
},
);
}),
],
if (books.isNotEmpty) ...[
_SearchGroupHeader(label: '阅读', count: books.length, color: const Color(0xFF16A34A)),
...books.map((b) {
final (label, color) = _bookStatus(b.status);
return _CompactListItem(
title: b.title,
subtitle: b.authors.isNotEmpty ? b.authors.join(', ') : null,
imagePath: b.coverPath,
accentColor: const Color(0xFF16A34A),
icon: Icons.menu_book_outlined,
selected: provider.selectedBook?.id == b.id,
statusLabel: label,
statusColor: color,
onTap: () {
provider.setMainTabIndex(1);
provider.selectBook(b);
},
);
}),
],
if (notes.isNotEmpty) ...[
_SearchGroupHeader(label: '笔记', count: notes.length, color: const Color(0xFF9333EA)),
...notes.map((n) => _CompactListItem(
title: n.title.isNotEmpty ? n.title : '随手记',
subtitle: n.content.length > 40 ? '${n.content.substring(0, 40)}...' : null,
imagePath: null,
accentColor: const Color(0xFF9333EA),
icon: Icons.note_outlined,
selected: provider.selectedNote?.id == n.id,
onTap: () {
provider.setMainTabIndex(2);
provider.selectNote(n);
},
)),
],
if (games.isNotEmpty) ...[
_SearchGroupHeader(label: '游戏', count: games.length, color: const Color(0xFFEA580C)),
...games.map((g) {
final (label, color) = _gameStatus(g.status);
return _CompactListItem(
title: g.title,
subtitle: g.platforms.isNotEmpty ? g.platforms.join(', ') : null,
imagePath: g.coverPath,
accentColor: const Color(0xFFEA580C),
icon: Icons.sports_esports_outlined,
selected: provider.selectedGame?.id == g.id,
statusLabel: label,
statusColor: color,
onTap: () {
provider.setMainTabIndex(3);
provider.selectGame(g);
},
);
}),
],
],
);
}
bool _matchMovie(Movie m, String kw) {
return m.title.toLowerCase().contains(kw) ||
(m.summary?.toLowerCase().contains(kw) ?? false) ||
m.genres.any((g) => g.toLowerCase().contains(kw)) ||
m.directors.any((d) => d.toLowerCase().contains(kw)) ||
m.actors.any((a) => a.toLowerCase().contains(kw));
}
bool _matchBook(Book b, String kw) {
return b.title.toLowerCase().contains(kw) ||
(b.summary?.toLowerCase().contains(kw) ?? false) ||
b.authors.any((a) => a.toLowerCase().contains(kw));
}
bool _matchNote(Note n, String kw) {
return n.title.toLowerCase().contains(kw) ||
n.content.toLowerCase().contains(kw) ||
n.tags.any((t) => t.toLowerCase().contains(kw));
}
bool _matchGame(Game g, String kw) {
return g.title.toLowerCase().contains(kw) ||
g.genres.any((ge) => ge.toLowerCase().contains(kw)) ||
g.platforms.any((p) => p.toLowerCase().contains(kw));
}
Widget _buildEmpty(String text, IconData icon) {
final colors = Theme.of(context).colorScheme;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 36, color: colors.onSurface.withValues(alpha: 0.15)),
const SizedBox(height: 8),
Text(text, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.35))),
],
),
);
}
}
// ─── 紧凑列表项 ──────────────────────────────────────────
class _CompactListItem extends StatelessWidget {
final String title;
final String? subtitle;
final String? imagePath;
final Color accentColor;
final IconData icon;
final bool selected;
final VoidCallback onTap;
final String? statusLabel; // 状态文字,如"已看""想看"
final Color? statusColor; // 状态颜色
const _CompactListItem({
required this.title,
this.subtitle,
this.imagePath,
required this.accentColor,
required this.icon,
required this.selected,
required this.onTap,
this.statusLabel,
this.statusColor,
});
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final isDark = colors.brightness == Brightness.dark;
final effectiveStatusColor = statusColor ?? accentColor;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 1),
child: Material(
color: selected
? accentColor.withValues(alpha: isDark ? 0.12 : 0.06)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
child: Row(
children: [
// 左侧状态指示条
Container(
width: 3,
height: 28,
decoration: BoxDecoration(
color: statusLabel != null ? effectiveStatusColor.withValues(alpha: 0.6) : Colors.transparent,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
// 缩略图 / 图标占位
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: accentColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(6),
),
clipBehavior: Clip.antiAlias,
child: imagePath != null && imagePath!.isNotEmpty
? FadeInLocalImage(
path: imagePath!,
fit: BoxFit.cover,
errorWidget: Icon(icon, size: 18, color: accentColor.withValues(alpha: 0.5)),
)
: Icon(icon, size: 18, color: accentColor.withValues(alpha: 0.5)),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(title, maxLines: 1, overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
color: selected ? accentColor : colors.onSurface,
)),
if (subtitle != null && subtitle!.isNotEmpty) ...[
const SizedBox(height: 1),
Text(subtitle!, maxLines: 1, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))),
],
],
),
),
// 状态标签
if (statusLabel != null) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: effectiveStatusColor.withValues(alpha: isDark ? 0.15 : 0.08),
borderRadius: BorderRadius.circular(4),
),
child: Text(statusLabel!, style: TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: effectiveStatusColor)),
),
],
],
),
),
),
),
);
}
}
// ─── 搜索分组标题 ──────────────────────────────────────
class _SearchGroupHeader extends StatelessWidget {
final String label;
final int count;
final Color color;
const _SearchGroupHeader({required this.label, required this.count, required this.color});
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.fromLTRB(18, 12, 18, 4),
child: Row(
children: [
Container(
width: 8, height: 8,
decoration: BoxDecoration(color: color.withValues(alpha: 0.6), borderRadius: BorderRadius.circular(2)),
),
const SizedBox(width: 6),
Text(label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.5))),
const SizedBox(width: 4),
Text('$count', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))),
],
),
);
}
}