Files
MookNote/lib/pages/profile_page.dart
2026-06-27 01:01:15 +08:00

2418 lines
87 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:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path;
import 'package:provider/provider.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:url_launcher/url_launcher.dart';
import '../models/data_models.dart';
import '../providers/app_provider.dart';
import '../utils/user_prefs.dart';
import '../utils/theme/app_theme.dart';
import '../utils/toast_util.dart';
import 'recycle_bin_page.dart';
import 'sync/backup_page.dart';
import '../widgets/fade_in_local_image.dart';
import 'statistics_page.dart';
import 'changelog_page.dart';
import 'legal_page.dart';
import 'online_search/enhanced_search_settings_page.dart';
import 'sync/cloud_sync_page.dart';
import 'app_icon_picker_page.dart';
import 'tag_management_page.dart';
import 'stroll_page.dart';
/// 个人中心页面
class ProfilePage extends StatefulWidget {
const ProfilePage({super.key});
@override
State<ProfilePage> createState() => _ProfilePageState();
}
class _ProfilePageState extends State<ProfilePage> {
final ImagePicker _picker = ImagePicker();
final UserPrefs _userPrefs = UserPrefs();
String _nickname = 'Mook';
String _motto = '好运不会眷顾一无所有之人。';
String? _avatarPath;
@override
void initState() {
super.initState();
_loadUserData();
}
Future<void> _loadUserData() async {
try {
await UserPrefs.init();
setState(() {
_nickname = _userPrefs.nickname;
_motto = _userPrefs.motto;
_avatarPath = _userPrefs.avatarPath;
});
} catch (_) {}
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Column(
children: [
AppBar(
leading: Builder(
builder: (context) => IconButton(
icon: Icon(Icons.menu, color: colors.onSurface),
onPressed: () => Scaffold.of(context).openDrawer(),
),
),
title: const Text('我的'),
),
Expanded(
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();
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHero(movies, books, notes),
const SizedBox(height: 20),
if (_userPrefs.showMovieTab) ...[
_buildModuleHeader('影视'),
_buildMovieModule(movies),
const SizedBox(height: 20),
],
if (_userPrefs.showBookTab) ...[
_buildModuleHeader('阅读'),
_buildBookModule(books),
const SizedBox(height: 20),
],
_buildTagsSection(movies, books, notes),
const SizedBox(height: 20),
_buildToolsGrid(context),
const SizedBox(height: 120),
],
),
);
},
),
),
],
);
}
// ─── Hero 区域 ──────────────────────────────────────────────────────
Widget _buildHero(List<Movie> movies, List<Book> books, List<Note> notes) {
final colors = Theme.of(context).colorScheme;
final coverPaths = [
...movies
.where((m) => m.posterPath != null && m.posterPath!.isNotEmpty)
.map((m) => m.posterPath!),
...books
.where((b) => b.coverPath != null && b.coverPath!.isNotEmpty)
.map((b) => b.coverPath!),
]..shuffle();
final hasData = coverPaths.length >= 4;
return Container(
margin: const EdgeInsets.fromLTRB(16, 0, 16, 0),
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(16)),
child: Stack(
children: [
if (hasData)
Positioned.fill(child: _buildPosterMosaic(coverPaths))
else
Positioned.fill(
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [colors.primary, colors.primary.withValues(alpha: 0.6)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
))),
Positioned.fill(
child: Container(
color: hasData
? Colors.black.withValues(alpha: 0.35)
: colors.surface.withValues(alpha: 0.82))),
Padding(
padding: EdgeInsets.fromLTRB(
20, MediaQuery.of(context).padding.top + 20, 20, 20),
child: Column(
children: [
Row(
children: [
GestureDetector(
onTap: _pickAvatar,
child: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: hasData
? Colors.white.withValues(alpha: 0.6)
: colors.outlineVariant,
width: 1.5),
),
clipBehavior: Clip.antiAlias,
child: _avatarPath != null && _avatarPath!.isNotEmpty
? FadeInLocalImage(
path: _avatarPath,
fit: BoxFit.cover,
errorWidget: Icon(Icons.person_outline,
size: 28,
color: hasData
? Colors.white.withValues(alpha: 0.5)
: colors.onSurface
.withValues(alpha: 0.3)))
: Container(
color: hasData
? Colors.white.withValues(alpha: 0.1)
: colors.surfaceContainerHighest,
child: Icon(Icons.person_outline,
size: 28,
color: hasData
? Colors.white.withValues(alpha: 0.5)
: colors.onSurface
.withValues(alpha: 0.3))),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(_nickname,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: hasData
? Colors.white
: colors.onSurface)),
const SizedBox(height: 4),
Text(_motto,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
color: hasData
? Colors.white.withValues(alpha: 0.85)
: colors.onSurface
.withValues(alpha: 0.6))),
],
),
),
],
),
const SizedBox(height: 20),
Row(
children: [
_buildHeroStat(_formatCount(movies.length), '观影', hasData),
_buildHeroStat(_formatCount(books.length), '阅读', hasData),
_buildHeroStat(_formatCount(notes.length), '笔记', hasData),
],
),
],
),
),
],
),
);
}
Widget _buildPosterMosaic(List<String> paths) {
final posters = paths.take(12).toList();
while (posters.length < 12)
posters.add(posters[posters.length % paths.length]);
return GridView.builder(
physics: const NeverScrollableScrollPhysics(),
padding: EdgeInsets.zero,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 2,
crossAxisSpacing: 2,
),
itemCount: 12,
itemBuilder: (_, i) =>
FadeInLocalImage(path: posters[i], fit: BoxFit.cover),
);
}
Widget _buildHeroStat(String value, String label, bool hasData) {
return Expanded(
child: Column(
children: [
Text(value,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: hasData
? Colors.white
: Theme.of(context).colorScheme.onSurface)),
const SizedBox(height: 2),
Text(label,
style: TextStyle(
fontSize: 11,
color: hasData
? Colors.white.withValues(alpha: 0.85)
: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.6))),
],
),
);
}
// ─── 模块标题 ──────────────────────────────────────────────────────
Widget _buildModuleHeader(String title) {
final colors = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Text(title,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: colors.onSurface)),
);
}
// ─── 影视模块 ──────────────────────────────────────────────────────
Widget _buildMovieModule(List<Movie> movies) {
final colors = Theme.of(context).colorScheme;
final watched = movies.where((m) => m.status == 'watched').length;
final watching = movies.where((m) => m.status == 'watching').length;
final wantTo = movies.where((m) => m.status == 'want_to_watch').length;
final recent = movies.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(children: [
_buildStatusTag('已看', watched, true, colors),
const SizedBox(width: 10),
_buildStatusTag('在看', watching, false, colors),
const SizedBox(width: 10),
_buildStatusTag('想看', wantTo, false, colors),
]),
),
const SizedBox(height: 10),
if (recent.isNotEmpty)
SizedBox(
height: 120,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 20),
itemCount: recent.take(8).length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (_, i) => _buildCoverCard(
title: recent[i].title,
imagePath: recent[i].posterPath,
onTap: () => Navigator.pushNamed(context, '/movie-detail',
arguments: recent[i]),
),
),
)
else
_buildEmptyHint('暂无影视记录'),
],
);
}
// ─── 阅读模块 ──────────────────────────────────────────────────────
Widget _buildBookModule(List<Book> books) {
final colors = Theme.of(context).colorScheme;
final read = books.where((b) => b.status == 'read').length;
final reading = books.where((b) => b.status == 'reading').length;
final wantTo = books.where((b) => b.status == 'want_to_read').length;
final recent = books.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(children: [
_buildStatusTag('已读', read, true, colors),
const SizedBox(width: 10),
_buildStatusTag('在读', reading, false, colors),
const SizedBox(width: 10),
_buildStatusTag('想读', wantTo, false, colors),
]),
),
const SizedBox(height: 10),
if (recent.isNotEmpty)
SizedBox(
height: 120,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 20),
itemCount: recent.take(8).length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (_, i) => _buildCoverCard(
title: recent[i].title,
imagePath: recent[i].coverPath,
onTap: () => Navigator.pushNamed(context, '/book-detail',
arguments: recent[i]),
),
),
)
else
_buildEmptyHint('暂无阅读记录'),
],
);
}
Widget _buildStatusTag(
String label, int count, bool active, ColorScheme colors) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: active
? colors.primary
: colors.onSurface.withValues(alpha: 0.25),
),
),
const SizedBox(width: 4),
Text('$label $count',
style: TextStyle(
fontSize: 12,
fontWeight: active ? FontWeight.w600 : FontWeight.w400,
color: active
? colors.onSurface
: colors.onSurface.withValues(alpha: 0.5),
)),
],
);
}
Widget _buildCoverCard(
{required String title, String? imagePath, VoidCallback? onTap}) {
final colors = Theme.of(context).colorScheme;
return GestureDetector(
onTap: onTap,
child: SizedBox(
width: 78,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Container(
width: 78,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
clipBehavior: Clip.antiAlias,
child: imagePath != null && imagePath.isNotEmpty
? FadeInLocalImage(
path: imagePath,
fit: BoxFit.cover,
errorWidget: Icon(Icons.image_outlined,
size: 20,
color: colors.onSurface.withValues(alpha: 0.2)))
: Icon(Icons.image_outlined,
size: 20,
color: colors.onSurface.withValues(alpha: 0.2)),
),
),
const SizedBox(height: 4),
Text(title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 10,
color: colors.onSurface.withValues(alpha: 0.6))),
],
),
),
);
}
Widget _buildEmptyHint(String text) {
final colors = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20),
child: Center(
child: Text(text,
style: TextStyle(
fontSize: 13,
color: colors.onSurface.withValues(alpha: 0.3)))),
);
}
// ─── 标签模块 ──────────────────────────────────────────────────────
Widget _buildTagsSection(
List<Movie> movies, List<Book> books, List<Note> notes) {
final colors = Theme.of(context).colorScheme;
final freq = <String, int>{};
for (final m in movies) {
for (final g in m.genres) {
freq[g] = (freq[g] ?? 0) + 1;
}
}
for (final b in books) {
for (final g in b.genres) {
freq[g] = (freq[g] ?? 0) + 1;
}
}
for (final n in notes) {
for (final t in n.tags) {
freq[t] = (freq[t] ?? 0) + 1;
}
}
final sorted = freq.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
final topTags = sorted.take(10).map((e) => e.key).toList();
if (topTags.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
Text('常用标签',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: colors.onSurface)),
const Spacer(),
GestureDetector(
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const TagManagementPage())),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('管理',
style: TextStyle(
fontSize: 12,
color: colors.onSurface.withValues(alpha: 0.4))),
Icon(Icons.chevron_right,
size: 16,
color: colors.onSurface.withValues(alpha: 0.3)),
],
),
),
],
),
),
const SizedBox(height: 10),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: topTags
.map((tag) => Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Text(tag,
style: TextStyle(
fontSize: 12,
color: colors.onSurface.withValues(alpha: 0.7))),
))
.toList(),
),
),
],
);
}
// ─── 工具栏 ────────────────────────────────────────────────────────
Widget _buildToolsGrid(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final tools = [
(
Icons.explore_outlined,
'漫步',
() => Navigator.push(
context, MaterialPageRoute(builder: (_) => const StrollPage()))
),
(
Icons.analytics_outlined,
'统计',
() => Navigator.push(
context, MaterialPageRoute(builder: (_) => const StatisticsPage()))
),
(Icons.backup_outlined, '备份', () => _showBackupOptions(context)),
(
Icons.settings_outlined,
'设置',
() => Navigator.push(
context, MaterialPageRoute(builder: (_) => const SettingsPage()))
),
(
Icons.delete_outline,
'回收',
() => Navigator.push(
context, MaterialPageRoute(builder: (_) => const RecycleBinPage()))
),
];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Container(
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: tools.asMap().entries.map((e) {
final isLast = e.key == tools.length - 1;
return Column(
children: [
InkWell(
onTap: e.value.$3,
borderRadius: BorderRadius.only(
topLeft:
e.key == 0 ? const Radius.circular(12) : Radius.zero,
topRight:
e.key == 0 ? const Radius.circular(12) : Radius.zero,
bottomLeft:
isLast ? const Radius.circular(12) : Radius.zero,
bottomRight:
isLast ? const Radius.circular(12) : Radius.zero,
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(e.value.$1,
size: 18,
color: colors.onSurface.withValues(alpha: 0.6)),
const SizedBox(width: 12),
Expanded(
child: Text(e.value.$2,
style: TextStyle(
fontSize: 13,
color: colors.onSurface
.withValues(alpha: 0.7)))),
Icon(Icons.chevron_right,
size: 16,
color: colors.onSurface.withValues(alpha: 0.2)),
],
),
),
),
if (!isLast)
Divider(
height: 1,
indent: 46,
endIndent: 16,
color: colors.outlineVariant),
],
);
}).toList(),
),
),
);
}
// ─── 辅助方法 ──────────────────────────────────────────────────────
void _push(BuildContext context, Widget page) {
Navigator.push(context, MaterialPageRoute(builder: (_) => page));
}
String _formatCount(int count) {
if (count >= 10000) return '${(count / 10000).toStringAsFixed(1)}';
if (count >= 1000) return '${(count / 1000).toStringAsFixed(1)}k';
return count.toString();
}
// ─── 头像 ────────────────────────────────────────────────────────────
Future<void> _pickAvatar() async {
final colors = Theme.of(context).colorScheme;
await showModalBottomSheet(
context: context,
backgroundColor: colors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (ctx) => SafeArea(
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(
width: 36,
height: 4,
margin: const EdgeInsets.only(top: 12, bottom: 16),
decoration: BoxDecoration(
color: colors.onSurface.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(2))),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Align(
alignment: Alignment.centerLeft,
child: Text('更换头像',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: colors.onSurface)))),
const SizedBox(height: 8),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10)),
child: Icon(Icons.folder_outlined,
size: 20, color: colors.onSurface.withValues(alpha: 0.6))),
title: Text('从文件管理器选择',
style: TextStyle(fontSize: 14, color: colors.onSurface)),
trailing: Icon(Icons.chevron_right,
color: colors.onSurface.withValues(alpha: 0.25)),
onTap: () async {
Navigator.pop(ctx);
await _pickAvatarFromFile();
},
),
if (_avatarPath != null && _avatarPath!.isNotEmpty) ...[
Divider(
height: 0.5,
indent: 24,
endIndent: 24,
color: colors.outlineVariant),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10)),
child: Icon(Icons.delete_outline,
size: 20, color: colors.error)),
title: Text('移除头像',
style: TextStyle(fontSize: 14, color: colors.error)),
trailing: Icon(Icons.chevron_right,
color: colors.onSurface.withValues(alpha: 0.25)),
onTap: () async {
Navigator.pop(ctx);
await _userPrefs.clearAvatarPath();
setState(() => _avatarPath = null);
},
),
],
const SizedBox(height: 8),
]),
),
);
}
Future<void> _pickAvatarFromFile() async {
try {
final pickedFile = await _picker.pickImage(
source: ImageSource.gallery,
maxWidth: 400,
maxHeight: 400,
imageQuality: 85);
if (pickedFile != null) {
final appDir = await getApplicationDocumentsDirectory();
final fileName = 'avatar_${DateTime.now().millisecondsSinceEpoch}.jpg';
final savedPath = path.join(appDir.path, 'avatars', fileName);
final avatarDir = Directory(path.join(appDir.path, 'avatars'));
if (!await avatarDir.exists()) await avatarDir.create(recursive: true);
await File(pickedFile.path).copy(savedPath);
await _userPrefs.setAvatarPath(savedPath);
setState(() => _avatarPath = savedPath);
}
} catch (e) {
if (mounted) ToastUtil.show(context, '选择头像失败');
}
}
// ─── 备份弹窗 ────────────────────────────────────────────────────────
void _showBackupOptions(BuildContext context) {
final colors = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
backgroundColor: colors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (ctx) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: colors.onSurface.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(2))),
const SizedBox(height: 20),
Align(
alignment: Alignment.centerLeft,
child: Text('选择备份方式',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: colors.onSurface))),
const SizedBox(height: 16),
ListTile(
contentPadding: EdgeInsets.zero,
leading: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10)),
child: Icon(Icons.folder_outlined,
color: colors.onSurface.withValues(alpha: 0.6))),
title: Text('本地备份',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: colors.onSurface)),
subtitle: Text('备份到本地文件夹,支持恢复',
style: TextStyle(
fontSize: 11,
color: colors.onSurface.withValues(alpha: 0.4))),
trailing: Icon(Icons.chevron_right,
color: colors.onSurface.withValues(alpha: 0.25)),
onTap: () {
Navigator.pop(ctx);
_push(context, const BackupPage());
},
),
Divider(height: 0.5, color: colors.outlineVariant),
ListTile(
contentPadding: EdgeInsets.zero,
leading: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10)),
child: Icon(Icons.cloud_outlined,
color: colors.onSurface.withValues(alpha: 0.6))),
title: Text('云备份',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: colors.onSurface)),
subtitle: Text('通过 WebDAV 同步到云端',
style: TextStyle(
fontSize: 11,
color: colors.onSurface.withValues(alpha: 0.4))),
trailing: Icon(Icons.chevron_right,
color: colors.onSurface.withValues(alpha: 0.25)),
onTap: () {
Navigator.pop(ctx);
_push(context, const CloudSyncPage());
},
),
const SizedBox(height: 20),
],
),
),
);
}
}
// ─── 设置页面 ──────────────────────────────────────────────────────────
class SettingsPage extends StatefulWidget {
const SettingsPage({super.key});
@override
State<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
final UserPrefs _userPrefs = UserPrefs();
bool _hideBottomNavOnScroll = true;
int _themeMode = 0; // 0=系统, 1=浅色, 2=深色
String _fontFamily = '';
@override
void initState() {
super.initState();
_hideBottomNavOnScroll = _userPrefs.hideBottomNavOnScroll;
_themeMode = _userPrefs.themeMode;
_fontFamily = _userPrefs.fontFamily;
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(title: const Text('设置')),
body: ListView(
children: [
_buildSectionHeader('显示设置'),
_buildNavigationItem(
icon: Icons.apps_outlined,
title: '应用图标',
subtitle: '更换桌面应用图标',
onTap: () => Navigator.push(context,
MaterialPageRoute(builder: (_) => const AppIconPickerPage())),
),
Divider(
height: 0.5,
indent: 24,
endIndent: 24,
color: colors.outlineVariant),
_buildNavigationItem(
icon: Icons.view_list_outlined,
title: '主界面设置',
subtitle: '启动标签、模块显示开关',
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const MainContentSettingsPage())),
),
Divider(
height: 0.5,
indent: 24,
endIndent: 24,
color: colors.outlineVariant),
_buildNavigationItem(
icon: Icons.dashboard_outlined,
title: '布局设置',
subtitle: '影视、阅读、笔记的展示样式',
onTap: () => Navigator.push(context,
MaterialPageRoute(builder: (_) => const LayoutSettingsPage())),
),
Divider(
height: 0.5,
indent: 24,
endIndent: 24,
color: colors.outlineVariant),
_buildThemeModeSelector(),
Divider(
height: 0.5,
indent: 24,
endIndent: 24,
color: colors.outlineVariant),
_buildColorSchemeSelector(),
Divider(
height: 0.5,
indent: 24,
endIndent: 24,
color: colors.outlineVariant),
_buildFontSelector(),
Divider(
height: 0.5,
indent: 24,
endIndent: 24,
color: colors.outlineVariant),
_buildSectionHeader('其他设置'),
_buildActionItem(
icon: Icons.person_outline,
title: '个人信息',
subtitle: '修改昵称和座右铭',
onTap: () => _showProfileEditDialog(context),
),
Divider(
height: 0.5,
indent: 24,
endIndent: 24,
color: colors.outlineVariant),
_buildActionItem(
icon: Icons.manage_search,
title: '增强搜索',
subtitle: '在线搜索影视和书籍信息',
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const EnhancedSearchSettingsPage())),
),
Divider(
height: 0.5,
indent: 24,
endIndent: 24,
color: colors.outlineVariant),
_buildSwitchItem(
icon: Icons.swipe_vertical_outlined,
title: '底部导航栏滚动隐藏',
subtitle: '下滑时自动隐藏底部导航栏',
value: _hideBottomNavOnScroll,
onChanged: _toggleHideBottomNavOnScroll,
),
Divider(
height: 0.5,
indent: 24,
endIndent: 24,
color: colors.outlineVariant),
_buildSectionHeader('数据管理'),
_buildActionItem(
icon: Icons.cleaning_services_outlined,
title: '清除缓存数据',
subtitle: '清理未在数据库中引用的图片文件',
onTap: () => _showClearCacheDialog(context),
),
Divider(
height: 0.5,
indent: 24,
endIndent: 24,
color: colors.outlineVariant),
_buildSectionHeader('帮助'),
_buildActionItem(
icon: Icons.language_outlined,
title: '查看官网',
subtitle: '在浏览器中打开官方网站',
onTap: () =>
launchUrl(Uri.parse('https://mooknote.iletter.top/#/')),
),
Divider(
height: 0.5,
indent: 24,
endIndent: 24,
color: colors.outlineVariant),
_buildActionItem(
icon: Icons.open_in_new_outlined,
title: '项目源码',
subtitle: '查看 GitHub 项目仓库',
onTap: () =>
launchUrl(Uri.parse('https://github.com/dellevin/mooknote')),
),
Divider(
height: 0.5,
indent: 24,
endIndent: 24,
color: colors.outlineVariant),
_buildActionItem(
icon: Icons.code_outlined,
title: '开发日志',
subtitle: '在浏览器中查看项目开发记录',
onTap: () => launchUrl(Uri.parse(
'http://docmost.iletter.top/share/ropwljpyvn/p/mook-note-lHmPTswdDC')),
),
Divider(
height: 0.5,
indent: 24,
endIndent: 24,
color: colors.outlineVariant),
_buildActionItem(
icon: Icons.update_outlined,
title: '更新日志',
subtitle: '查看版本更新内容',
onTap: () => Navigator.push(context,
MaterialPageRoute(builder: (_) => const ChangelogPage())),
),
Divider(
height: 0.5,
indent: 24,
endIndent: 24,
color: colors.outlineVariant),
_buildActionItem(
icon: Icons.description_outlined,
title: '用户服务协议',
subtitle: '查看用户服务协议',
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) =>
const LegalPage(slug: 'terms', title: '用户服务协议'))),
),
Divider(
height: 0.5,
indent: 24,
endIndent: 24,
color: colors.outlineVariant),
_buildActionItem(
icon: Icons.shield_outlined,
title: '隐私政策',
subtitle: '查看隐私政策',
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) =>
const LegalPage(slug: 'privacy', title: '隐私政策'))),
),
Divider(
height: 0.5,
indent: 24,
endIndent: 24,
color: colors.outlineVariant),
],
),
);
}
Future<void> _toggleHideBottomNavOnScroll(bool value) async {
await _userPrefs.setHideBottomNavOnScroll(value);
setState(() => _hideBottomNavOnScroll = value);
}
void _showProfileEditDialog(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final nicknameController = TextEditingController(text: _userPrefs.nickname);
final mottoController = TextEditingController(text: _userPrefs.motto);
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: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nicknameController,
style: TextStyle(fontSize: 14, color: colors.onSurface),
decoration: InputDecoration(
labelText: '昵称',
labelStyle: TextStyle(
fontSize: 13,
color: colors.onSurface.withValues(alpha: 0.5)),
filled: true,
fillColor: colors.surfaceContainerHighest,
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none),
),
),
const SizedBox(height: 12),
TextField(
controller: mottoController,
maxLines: 2,
style: TextStyle(fontSize: 14, color: colors.onSurface),
decoration: InputDecoration(
labelText: '座右铭',
labelStyle: TextStyle(
fontSize: 13,
color: colors.onSurface.withValues(alpha: 0.5)),
filled: true,
fillColor: colors.surfaceContainerHighest,
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none),
),
),
],
),
actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
style: TextButton.styleFrom(
foregroundColor: colors.onSurface.withValues(alpha: 0.6),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
),
child: const Text('取消', style: TextStyle(fontSize: 14)),
),
ElevatedButton(
onPressed: () async {
final nickname = nicknameController.text.trim();
final motto = mottoController.text.trim();
if (nickname.isNotEmpty) await _userPrefs.setNickname(nickname);
if (motto.isNotEmpty) await _userPrefs.setMotto(motto);
if (ctx.mounted) Navigator.pop(ctx);
if (context.mounted) ToastUtil.show(context, '已保存');
},
style: ElevatedButton.styleFrom(
backgroundColor: colors.primary,
foregroundColor: colors.onPrimary,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
),
child: const Text('保存', style: TextStyle(fontSize: 14)),
),
],
),
);
}
static const _themeModeLabels = ['跟随系统', '浅色模式', '深色模式'];
static const _themeModeIcons = [
Icons.brightness_auto,
Icons.light_mode,
Icons.dark_mode
];
Widget _buildThemeModeSelector() {
final colors = Theme.of(context).colorScheme;
return InkWell(
onTap: () => _showThemeModePicker(),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10)),
child: Icon(_themeModeIcons[_themeMode],
color: colors.onSurface.withValues(alpha: 0.6), size: 18)),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('主题模式',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: colors.onSurface)),
const SizedBox(height: 2),
Text(_themeModeLabels[_themeMode],
style: TextStyle(
fontSize: 11,
color: colors.onSurface.withValues(alpha: 0.4))),
]),
),
Icon(Icons.chevron_right,
color: colors.onSurface.withValues(alpha: 0.25), size: 20),
],
),
),
);
}
void _showThemeModePicker() {
final colors = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (ctx) => Container(
decoration: BoxDecoration(
color: colors.surface,
borderRadius:
const BorderRadius.vertical(top: Radius.circular(16))),
padding: const EdgeInsets.only(bottom: 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 36,
height: 4,
margin: const EdgeInsets.only(top: 12, bottom: 12),
decoration: BoxDecoration(
color: colors.onSurface.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(2))),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Align(
alignment: Alignment.centerLeft,
child: Text('主题模式',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: colors.onSurface))),
),
const SizedBox(height: 8),
for (int i = 0; i < _themeModeLabels.length; i++)
InkWell(
onTap: () async {
await _setThemeMode(i);
Navigator.pop(ctx);
},
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 24, vertical: 10),
child: Row(
children: [
Icon(_themeModeIcons[i],
size: 18,
color: colors.onSurface.withValues(alpha: 0.6)),
const SizedBox(width: 12),
Expanded(
child: Text(_themeModeLabels[i],
style: TextStyle(
fontSize: 13, color: colors.onSurface))),
if (_themeMode == i)
Icon(Icons.check, color: colors.onSurface, size: 18),
],
),
),
),
],
),
),
);
}
Future<void> _setThemeMode(int mode) async {
await _userPrefs.setThemeMode(mode);
final themeMode = switch (mode) {
1 => ThemeMode.light,
2 => ThemeMode.dark,
_ => ThemeMode.system,
};
if (mounted) {
context.read<AppProvider>().setThemeMode(themeMode);
setState(() => _themeMode = mode);
}
}
Widget _buildColorSchemeSelector() {
final colors = Theme.of(context).colorScheme;
final provider = context.watch<AppProvider>();
final currentIndex = provider.colorSchemeIndex;
final label = currentIndex == -1 ? '莫奈取色' : AppTheme.colorSchemeNames[currentIndex];
return InkWell(
onTap: () => _showColorSchemePicker(),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10)),
child: Icon(currentIndex == -1 ? Icons.auto_awesome : Icons.palette_outlined,
color: colors.onSurface.withValues(alpha: 0.6), size: 18)),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('配色方案',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: colors.onSurface)),
const SizedBox(height: 2),
Text(label,
style: TextStyle(
fontSize: 11,
color: colors.onSurface.withValues(alpha: 0.4))),
]),
),
Icon(Icons.chevron_right,
color: colors.onSurface.withValues(alpha: 0.25), size: 20),
],
),
),
);
}
void _showColorSchemePicker() {
final colors = Theme.of(context).colorScheme;
final provider = context.read<AppProvider>();
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (ctx) => Container(
decoration: BoxDecoration(
color: colors.surface,
borderRadius:
const BorderRadius.vertical(top: Radius.circular(16))),
padding: const EdgeInsets.only(bottom: 20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 36,
height: 4,
margin: const EdgeInsets.only(top: 12, bottom: 16),
decoration: BoxDecoration(
color: colors.onSurface.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(2))),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Align(
alignment: Alignment.centerLeft,
child: Text('配色方案',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: colors.onSurface))),
),
const SizedBox(height: 12),
// 莫奈自动取色
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: _buildMonetOption(provider, colors, ctx),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 2.2),
itemCount: AppTheme.seedColors.length,
itemBuilder: (_, i) {
final selected = provider.colorSchemeIndex == i;
return GestureDetector(
onTap: () {
provider.setColorScheme(i);
Navigator.pop(ctx);
},
child: Container(
decoration: BoxDecoration(
color: selected
? AppTheme.seedColors[i].withValues(alpha: 0.12)
: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: selected
? AppTheme.seedColors[i]
: Colors.transparent,
width: 1.5),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 14,
height: 14,
decoration: BoxDecoration(
color: AppTheme.seedColors[i],
shape: BoxShape.circle)),
const SizedBox(width: 8),
Text(AppTheme.colorSchemeNames[i],
style: TextStyle(
fontSize: 13,
fontWeight: selected
? FontWeight.w600
: FontWeight.w400,
color: colors.onSurface)),
],
),
),
);
},
),
),
],
),
),
);
}
Widget _buildMonetOption(AppProvider provider, ColorScheme colors, BuildContext ctx) {
final selected = provider.colorSchemeIndex == -1;
final monetColor = AppTheme.monetColor;
final available = monetColor != null;
return GestureDetector(
onTap: available
? () { provider.setColorScheme(-1); Navigator.pop(ctx); }
: null,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: selected
? (monetColor ?? colors.primary).withValues(alpha: 0.12)
: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: selected
? (monetColor ?? colors.primary)
: available ? colors.outlineVariant : colors.outlineVariant.withValues(alpha: 0.5),
width: selected ? 1.5 : 0.5),
),
child: Row(
children: [
Container(
width: 20, height: 20,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: monetColor != null
? LinearGradient(colors: [monetColor, monetColor.withValues(alpha: 0.6)])
: null,
color: available ? null : colors.outlineVariant,
),
child: available ? null : Icon(Icons.auto_awesome, size: 12, color: colors.onSurface.withValues(alpha: 0.3)),
),
const SizedBox(width: 10),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('莫奈取色', style: TextStyle(
fontSize: 13,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
color: available ? colors.onSurface : colors.onSurface.withValues(alpha: 0.35))),
Text(available ? '从系统壁纸自动提取配色' : '此设备不支持',
style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.35))),
]),
),
if (selected) Icon(Icons.check_circle, size: 18, color: monetColor ?? colors.primary),
],
),
),
);
}
// ─── 字体选择器 ───
static const _fontLabels = ['系统默认', '霞鹜文楷', 'OPPO Sans', '思源宋体', '得意黑'];
static const _fontValues = [
'',
'LXGWWenKai',
'OPPOSans',
'NotoSerifSC',
'SmileySans'
];
static const _fontIcons = [
Icons.font_download_outlined,
Icons.brush_outlined,
Icons.phone_android,
Icons.text_fields,
Icons.emoji_emotions_outlined
];
Widget _buildFontSelector() {
final colors = Theme.of(context).colorScheme;
final idx = _fontValues.indexOf(_fontFamily);
final label = idx >= 0 ? _fontLabels[idx] : '系统默认';
return InkWell(
onTap: _showFontPicker,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
child: Row(children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10)),
child: Icon(Icons.font_download_outlined,
size: 18, color: colors.onSurface.withValues(alpha: 0.6))),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('字体',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: colors.onSurface)),
const SizedBox(height: 2),
Text(label,
style: TextStyle(
fontSize: 11,
color: colors.onSurface.withValues(alpha: 0.4))),
])),
Icon(Icons.chevron_right,
size: 20, color: colors.onSurface.withValues(alpha: 0.25)),
]),
),
);
}
void _showFontPicker() {
final colors = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (ctx) => Container(
decoration: BoxDecoration(
color: colors.surface,
borderRadius:
const BorderRadius.vertical(top: Radius.circular(16))),
padding: const EdgeInsets.only(bottom: 20),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(
width: 36,
height: 4,
margin: const EdgeInsets.only(top: 12, bottom: 16),
decoration: BoxDecoration(
color: colors.onSurface.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(2))),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Align(
alignment: Alignment.centerLeft,
child: Text('字体',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: colors.onSurface)))),
const SizedBox(height: 8),
for (int i = 0; i < _fontLabels.length; i++) ...[
if (i > 0)
Divider(
height: 0.5,
indent: 24,
endIndent: 24,
color: colors.outlineVariant),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10)),
child: Icon(_fontIcons[i],
size: 20,
color: _fontFamily == _fontValues[i]
? colors.primary
: colors.onSurface.withValues(alpha: 0.6))),
title: Text(_fontLabels[i],
style: TextStyle(
fontSize: 14,
fontWeight: _fontFamily == _fontValues[i]
? FontWeight.w600
: FontWeight.w400,
color: colors.onSurface)),
trailing: _fontFamily == _fontValues[i]
? Icon(Icons.check, size: 20, color: colors.primary)
: null,
onTap: () {
Navigator.pop(ctx);
_setFontFamily(_fontValues[i]);
},
),
],
]),
),
);
}
void _setFontFamily(String family) {
setState(() => _fontFamily = family);
context.read<AppProvider>().setFontFamily(family);
}
Widget _buildSwitchItem(
{required IconData icon,
required String title,
required String subtitle,
required bool value,
required ValueChanged<bool> onChanged}) {
final colors = Theme.of(context).colorScheme;
return InkWell(
onTap: () => onChanged(!value),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10)),
child: Icon(icon,
color: colors.onSurface.withValues(alpha: 0.6), size: 18)),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: colors.onSurface)),
const SizedBox(height: 2),
Text(subtitle,
style: TextStyle(
fontSize: 11,
color: colors.onSurface.withValues(alpha: 0.4))),
]),
),
Switch(
value: value,
onChanged: onChanged,
activeColor: colors.primary,
activeTrackColor: colors.primary.withOpacity(0.3),
inactiveThumbColor: colors.surface,
inactiveTrackColor: colors.outline),
],
),
),
);
}
Widget _buildSectionHeader(String title) {
final colors = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 8),
child: Text(title,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: colors.onSurface)),
);
}
Widget _buildNavigationItem(
{required IconData icon,
required String title,
required String subtitle,
required VoidCallback onTap}) {
final colors = Theme.of(context).colorScheme;
return InkWell(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10)),
child: Icon(icon,
color: colors.onSurface.withValues(alpha: 0.6), size: 18)),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: colors.onSurface)),
const SizedBox(height: 2),
Text(subtitle,
style: TextStyle(
fontSize: 11,
color: colors.onSurface.withValues(alpha: 0.4))),
]),
),
Icon(Icons.chevron_right,
color: colors.onSurface.withValues(alpha: 0.25), size: 20),
],
),
),
);
}
Widget _buildActionItem(
{required IconData icon,
required String title,
required String subtitle,
required VoidCallback onTap}) {
final colors = Theme.of(context).colorScheme;
return InkWell(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10)),
child: Icon(icon,
color: colors.onSurface.withValues(alpha: 0.6), size: 18)),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: colors.onSurface)),
const SizedBox(height: 2),
Text(subtitle,
style: TextStyle(
fontSize: 11,
color: colors.onSurface.withValues(alpha: 0.4))),
]),
),
],
),
),
);
}
void _showClearCacheDialog(BuildContext pageContext) {
final colors = Theme.of(context).colorScheme;
showDialog(
context: pageContext,
builder: (dialogContext) => AlertDialog(
backgroundColor: colors.surface,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: Text('清除缓存数据',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: colors.onSurface)),
content: Text('这将删除所有未在数据库中引用的图片文件。确定要继续吗?',
style: TextStyle(
fontSize: 14,
color: colors.onSurface.withValues(alpha: 0.6),
height: 1.5)),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text('取消',
style: TextStyle(
color: colors.onSurface.withValues(alpha: 0.6)))),
ElevatedButton(
onPressed: () async {
Navigator.pop(dialogContext);
await _clearCacheData(pageContext);
},
style: ElevatedButton.styleFrom(
backgroundColor: colors.error,
foregroundColor: colors.onError,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8)),
child: const Text('清除'),
),
],
actionsPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
);
}
Future<void> _clearCacheData(BuildContext context) async {
try {
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const Center(child: CircularProgressIndicator()));
final appProvider = context.read<AppProvider>();
// 1. 清理未引用的图片文件
final dbImagePaths = await _getAllDbImagePaths(appProvider);
final deletedImages = await _cleanImageDirectory(dbImagePaths);
// 2. 清理临时目录缓存分享海报、备份ZIP等
final deletedTemp = await _cleanTempDirectory();
Navigator.pop(context);
if (context.mounted) {
final total = deletedImages + deletedTemp;
if (total == 0) {
ToastUtil.show(context, '没有需要清理的缓存');
} else {
ToastUtil.show(
context, '已清理 $deletedImages 个孤立图片,$deletedTemp 个临时文件');
}
}
} catch (e) {
Navigator.pop(context);
if (context.mounted) ToastUtil.show(context, '清理失败: $e');
}
}
Future<Set<String>> _getAllDbImagePaths(AppProvider provider) async {
final paths = <String>{};
for (final movie in provider.movies) {
if (movie.posterPath?.isNotEmpty == true) paths.add(movie.posterPath!);
}
for (final book in provider.books) {
if (book.coverPath?.isNotEmpty == true) paths.add(book.coverPath!);
}
for (final note in provider.notes) {
for (final p in note.images) {
if (p.isNotEmpty) paths.add(p);
}
}
for (final movieId in provider.movies.map((m) => m.id)) {
for (final poster in await provider.getMoviePosters(movieId)) {
if (poster.posterPath.isNotEmpty) paths.add(poster.posterPath);
}
}
return paths;
}
Future<int> _cleanImageDirectory(Set<String> dbImagePaths) async {
int deletedCount = 0;
try {
final appDir = await getApplicationDocumentsDirectory();
final imagesDir = Directory('${appDir.path}/images');
if (!await imagesDir.exists()) return 0;
await for (final entity
in imagesDir.list(recursive: true, followLinks: false)) {
if (entity is File &&
!dbImagePaths.contains(entity.path) &&
!path.basename(entity.path).startsWith('avatar')) {
try {
await entity.delete();
deletedCount++;
} catch (_) {}
}
}
} catch (e) {
debugPrint('清理图片目录失败: $e');
}
return deletedCount;
}
Future<int> _cleanTempDirectory() async {
int deletedCount = 0;
final now = DateTime.now();
// 1. 清理临时目录中的分享海报保留备份ZIP
try {
final tempDir = await getTemporaryDirectory();
if (await tempDir.exists()) {
await for (final entity in tempDir.list(followLinks: false)) {
if (entity is File) {
final name = path.basename(entity.path);
if (name.startsWith('book_poster_') ||
name.startsWith('movie_poster_') ||
name.startsWith('note_share_') ||
name.startsWith('mooknote_download') ||
name.startsWith('mooknote_bidir')) {
try {
final stat = await entity.stat();
if (now.difference(stat.modified).inHours >= 1) {
await entity.delete();
deletedCount++;
}
} catch (_) {}
}
}
}
}
} catch (e) {
debugPrint('清理临时目录失败: $e');
}
// 2. 清理应用缓存目录 (/data/user/0/{package}/cache/)
try {
final cacheDir = await getApplicationCacheDirectory();
if (await cacheDir.exists()) {
await for (final entity
in cacheDir.list(recursive: true, followLinks: false)) {
if (entity is File) {
try {
await entity.delete();
deletedCount++;
} catch (_) {}
}
}
}
} catch (e) {
debugPrint('清理缓存目录失败: $e');
}
return deletedCount;
}
}
// ─── 主界面设置 ───
class MainContentSettingsPage extends StatefulWidget {
const MainContentSettingsPage({super.key});
@override
State<MainContentSettingsPage> createState() =>
_MainContentSettingsPageState();
}
class _MainContentSettingsPageState extends State<MainContentSettingsPage> {
final UserPrefs _userPrefs = UserPrefs();
bool _showMovieTab = true;
bool _showBookTab = true;
bool _showNoteTab = true;
bool _showNotePlusTab = false;
int _defaultTabIndex = 0;
@override
void initState() {
super.initState();
_loadSettings();
}
void _loadSettings() {
setState(() {
_showMovieTab = _userPrefs.showMovieTab;
_showBookTab = _userPrefs.showBookTab;
_showNoteTab = _userPrefs.showNoteTab;
_showNotePlusTab = _userPrefs.showNotePlusTab;
_defaultTabIndex = _userPrefs.defaultMainTabIndex;
});
}
int get _enabledTabCount {
int count = 0;
if (_showMovieTab) count++;
if (_showBookTab) count++;
if (_showNoteTab) count++;
return count;
}
Future<void> _toggleMovieTab(bool value) async {
if (!value && _enabledTabCount <= 1) {
ToastUtil.show(context, '至少保留一个标签页');
return;
}
await _userPrefs.setShowMovieTab(value);
setState(() => _showMovieTab = value);
}
Future<void> _toggleBookTab(bool value) async {
if (!value && _enabledTabCount <= 1) {
ToastUtil.show(context, '至少保留一个标签页');
return;
}
await _userPrefs.setShowBookTab(value);
setState(() => _showBookTab = value);
}
Future<void> _toggleNoteTab(bool value) async {
if (!value && _enabledTabCount <= 1) {
ToastUtil.show(context, '至少保留一个标签页');
return;
}
await _userPrefs.setShowNoteTab(value);
setState(() => _showNoteTab = value);
}
Future<void> _toggleNotePlusTab(bool value) async {
await _userPrefs.setShowNotePlusTab(value);
setState(() => _showNotePlusTab = value);
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(title: const Text('主界面设置')),
body: ListView(
children: [
_buildSectionHeader('启动设置'),
_buildDefaultTabSelector(),
Divider(
height: 0.5,
indent: 24,
endIndent: 24,
color: colors.outlineVariant),
_buildSectionHeader('模块开关'),
_buildSwitchItem(Icons.movie_outlined, '观影', '记录和管理观影记录',
_showMovieTab, _toggleMovieTab),
Divider(
height: 0.5,
indent: 24,
endIndent: 24,
color: colors.outlineVariant),
_buildSwitchItem(Icons.menu_book_outlined, '阅读', '记录和管理阅读记录',
_showBookTab, _toggleBookTab),
Divider(
height: 0.5,
indent: 24,
endIndent: 24,
color: colors.outlineVariant),
_buildSwitchItem(Icons.note_outlined, '笔记', '记录和管理笔记', _showNoteTab,
_toggleNoteTab),
Divider(
height: 0.5,
indent: 24,
endIndent: 24,
color: colors.outlineVariant),
_buildSwitchItem(Icons.edit_note, 'Note Plus', '块编辑器,支持富文本文档',
_showNotePlusTab, _toggleNotePlusTab),
Divider(
height: 0.5,
indent: 24,
endIndent: 24,
color: colors.outlineVariant),
Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10),
child: Text('至少保留一个模块,关闭后对应标签页将不再显示。',
style: TextStyle(
fontSize: 11,
color: colors.onSurface.withValues(alpha: 0.3))),
),
],
),
);
}
Widget _buildSectionHeader(String title) {
final colors = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 8),
child: Text(title,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: colors.onSurface)),
);
}
Widget _buildSwitchItem(IconData icon, String title, String subtitle,
bool value, ValueChanged<bool> onChanged) {
final colors = Theme.of(context).colorScheme;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
leading: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10)),
child: Icon(icon,
color: colors.onSurface.withValues(alpha: 0.6), size: 18)),
title: Text(title,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: colors.onSurface)),
subtitle: Text(subtitle,
style: TextStyle(
fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))),
trailing: Switch(
value: value,
onChanged: onChanged,
activeColor: colors.primary,
activeTrackColor: colors.primary.withOpacity(0.3),
inactiveThumbColor: colors.surface,
inactiveTrackColor: colors.outline),
);
}
Widget _buildDefaultTabSelector() {
final colors = Theme.of(context).colorScheme;
final labels = ['影视', '阅读', '笔记'];
final icons = [
Icons.movie_outlined,
Icons.menu_book_outlined,
Icons.note_outlined
];
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
leading: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10)),
child: Icon(Icons.home_outlined,
color: colors.onSurface.withValues(alpha: 0.6), size: 18)),
title: Text('默认启动标签',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: colors.onSurface)),
subtitle: Text('打开应用时默认显示的页面',
style: TextStyle(
fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(labels[_defaultTabIndex],
style: TextStyle(
fontSize: 14,
color: colors.onSurface.withValues(alpha: 0.4))),
const SizedBox(width: 4),
Icon(Icons.chevron_right,
color: colors.onSurface.withValues(alpha: 0.25), size: 20),
],
),
onTap: () => _showDefaultTabPicker(labels, icons),
);
}
void _showDefaultTabPicker(List<String> labels, List<IconData> icons) {
final colors = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (ctx) => Container(
decoration: BoxDecoration(
color: colors.surface,
borderRadius:
const BorderRadius.vertical(top: Radius.circular(16))),
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: colors.onSurface.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(2))),
const SizedBox(height: 20),
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text('默认启动标签',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: colors.onSurface)))),
const SizedBox(height: 16),
for (int i = 0; i < labels.length; i++)
ListTile(
leading: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10)),
child: Icon(icons[i],
color: colors.onSurface.withValues(alpha: 0.6))),
title: Text(labels[i],
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: colors.onSurface)),
trailing: _defaultTabIndex == i
? Icon(Icons.check, color: colors.onSurface, size: 20)
: null,
onTap: () async {
await _userPrefs.setDefaultMainTabIndex(i);
setState(() => _defaultTabIndex = i);
Navigator.pop(ctx);
},
),
const SizedBox(height: 8),
],
),
),
);
}
}
// ─── 布局设置 ──────────────────────────────────────────────────────────
class LayoutSettingsPage extends StatefulWidget {
const LayoutSettingsPage({super.key});
@override
State<LayoutSettingsPage> createState() => _LayoutSettingsPageState();
}
class _LayoutSettingsPageState extends State<LayoutSettingsPage> {
final UserPrefs _userPrefs = UserPrefs();
int _noteLayout = 0;
int _movieLayout = 0;
int _bookLayout = 0;
@override
void initState() {
super.initState();
_noteLayout = _userPrefs.noteLayoutStyle;
_movieLayout = _userPrefs.movieLayoutStyle;
_bookLayout = _userPrefs.bookLayoutStyle;
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(title: const Text('布局设置')),
body: ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: [
_buildSection(
'影视布局',
[
ButtonSegment(
value: 0,
icon: Icon(Icons.grid_view_outlined, size: 16),
label: Text('海报网格', style: TextStyle(fontSize: 12))),
ButtonSegment(
value: 1,
icon: Icon(Icons.view_list_outlined, size: 16),
label: Text('列表', style: TextStyle(fontSize: 12))),
ButtonSegment(
value: 2,
icon: Icon(Icons.crop_landscape_outlined, size: 16),
label: Text('大图卡片', style: TextStyle(fontSize: 12))),
],
_movieLayout,
(v) => _setLayout('movie', v)),
_buildSection(
'阅读布局',
[
ButtonSegment(
value: 0,
icon: Icon(Icons.grid_view_outlined, size: 16),
label: Text('封面网格', style: TextStyle(fontSize: 12))),
ButtonSegment(
value: 1,
icon: Icon(Icons.view_list_outlined, size: 16),
label: Text('列表', style: TextStyle(fontSize: 12))),
],
_bookLayout,
(v) => _setLayout('book', v)),
_buildSection(
'笔记布局',
[
ButtonSegment(
value: 0,
icon: Icon(Icons.view_list_outlined, size: 16),
label: Text('列表', style: TextStyle(fontSize: 12))),
ButtonSegment(
value: 1,
icon: Icon(Icons.grid_view_outlined, size: 16),
label: Text('瀑布流', style: TextStyle(fontSize: 12))),
ButtonSegment(
value: 2,
icon: Icon(Icons.timeline_outlined, size: 16),
label: Text('时间线', style: TextStyle(fontSize: 12))),
],
_noteLayout,
(v) => _setLayout('note', v)),
],
),
);
}
void _setLayout(String type, int value) async {
switch (type) {
case 'note':
await _userPrefs.setNoteLayoutStyle(value);
setState(() => _noteLayout = value);
case 'movie':
await _userPrefs.setMovieLayoutStyle(value);
setState(() => _movieLayout = value);
if (mounted) context.read<AppProvider>().setMovieLayoutStyle(value);
case 'book':
await _userPrefs.setBookLayoutStyle(value);
setState(() => _bookLayout = value);
}
}
Widget _buildSection(String title, List<ButtonSegment<int>> segments,
int selected, ValueChanged<int> onChanged) {
final colors = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 8),
child: Text(title,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: colors.onSurface)),
),
SegmentedButton<int>(
segments: segments,
selected: {selected},
onSelectionChanged: (s) => onChanged(s.first),
showSelectedIcon: false,
style: ButtonStyle(
backgroundColor: WidgetStateProperty.resolveWith((s) =>
s.contains(WidgetState.selected)
? colors.onSurface
: colors.surfaceContainerHighest),
foregroundColor: WidgetStateProperty.resolveWith((s) =>
s.contains(WidgetState.selected)
? colors.surface
: colors.onSurface.withValues(alpha: 0.6)),
side: WidgetStateProperty.all(BorderSide.none),
shape: WidgetStateProperty.all(RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10))),
padding: WidgetStateProperty.all(
const EdgeInsets.symmetric(vertical: 10)),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.standard,
),
),
],
),
);
}
}
/// WebView 页面
class WebViewPage extends StatefulWidget {
final String url;
const WebViewPage({super.key, required this.url});
@override
State<WebViewPage> createState() => _WebViewPageState();
}
class _WebViewPageState extends State<WebViewPage> {
late final WebViewController _controller;
bool _isLoading = true;
@override
void initState() {
super.initState();
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(NavigationDelegate(
onPageStarted: (_) => setState(() => _isLoading = true),
onPageFinished: (_) => setState(() => _isLoading = false),
onWebResourceError: (_) => setState(() => _isLoading = false),
))
..loadRequest(Uri.parse(widget.url));
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(title: const Text(''), actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => _controller.reload())
]),
body: Stack(children: [
WebViewWidget(controller: _controller),
if (_isLoading)
Center(
child: CircularProgressIndicator(
color: colors.onSurface.withValues(alpha: 0.4)))
]),
);
}
}