书籍接口与界面

This commit is contained in:
DelLevin-Home
2026-06-24 19:51:35 +08:00
parent e4ed73d604
commit 5b7823cb78
20 changed files with 4552 additions and 361 deletions

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,755 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
import 'package:provider/provider.dart';
import 'package:uuid/uuid.dart';
import '../utils/server_config.dart';
import '../utils/user_prefs.dart';
import '../models/data_models.dart';
import '../providers/app_provider.dart';
import '../utils/image_path_helper.dart';
import '../utils/toast_util.dart';
/// 书籍详情页 - 在线版
class BookDetailPage extends StatefulWidget {
final String bookId;
const BookDetailPage({super.key, required this.bookId});
@override
State<BookDetailPage> createState() => _BookDetailPageState();
}
class _BookDetailPageState extends State<BookDetailPage> {
Map<String, dynamic>? _data;
bool _loading = true;
String? _error;
Book? _localBook;
String? _catalog;
bool _catalogLoading = false;
int _currentTab = 0;
@override
void initState() {
super.initState();
_load();
}
String _resolveCoverUrl(String cover) {
if (cover.startsWith('http')) return cover;
return '${ServerConfig.vipBaseUrl}/mk_book$cover';
}
Future<void> _load() async {
final token = UserPrefs().bookSearchToken;
try {
final url = '${ServerConfig.vipBaseUrl}/api/book/detail?id=${widget.bookId}&token=$token';
final resp = await http.get(Uri.parse(url)).timeout(const Duration(seconds: 10));
if (!mounted) return;
if (resp.statusCode == 200) {
final json_ = json.decode(resp.body);
if (json_['code'] == 0 && json_['data'] != null) {
setState(() {
_data = json_['data'];
_loading = false;
});
_checkLocal();
_loadCatalog();
return;
}
}
setState(() {
_error = '加载失败';
_loading = false;
});
} catch (_) {
if (mounted) setState(() {
_error = '网络错误';
_loading = false;
});
}
}
void _checkLocal() {
final title = _data?['title'] ?? '';
if (title.toString().isEmpty) return;
final provider = context.read<AppProvider>();
final match = provider.books.where((b) => !b.isDeleted && b.title == title).toList();
if (match.isNotEmpty) {
setState(() => _localBook = match.first);
}
}
String _decodeText(List<int> bytes) {
if (bytes.length >= 2) {
// UTF-16 LE BOM: FF FE
if (bytes[0] == 0xFF && bytes[1] == 0xFE) {
final codes = <int>[];
for (var i = 2; i + 1 < bytes.length; i += 2) {
codes.add(bytes[i] | (bytes[i + 1] << 8));
}
return String.fromCharCodes(codes);
}
// UTF-16 BE BOM: FE FF
if (bytes[0] == 0xFE && bytes[1] == 0xFF) {
final codes = <int>[];
for (var i = 2; i + 1 < bytes.length; i += 2) {
codes.add((bytes[i] << 8) | bytes[i + 1]);
}
return String.fromCharCodes(codes);
}
}
// 无 BOM按 UTF-16 LE 尝试(大部分中文 txt 是这种)
if (bytes.length >= 2 && bytes.length % 2 == 0) {
final codes = <int>[];
for (var i = 0; i + 1 < bytes.length; i += 2) {
codes.add(bytes[i] | (bytes[i + 1] << 8));
}
final text = String.fromCharCodes(codes);
// 检查解码结果是否包含大量不可打印字符(说明不是 UTF-16
final printable = text.runes.where((r) => r >= 0x20 && r < 0xFFFF).length;
if (printable > text.length * 0.8) return text;
}
// fallback: UTF-8
try {
return utf8.decode(bytes);
} catch (_) {
return String.fromCharCodes(bytes);
}
}
Future<void> _loadCatalog() async {
final bookmark = _data?['bookmark'] ?? '';
if (bookmark.toString().isEmpty) return;
setState(() => _catalogLoading = true);
try {
final bookmarkStr = bookmark.toString();
final bookmarkUrl = bookmarkStr.startsWith('http')
? bookmarkStr
: '${ServerConfig.vipBaseUrl}/mk_book/$bookmarkStr';
final resp = await http.get(
Uri.parse(bookmarkUrl),
headers: {'Accept-Encoding': 'identity'},
).timeout(const Duration(seconds: 10));
if (!mounted) return;
if (resp.statusCode == 200) {
final bytes = resp.bodyBytes;
final text = _decodeText(bytes);
setState(() {
_catalog = text;
_catalogLoading = false;
});
return;
}
} catch (_) {}
if (mounted) setState(() => _catalogLoading = false);
}
Future<void> _addBook(String status) async {
final m = _data!;
final title = m['title'] ?? '';
final author = m['author'] ?? '';
final press = m['press'] ?? '';
final isbn = m['isbn'] ?? '';
final yearStr = m['publishedDate'] ?? '';
final cover = m['cover'] ?? '';
DateTime? publishDate;
if (yearStr.toString().isNotEmpty) {
publishDate = DateTime.tryParse('${yearStr}-01-01');
}
final bookId = const Uuid().v4();
String? coverPath;
if (cover.toString().isNotEmpty) {
try {
final coverUrl = _resolveCoverUrl(cover.toString());
final resp = await http.get(Uri.parse(coverUrl), headers: {
'User-Agent': 'Mozilla/5.0'
}).timeout(const Duration(seconds: 15));
if (resp.statusCode == 200 && resp.bodyBytes.length < 10 * 1024 * 1024) {
final fileName = 'cover_${DateTime.now().millisecondsSinceEpoch}.jpg';
final targetPath = await ImagePathHelper.instance.getBookCoverPath(bookId, fileName);
await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath));
await File(targetPath).writeAsBytes(resp.bodyBytes);
coverPath = targetPath;
}
} catch (_) {}
}
final book = Book(
id: bookId,
title: title.toString(),
coverPath: coverPath,
authors: _splitStr(author.toString()),
publisher: press.toString(),
isbn: isbn.toString(),
publishDate: publishDate,
status: status,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
if (!mounted) return;
final provider = context.read<AppProvider>();
await provider.addBook(book);
if (mounted) {
setState(() {
_localBook = provider.books.firstWhere((b) => b.id == book.id);
});
ToastUtil.show(context, '已添加到${_statusLabel(status)}');
}
}
List<String> _splitStr(String s) => s
.split(RegExp(r'[,/、]'))
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toList();
String _statusLabel(String status) {
switch (status) {
case 'read':
return '已读';
case 'reading':
return '在读';
case 'want_to_read':
return '想读';
default:
return '';
}
}
void _showAddSheet() {
final colors = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
backgroundColor: colors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 16),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Center(
child: Container(
width: 36,
height: 4,
margin: const EdgeInsets.only(bottom: 14),
decoration: BoxDecoration(
color: colors.onSurface.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(2)))),
Text('添加到',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: colors.onSurface)),
const SizedBox(height: 14),
_sheetItem(ctx, colors, Icons.check_circle_outline, '已读', 'read'),
_sheetItem(ctx, colors, Icons.play_circle_outline, '在读', 'reading'),
_sheetItem(ctx, colors, Icons.bookmark_outline, '想读', 'want_to_read'),
]),
),
),
);
}
Widget _sheetItem(BuildContext ctx, ColorScheme colors, IconData icon,
String label, String status) {
return InkWell(
onTap: () {
Navigator.pop(ctx);
_addBook(status);
},
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
margin: const EdgeInsets.only(bottom: 4),
child: Row(children: [
Icon(icon, size: 22, color: colors.onSurface.withValues(alpha: 0.6)),
const SizedBox(width: 12),
Text(label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: colors.onSurface)),
]),
),
);
}
// ── Build ──────────────────────────────────────────────
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colors.surface,
floatingActionButton:
(!_loading && _error == null && _localBook == null && _data != null)
? FloatingActionButton(
onPressed: _showAddSheet,
backgroundColor: colors.primary,
child: Icon(Icons.add, color: colors.onPrimary))
: null,
body: _loading
? Center(child: CircularProgressIndicator(color: colors.primary, strokeWidth: 2))
: _error != null
? _buildError(colors)
: _buildBody(colors),
);
}
Widget _buildError(ColorScheme colors) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 48, color: colors.onSurface.withValues(alpha: 0.2)),
const SizedBox(height: 16),
Text(_error!, style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4))),
const SizedBox(height: 16),
TextButton(
onPressed: () {
setState(() { _loading = true; _error = null; });
_load();
},
child: Text('重试', style: TextStyle(color: colors.primary))),
],
));
}
Widget _buildBody(ColorScheme colors) {
final m = _data!;
final cover = m['cover'] ?? '';
final title = m['title'] ?? '';
final author = m['author'] ?? '';
final press = m['press'] ?? '';
final isbn = m['isbn'] ?? '';
final year = m['publishedDate'] ?? '';
final pages = m['pagination'];
final coverUrl = cover.toString().isNotEmpty ? _resolveCoverUrl(cover.toString()) : '';
return Column(children: [
// 顶部固定区域
Container(
color: colors.surface,
child: SafeArea(
bottom: false,
child: Column(children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(children: [
GestureDetector(
onTap: () => Navigator.pop(context),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
shape: BoxShape.circle),
child: Icon(Icons.arrow_back, size: 20, color: colors.onSurface)),
),
]),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: SizedBox(
width: 110,
height: 160,
child: coverUrl.isNotEmpty
? Image.network(coverUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _coverPlaceholder(colors))
: _coverPlaceholder(colors),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: colors.onSurface)),
if (author.toString().isNotEmpty) ...[
const SizedBox(height: 8),
Text(author, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6))),
],
if (press.toString().isNotEmpty) ...[
const SizedBox(height: 6),
Text(press, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.45))),
],
if (year.toString().isNotEmpty) ...[
const SizedBox(height: 6),
Text('出版年份:$year', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
],
if (isbn.toString().isNotEmpty) ...[
const SizedBox(height: 4),
Text('ISBN$isbn', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
],
if (pages != null && pages != 0) ...[
const SizedBox(height: 4),
Text('页数:$pages', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
],
if (_localBook != null) ...[
const SizedBox(height: 10),
_buildLocalStatus(colors),
],
]),
),
]),
),
]),
),
),
// Tab 栏
Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: colors.outlineVariant, width: 0.5))),
child: Row(children: [
_buildTabButton('基础信息', 0),
_buildTabButton('国图信息', 1),
_buildTabButton('网购地址', 2),
_buildTabButton('书籍目录', 3),
]),
),
// 内容区
Expanded(
child: _currentTab == 0
? _buildBasicInfo(colors)
: _currentTab == 1
? _buildOpacTab(colors)
: _currentTab == 2
? _buildOnlineTab(colors)
: _buildCatalogTab(colors),
),
]);
}
Widget _buildTabButton(String label, int index) {
final colors = Theme.of(context).colorScheme;
final selected = _currentTab == index;
return GestureDetector(
onTap: () => setState(() => _currentTab = index),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 11),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: selected ? colors.primary : Colors.transparent,
width: 2))),
child: Text(label,
style: TextStyle(
fontSize: 13,
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
color: selected
? colors.primary
: colors.onSurface.withValues(alpha: 0.4))),
),
);
}
// ── 基础信息 Tab ──────────────────────────────────────────
Widget _buildBasicInfo(ColorScheme colors) {
final m = _data!;
final tags = m['tags'] ?? '';
final sub1 = m['sub1'] ?? '';
final sub2 = m['sub2'] ?? '';
return ListView(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 40),
children: [
// 分类
_buildSectionTitle(colors, '分类', Icons.category_outlined),
const SizedBox(height: 6),
if (sub1.toString().isNotEmpty)
_buildChipWrap(colors, sub1.toString().split(RegExp(r'[,]')))
else
_buildEmptyHint(colors),
const SizedBox(height: 16),
// 标签
_buildSectionTitle(colors, '标签', Icons.sell_outlined),
const SizedBox(height: 6),
if (tags.toString().isNotEmpty)
_buildChipWrap(colors, tags.toString().split(RegExp(r'[,]')))
else
_buildEmptyHint(colors),
const SizedBox(height: 16),
// 内容简介
_buildSectionTitle(colors, '内容简介', Icons.article_outlined),
const SizedBox(height: 8),
if (sub2.toString().isNotEmpty)
Text(sub2.toString().replaceAll(RegExp(r'<[^>]*>'), ''),
style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.7), height: 1.7))
else
_buildEmptyHint(colors),
],
);
}
Widget _buildEmptyHint(ColorScheme colors) {
return Text('暂无该信息数据',
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.3)));
}
// ── 国图信息 Tab ──────────────────────────────────────────
Widget _buildOpacTab(ColorScheme colors) {
final opacStr = _data?['opacInfo'];
Map<String, dynamic>? opac;
if (opacStr != null && opacStr.toString().isNotEmpty) {
try { opac = json.decode(opacStr.toString()); } catch (_) {}
}
if (opac == null) {
return Center(
child: Text('暂无国图信息',
style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.35))));
}
return ListView(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 40),
children: [
_buildOpacInfo(colors, opac),
],
);
}
// ── 网购地址 Tab ──────────────────────────────────────────
Widget _buildOnlineTab(ColorScheme colors) {
final onlineStr = _data?['online'];
List<Map<String, dynamic>> links = [];
if (onlineStr != null && onlineStr.toString().isNotEmpty) {
try {
final list = json.decode(onlineStr.toString()) as List;
links = list.map((e) => e as Map<String, dynamic>).toList();
} catch (_) {}
}
if (links.isEmpty) {
return Center(
child: Text('暂无网购地址',
style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.35))));
}
return ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 40),
itemCount: links.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) => _buildOnlineLink(colors, links[index]),
);
}
// ── 书籍目录 Tab ──────────────────────────────────────────
Widget _buildCatalogTab(ColorScheme colors) {
if (_catalogLoading) {
return Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: colors.primary)));
}
if (_catalog == null || _catalog!.isEmpty) {
return Center(
child: Text('暂无目录信息',
style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.35))));
}
final lines = _catalog!.split('\n').where((l) => l.trim().isNotEmpty).toList();
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 40),
itemCount: lines.length,
itemBuilder: (context, index) {
final text = lines[index].trim();
final level = _detectLevel(text);
final indent = level * 16.0;
final isMain = level == 0;
return Padding(
padding: EdgeInsets.only(left: 8 + indent, top: isMain ? 10 : 4, bottom: isMain ? 2 : 1),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isMain)
Container(
width: 3, height: 14,
margin: const EdgeInsets.only(right: 8, top: 2),
decoration: BoxDecoration(
color: colors.primary.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(1.5),
),
),
Expanded(
child: Text(text, style: TextStyle(
fontSize: isMain ? 13.5 : 12.5,
fontWeight: isMain ? FontWeight.w600 : FontWeight.w400,
color: isMain ? colors.onSurface.withValues(alpha: 0.85) : colors.onSurface.withValues(alpha: 0.55),
height: 1.4,
)),
),
],
),
);
},
);
}
// ── 通用组件 ──────────────────────────────────────────────
Widget _buildSectionTitle(ColorScheme colors, String title, IconData icon) {
return Row(
children: [
Icon(icon, size: 15, color: colors.primary.withValues(alpha: 0.7)),
const SizedBox(width: 6),
Text(title, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface)),
],
);
}
Widget _buildChipWrap(ColorScheme colors, List<String> items) {
return Wrap(
spacing: 6,
runSpacing: 6,
children: items.where((t) => t.trim().isNotEmpty).map((t) => Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: colors.outlineVariant, width: 0.5),
),
child: Text(t.trim(), style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.65))),
)).toList(),
);
}
Widget _buildOpacInfo(ColorScheme colors, Map<String, dynamic> opac) {
final items = <List<String>>[];
if (opac['title'] != null && opac['title'].toString().isNotEmpty) items.add(['题名', opac['title'].toString()]);
if (opac['authors'] != null) {
final authors = (opac['authors'] as List).map((e) => e.toString()).join('');
if (authors.isNotEmpty) items.add(['作者', authors]);
}
if (opac['publisher'] != null && opac['publisher'].toString().isNotEmpty) items.add(['出版社', opac['publisher'].toString()]);
if (opac['pubdate'] != null && opac['pubdate'].toString().isNotEmpty) items.add(['出版日期', opac['pubdate'].toString()]);
if (opac['isbn'] != null && opac['isbn'].toString().isNotEmpty) items.add(['ISBN', opac['isbn'].toString()]);
if (opac['clc'] != null && opac['clc'].toString().isNotEmpty) items.add(['中图分类号', opac['clc'].toString()]);
if (opac['tags'] != null && opac['tags'].toString().isNotEmpty) items.add(['主题词', opac['tags'].toString()]);
if (items.isEmpty) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10),
),
child: Column(
children: items.map((pair) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 72,
child: Text(pair[0], style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
),
Expanded(
child: Text(pair[1], style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.75), height: 1.4)),
),
],
),
)).toList(),
),
);
}
Widget _buildOnlineLink(ColorScheme colors, Map<String, dynamic> link) {
final source = link['source'] ?? '';
final url = link['url'] ?? '';
if (source.toString().isEmpty || url.toString().isEmpty) return const SizedBox.shrink();
return GestureDetector(
onTap: () => launchUrl(Uri.parse(url.toString()), mode: LaunchMode.externalApplication),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Icon(Icons.link, size: 16, color: colors.primary.withValues(alpha: 0.6)),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(source.toString(), style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface)),
const SizedBox(height: 2),
Text(url.toString(), style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.35)), maxLines: 1, overflow: TextOverflow.ellipsis),
],
),
),
Icon(Icons.chevron_right, size: 16, color: colors.onSurface.withValues(alpha: 0.3)),
],
),
),
);
}
/// 检测目录层级0=主章节, 1=子章节
int _detectLevel(String line) {
// 主章节第X章、第X篇、Chapter X、数字+点开头(如 "1. "
if (RegExp(r'^第[一二三四五六七八九十百千\d]+[章篇部回卷]').hasMatch(line)) return 0;
if (RegExp(r'^Chapter\s+\d+', caseSensitive: false).hasMatch(line)) return 0;
if (RegExp(r'^\d+[\.\s、]').hasMatch(line)) return 0;
if (RegExp(r'^[一二三四五六七八九十]+[、..]').hasMatch(line)) return 0;
// 子章节第X节、数字.数字(如 "1.1 "
if (RegExp(r'^第[一二三四五六七八九十百千\d]+[节]').hasMatch(line)) return 1;
if (RegExp(r'^\d+\.\d+[\.\s、]').hasMatch(line)) return 1;
if (RegExp(r'^[(]\d+[)]').hasMatch(line)) return 1;
if (line.startsWith(' ') || line.startsWith('\t')) return 1;
// 默认主章节
return 0;
}
Widget _coverPlaceholder(ColorScheme colors) {
return Container(
color: colors.surfaceContainerHighest,
child: Center(
child: Icon(Icons.menu_book_outlined,
size: 32, color: colors.onSurface.withValues(alpha: 0.15))));
}
Widget _buildLocalStatus(ColorScheme colors) {
final status = _localBook!.status;
final label = _statusLabel(status);
Color dotColor;
switch (status) {
case 'read':
dotColor = colors.primary;
break;
case 'reading':
dotColor = const Color(0xFF666666);
break;
default:
dotColor = const Color(0xFF999999);
break;
}
return Row(children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle)),
const SizedBox(width: 6),
Text('已在本地 · $label',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: colors.onSurface.withValues(alpha: 0.6))),
]);
}
}

View File

@@ -0,0 +1,458 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import '../utils/user_prefs.dart';
import '../utils/server_config.dart';
/// 增强搜索设置页面
class EnhancedSearchSettingsPage extends StatefulWidget {
const EnhancedSearchSettingsPage({super.key});
@override
State<EnhancedSearchSettingsPage> createState() =>
_EnhancedSearchSettingsPageState();
}
class _EnhancedSearchSettingsPageState
extends State<EnhancedSearchSettingsPage> {
final _userPrefs = UserPrefs();
final _movieTokenController = TextEditingController();
final _bookTokenController = TextEditingController();
bool _enabled = false;
// null=未验证/检查中, true=有效, false=无效
bool? _movieTokenValid;
bool? _bookTokenValid;
String? _movieTokenMessage;
String? _bookTokenMessage;
@override
void initState() {
super.initState();
_enabled = _userPrefs.enhancedSearchEnabled;
_movieTokenController.text = _userPrefs.movieSearchToken;
_bookTokenController.text = _userPrefs.bookSearchToken;
if (_enabled) _verifyTokens();
}
@override
void dispose() {
_movieTokenController.dispose();
_bookTokenController.dispose();
super.dispose();
}
Future<void> _verifyTokens() async {
final movieToken = _movieTokenController.text.trim();
final bookToken = _bookTokenController.text.trim();
if (movieToken.isNotEmpty) {
_checkToken(movieToken, 'movie').then((result) async {
if (!mounted) return;
final valid = result != null && result['valid'] == true;
if (valid) {
setState(() {
_movieTokenValid = true;
_movieTokenMessage = result['messageString'] as String?;
});
} else {
// 当前类型失败,用另一种类型重试
final retry = await _checkToken(movieToken, 'book');
if (!mounted) return;
setState(() {
_movieTokenValid = false;
_movieTokenMessage = retry != null && retry['valid'] == true
? '该 Token 可能是书籍类型,请检查是否填错位置'
: ((result != null ? result['messageString'] as String? : null) ?? '验证失败');
});
}
});
}
if (bookToken.isNotEmpty) {
_checkToken(bookToken, 'book').then((result) async {
if (!mounted) return;
final valid = result != null && result['valid'] == true;
if (valid) {
setState(() {
_bookTokenValid = true;
_bookTokenMessage = result['messageString'] as String?;
});
} else {
final retry = await _checkToken(bookToken, 'movie');
if (!mounted) return;
setState(() {
_bookTokenValid = false;
_bookTokenMessage = retry != null && retry['valid'] == true
? '该 Token 可能是影视类型,请检查是否填错位置'
: ((result != null ? result['messageString'] as String? : null) ?? '验证失败');
});
}
});
}
}
Future<Map<String, dynamic>?> _checkToken(String token, String type) async {
try {
final url = '${ServerConfig.vipBaseUrl}/api/token/check?token=$token&type=$type';
final resp =
await http.get(Uri.parse(url)).timeout(const Duration(seconds: 8));
if (resp.statusCode == 200) {
final data = json.decode(resp.body);
if (data['code'] == 0 && data['data'] != null) {
return data['data'] as Map<String, dynamic>;
}
}
} catch (_) {}
return null;
}
Future<void> _toggle(bool value) async {
if (value) {
await _userPrefs.setMovieSearchToken(_movieTokenController.text.trim());
await _userPrefs.setBookSearchToken(_bookTokenController.text.trim());
await _userPrefs.setEnhancedSearchEnabled(true);
setState(() => _enabled = true);
_verifyTokens();
} else {
await _userPrefs.setEnhancedSearchEnabled(false);
setState(() {
_enabled = false;
_movieTokenValid = null;
_bookTokenValid = null;
_movieTokenMessage = null;
_bookTokenMessage = null;
});
}
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(
title: const Text('增强搜索'),
actions: [
if (_enabled)
IconButton(
icon: const Icon(Icons.refresh),
tooltip: '刷新验证',
onPressed: () {
setState(() {
_movieTokenValid = null;
_bookTokenValid = null;
_movieTokenMessage = null;
_bookTokenMessage = null;
});
_verifyTokens();
},
),
],
),
body: ListView(
padding: const EdgeInsets.all(20),
children: [
_buildSwitchRow(colors),
const SizedBox(height: 16),
_buildStatusBanner(colors),
const SizedBox(height: 20),
_buildSectionLabel(colors, '影视增强搜索 Token'),
const SizedBox(height: 8),
_buildTokenInput(
colors: colors,
controller: _movieTokenController,
hint: '输入影视搜索 Token',
valid: _movieTokenValid,
message: _movieTokenMessage,
),
const SizedBox(height: 16),
_buildSectionLabel(colors, '书籍增强搜索 Token'),
const SizedBox(height: 8),
_buildTokenInput(
colors: colors,
controller: _bookTokenController,
hint: '输入书籍搜索 Token',
valid: _bookTokenValid,
message: _bookTokenMessage,
),
const SizedBox(height: 28),
_buildSaveButton(colors),
const SizedBox(height: 32),
_buildTips(colors),
],
),
);
}
Widget _buildSwitchRow(ColorScheme colors) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: colors.outlineVariant, width: 0.5),
),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: _enabled
? colors.primary.withValues(alpha: 0.1)
: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.manage_search,
size: 18,
color: _enabled
? colors.primary
: colors.onSurface.withValues(alpha: 0.5)),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('增强搜索',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: colors.onSurface)),
const SizedBox(height: 1),
Text(_enabled ? '已开启' : '未开启',
style: TextStyle(
fontSize: 11,
color: colors.onSurface.withValues(alpha: 0.4))),
],
),
),
Switch(
value: _enabled,
onChanged: _toggle,
activeThumbColor: colors.primary,
activeTrackColor: colors.primary.withValues(alpha: 0.3),
inactiveThumbColor: colors.surface,
inactiveTrackColor: colors.outline,
),
],
),
);
}
Widget _buildStatusBanner(ColorScheme colors) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: _enabled
? const Color(0xFF16A34A).withValues(alpha: 0.08)
: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: _enabled
? const Color(0xFF16A34A).withValues(alpha: 0.3)
: colors.outlineVariant,
width: 0.5,
),
),
child: Row(
children: [
Icon(
_enabled ? Icons.check_circle_outline : Icons.info_outline,
size: 18,
color: _enabled
? const Color(0xFF16A34A)
: colors.onSurface.withValues(alpha: 0.4),
),
const SizedBox(width: 10),
Expanded(
child: Text(
_enabled ? '增强搜索已开启' : '填写 Token 后开启增强搜索',
style: TextStyle(
fontSize: 13,
color: _enabled
? const Color(0xFF16A34A)
: colors.onSurface.withValues(alpha: 0.6),
),
),
),
],
),
);
}
Widget _buildSectionLabel(ColorScheme colors, String text) {
return Text(text,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: colors.onSurface.withValues(alpha: 0.5)));
}
Widget _buildTokenInput({
required ColorScheme colors,
required TextEditingController controller,
required String hint,
bool? valid,
String? message,
}) {
return Column(
children: [
Container(
height: 40,
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: colors.outlineVariant, width: 0.5),
),
child: Row(
children: [
const SizedBox(width: 12),
Icon(Icons.key,
size: 16, color: colors.onSurface.withValues(alpha: 0.3)),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: controller,
style: TextStyle(fontSize: 13, color: colors.onSurface),
decoration: InputDecoration(
hintText: hint,
hintStyle: TextStyle(
fontSize: 13,
color: colors.onSurface.withValues(alpha: 0.3)),
isDense: true,
contentPadding: EdgeInsets.zero,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
filled: false,
),
),
),
const SizedBox(width: 12),
],
),
),
if (_enabled && valid != null)
Padding(
padding: const EdgeInsets.only(top: 6, left: 4),
child: Row(
children: [
Icon(
valid ? Icons.check_circle : Icons.cancel,
size: 14,
color: valid ? const Color(0xFF16A34A) : colors.error,
),
const SizedBox(width: 6),
Expanded(
child: Text(
message ?? (valid ? 'Token 有效' : 'Token 无效'),
style: TextStyle(
fontSize: 11,
color: valid ? const Color(0xFF16A34A) : colors.error),
),
),
],
),
),
if (_enabled && valid == null && controller.text.trim().isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 6, left: 4),
child: Row(
children: [
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 1.5,
color: colors.onSurface.withValues(alpha: 0.3))),
const SizedBox(width: 6),
Text('验证中...',
style: TextStyle(
fontSize: 11,
color: colors.onSurface.withValues(alpha: 0.4))),
],
),
),
],
);
}
Future<void> _saveAndVerify() async {
await _userPrefs.setMovieSearchToken(_movieTokenController.text.trim());
await _userPrefs.setBookSearchToken(_bookTokenController.text.trim());
setState(() {
_movieTokenValid = null;
_bookTokenValid = null;
_movieTokenMessage = null;
_bookTokenMessage = null;
});
_verifyTokens();
}
Widget _buildSaveButton(ColorScheme colors) {
return GestureDetector(
onTap: _saveAndVerify,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: colors.primary,
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: Text('保存并验证', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: colors.onPrimary)),
),
),
);
}
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: 11,
fontWeight: FontWeight.w500,
color: colors.onSurface.withValues(alpha: 0.4),
letterSpacing: 0.5)),
const SizedBox(height: 10),
_tip(colors, '增强搜索可在线检索影视和书籍的详细信息'),
_tip(colors, 'Token 过期或失效后需重新获取并填写'),
_tip(colors, '作者会在 QQ 群不定期发放增强搜索的token'),
],
),
);
}
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: 7),
child: Icon(Icons.circle,
size: 4, color: colors.onSurface.withValues(alpha: 0.25)),
),
const SizedBox(width: 8),
Expanded(
child: Text(text,
style: TextStyle(
fontSize: 12,
color: colors.onSurface.withValues(alpha: 0.5),
height: 1.5))),
],
),
);
}
}

120
lib/pages/legal_page.dart Normal file
View File

@@ -0,0 +1,120 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:http/http.dart' as http;
import '../utils/server_config.dart';
/// 用户服务协议 / 隐私政策查看页面
class LegalPage extends StatefulWidget {
final String slug;
final String title;
const LegalPage({super.key, required this.slug, required this.title});
@override
State<LegalPage> createState() => _LegalPageState();
}
class _LegalPageState extends State<LegalPage> {
String _content = '';
bool _isLoading = true;
String? _error;
static final String _baseUrl = ServerConfig.baseUrl;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final resp = await http.get(
Uri.parse('$_baseUrl/api/pages/${widget.slug}'),
);
if (!mounted) return;
if (resp.statusCode == 200) {
final data = json.decode(resp.body);
setState(() {
_content = data['content'] ?? '';
_isLoading = false;
});
} else {
setState(() {
_error = '暂无内容';
_isLoading = false;
});
}
} catch (e) {
if (!mounted) return;
setState(() {
_error = '加载失败,请检查网络';
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(title: Text(widget.title)),
body: _isLoading
? Center(child: CircularProgressIndicator(color: colors.primary))
: _error != null
? _buildError(colors)
: _buildContent(colors),
);
}
Widget _buildContent(ColorScheme colors) {
return Markdown(
data: _content,
padding: const EdgeInsets.fromLTRB(20, 8, 20, 40),
styleSheet: MarkdownStyleSheet(
h1: TextStyle(fontSize: 22, fontWeight: FontWeight.w600, color: colors.onSurface, height: 1.4),
h2: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface, height: 1.4),
h3: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface, height: 1.4),
p: TextStyle(fontSize: 14, color: colors.onSurface, height: 1.8),
code: TextStyle(fontSize: 13, color: colors.onSurface, backgroundColor: colors.surfaceContainerHighest),
codeblockDecoration: BoxDecoration(
color: colors.surfaceContainerHighest,
border: Border.all(color: colors.outline),
borderRadius: BorderRadius.circular(6),
),
codeblockPadding: const EdgeInsets.all(12),
blockquote: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), fontStyle: FontStyle.italic),
blockquoteDecoration: BoxDecoration(
border: Border(left: BorderSide(color: colors.onSurface.withValues(alpha: 0.4), width: 4)),
),
blockquotePadding: const EdgeInsets.only(left: 12),
listBullet: TextStyle(fontSize: 14, color: colors.onSurface),
listIndent: 24,
a: const TextStyle(fontSize: 14, color: Color(0xFF4A90D9), decoration: TextDecoration.underline),
horizontalRuleDecoration: BoxDecoration(
border: Border(top: BorderSide(color: colors.outline, width: 0.5)),
),
),
);
}
Widget _buildError(ColorScheme colors) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.article_outlined, size: 48, color: colors.onSurface.withValues(alpha: 0.25)),
const SizedBox(height: 16),
Text(_error!, style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4))),
const SizedBox(height: 16),
TextButton(
onPressed: () { setState(() { _isLoading = true; _error = null; }); _load(); },
child: Text('重试', style: TextStyle(color: colors.primary)),
),
],
),
);
}
}

View File

@@ -8,6 +8,7 @@ import 'movies/movie_tab_page.dart';
import 'book/book_tab_page.dart'; import 'book/book_tab_page.dart';
import 'note/note_tab_page.dart'; import 'note/note_tab_page.dart';
import 'search_page.dart'; import 'search_page.dart';
import 'online_search_page.dart';
import 'sync/webdav_sync_page.dart'; import 'sync/webdav_sync_page.dart';
/// 主内容页 - 观影/阅读/笔记标签页PageView 滑动切换) /// 主内容页 - 观影/阅读/笔记标签页PageView 滑动切换)
@@ -88,14 +89,41 @@ class _MainContentPageState extends State<MainContentPage> {
Widget _buildAppBar(BuildContext context) { Widget _buildAppBar(BuildContext context) {
return Consumer<AppProvider>( return Consumer<AppProvider>(
builder: (context, provider, child) { builder: (context, provider, child) {
final colors = Theme.of(context).colorScheme;
return AppBar( return AppBar(
titleSpacing: 8,
leadingWidth: 44,
title: Text(_getAppBarTitle(provider)), title: Text(_getAppBarTitle(provider)),
actionsPadding: const EdgeInsets.only(right: 4),
actions: [ actions: [
_buildCloudSyncButton(context), _buildCloudSyncButton(context),
IconButton( IconButton(
icon: const Icon(Icons.search), icon: const Icon(Icons.search),
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const SearchPage())), onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const SearchPage())),
), ),
if (UserPrefs().enhancedSearchEnabled)
IconButton(
icon: Stack(
clipBehavior: Clip.none,
children: [
const Icon(Icons.search, size: 22),
Positioned(
right: -3,
top: -3,
child: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: colors.surface,
shape: BoxShape.circle,
),
child: Icon(Icons.add, size: 10, color: colors.onSurface),
),
),
],
),
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const OnlineSearchPage())),
),
], ],
); );
}, },
@@ -111,6 +139,7 @@ class _MainContentPageState extends State<MainContentPage> {
} }
} }
// ─── 云备份 ────────────────────────────────────────── // ─── 云备份 ──────────────────────────────────────────
Widget _buildCloudSyncButton(BuildContext context) { Widget _buildCloudSyncButton(BuildContext context) {
@@ -133,18 +162,18 @@ class _MainContentPageState extends State<MainContentPage> {
final bc = Theme.of(ctx).colorScheme; final bc = Theme.of(ctx).colorScheme;
return SafeArea( return SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), padding: const EdgeInsets.fromLTRB(16, 6, 16, 16),
child: Column(mainAxisSize: MainAxisSize.min, children: [ child: Column(mainAxisSize: MainAxisSize.min, children: [
Center(child: Container( Center(child: Container(
width: 40, height: 4, margin: const EdgeInsets.only(bottom: 20), width: 36, height: 4, margin: const EdgeInsets.only(bottom: 14),
decoration: BoxDecoration(color: bc.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2)), decoration: BoxDecoration(color: bc.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2)),
)), )),
Text('云备份', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: bc.onSurface)), Text('云备份', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: bc.onSurface)),
const SizedBox(height: 20), const SizedBox(height: 14),
_cloudCard(icon: Icons.cloud_upload_outlined, title: '上传数据', desc: hasConfig ? '将本地数据同步到云端' : '请先配置 WebDAV 服务器', enabled: hasConfig, onTap: hasConfig ? () { Navigator.pop(ctx); _performSync(context, SyncDirection.upload); } : null, colors: bc), _cloudCard(icon: Icons.cloud_upload_outlined, title: '上传数据', desc: hasConfig ? '将本地数据同步到云端' : '请先配置 WebDAV 服务器', enabled: hasConfig, onTap: hasConfig ? () { Navigator.pop(ctx); _performSync(context, SyncDirection.upload); } : null, colors: bc),
const SizedBox(height: 12), const SizedBox(height: 8),
_cloudCard(icon: Icons.cloud_download_outlined, title: '下载数据', desc: hasConfig ? '从云端恢复数据到本地' : '请先配置 WebDAV 服务器', enabled: hasConfig, onTap: hasConfig ? () { Navigator.pop(ctx); _performSync(context, SyncDirection.download); } : null, colors: bc), _cloudCard(icon: Icons.cloud_download_outlined, title: '下载数据', desc: hasConfig ? '从云端恢复数据到本地' : '请先配置 WebDAV 服务器', enabled: hasConfig, onTap: hasConfig ? () { Navigator.pop(ctx); _performSync(context, SyncDirection.download); } : null, colors: bc),
const SizedBox(height: 12), const SizedBox(height: 8),
_cloudCard(icon: Icons.settings_outlined, title: 'WebDAV 设置', desc: '配置服务器地址与认证信息', enabled: true, onTap: () { Navigator.pop(ctx); Navigator.push(context, MaterialPageRoute(builder: (_) => const WebDAVSyncPage())); }, colors: bc), _cloudCard(icon: Icons.settings_outlined, title: 'WebDAV 设置', desc: '配置服务器地址与认证信息', enabled: true, onTap: () { Navigator.pop(ctx); Navigator.push(context, MaterialPageRoute(builder: (_) => const WebDAVSyncPage())); }, colors: bc),
]), ]),
), ),
@@ -157,19 +186,19 @@ class _MainContentPageState extends State<MainContentPage> {
return GestureDetector( return GestureDetector(
onTap: onTap, onTap: onTap,
child: Container( child: Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: enabled ? colors.primary.withValues(alpha: 0.04) : colors.surfaceContainerHighest, color: enabled ? colors.primary.withValues(alpha: 0.04) : colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(10),
border: Border.all(color: enabled ? colors.primary.withValues(alpha: 0.1) : colors.outlineVariant, width: 0.5), border: Border.all(color: enabled ? colors.primary.withValues(alpha: 0.1) : colors.outlineVariant, width: 0.5),
), ),
child: Row(children: [ child: Row(children: [
Container(width: 48, height: 48, decoration: BoxDecoration(color: enabled ? colors.primary.withValues(alpha: 0.08) : colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(12)), child: Icon(icon, size: 24, color: enabled ? colors.primary : colors.onSurface.withValues(alpha: 0.18))), Container(width: 36, height: 36, decoration: BoxDecoration(color: enabled ? colors.primary.withValues(alpha: 0.08) : colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(icon, size: 20, color: enabled ? colors.primary : colors.onSurface.withValues(alpha: 0.18))),
const SizedBox(width: 16), const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(title, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: enabled ? colors.onSurface : colors.onSurface.withValues(alpha: 0.25))), Text(title, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: enabled ? colors.onSurface : colors.onSurface.withValues(alpha: 0.25))),
const SizedBox(height: 2), const SizedBox(height: 1),
Text(desc, style: TextStyle(fontSize: 12, color: enabled ? colors.onSurface.withValues(alpha: 0.4) : colors.onSurface.withValues(alpha: 0.2))), Text(desc, style: TextStyle(fontSize: 11, color: enabled ? colors.onSurface.withValues(alpha: 0.4) : colors.onSurface.withValues(alpha: 0.2))),
])), ])),
Icon(Icons.chevron_right, size: 20, color: enabled ? colors.onSurface.withValues(alpha: 0.15) : colors.onSurface.withValues(alpha: 0.08)), Icon(Icons.chevron_right, size: 20, color: enabled ? colors.onSurface.withValues(alpha: 0.15) : colors.onSurface.withValues(alpha: 0.08)),
]), ]),

View File

@@ -0,0 +1,910 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
import 'package:provider/provider.dart';
import 'package:uuid/uuid.dart';
import '../utils/server_config.dart';
import '../utils/user_prefs.dart';
import '../models/data_models.dart';
import '../providers/app_provider.dart';
import '../utils/image_path_helper.dart';
import '../utils/toast_util.dart';
/// 影视详情页 - 在线版
class MovieDetailPage extends StatefulWidget {
final int vodId;
const MovieDetailPage({super.key, required this.vodId});
@override
State<MovieDetailPage> createState() => _MovieDetailPageState();
}
class _MovieDetailPageState extends State<MovieDetailPage> {
Map<String, dynamic>? _data;
List<Map<String, dynamic>> _staffList = [];
bool _loading = true;
bool _staffLoading = false;
String? _error;
bool _expanded = false;
Movie? _localMovie;
int _currentTab = 0;
int _detailStyle = 0; // 0: 紧凑, 1: 沉浸式
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final token = UserPrefs().movieSearchToken;
try {
final url =
'${ServerConfig.vipBaseUrl}/api/movie/detail?vodId=${widget.vodId}&token=$token';
final resp =
await http.get(Uri.parse(url)).timeout(const Duration(seconds: 10));
if (!mounted) return;
if (resp.statusCode == 200) {
final json_ = json.decode(resp.body);
if (json_['code'] == 0 && json_['data'] != null) {
setState(() {
_data = json_['data'];
_loading = false;
});
_loadStaff();
_checkLocal();
return;
}
}
setState(() {
_error = '加载失败';
_loading = false;
});
} catch (_) {
if (mounted)
setState(() {
_error = '网络错误';
_loading = false;
});
}
}
Future<void> _loadStaff() async {
final staffStr = _data?['vod_staff'] ?? '';
if (staffStr.toString().isEmpty) return;
final token = UserPrefs().movieSearchToken;
setState(() {
_staffLoading = true;
});
try {
final url = '${ServerConfig.vipBaseUrl}/api/actor/staff-pic?token=$token';
final resp = await http.post(Uri.parse(url),
body: staffStr.toString(),
headers: {
'Content-Type': 'application/json'
}).timeout(const Duration(seconds: 10));
if (!mounted) return;
if (resp.statusCode == 200) {
final json_ = json.decode(resp.body);
if (json_['code'] == 0 && json_['data'] != null) {
setState(() {
_staffList = (json_['data'] as List)
.map((e) => e as Map<String, dynamic>)
.toList();
_staffLoading = false;
});
return;
}
}
} catch (_) {}
if (mounted)
setState(() {
_staffLoading = false;
});
}
void _checkLocal() {
final name = _data?['vod_name'] ?? '';
if (name.toString().isEmpty) return;
final provider = context.read<AppProvider>();
final match =
provider.movies.where((m) => !m.isDeleted && m.title == name).toList();
if (match.isNotEmpty) {
setState(() {
_localMovie = match.first;
});
}
}
Future<void> _addMovie(String status) async {
final m = _data!;
final name = m['vod_name'] ?? '';
final director = m['vod_director'] ?? '';
final actorStr = m['vod_actor'] ?? '';
final classStr = m['vod_class'] ?? '';
final yearStr = m['vod_year'] ?? '';
final scoreStr = m['vod_score'] ?? '';
final content = m['vod_content'] ?? m['vod_blurb'] ?? '';
final pic = m['vod_pic'] ?? '';
DateTime? releaseDate;
if (yearStr.toString().isNotEmpty) {
releaseDate = DateTime.tryParse('${yearStr}-01-01');
}
final movieId = const Uuid().v4();
String? posterPath;
if (pic.toString().isNotEmpty) {
try {
final resp = await http.get(Uri.parse(pic.toString()), headers: {
'User-Agent': 'Mozilla/5.0'
}).timeout(const Duration(seconds: 15));
if (resp.statusCode == 200 &&
resp.bodyBytes.length < 10 * 1024 * 1024) {
final fileName =
'poster_${DateTime.now().millisecondsSinceEpoch}.jpg';
final targetPath = await ImagePathHelper.instance
.getMoviePosterPath(movieId, fileName);
await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath));
await File(targetPath).writeAsBytes(resp.bodyBytes);
posterPath = targetPath;
}
} catch (_) {}
}
final movie = Movie(
id: movieId,
title: name.toString(),
posterPath: posterPath,
releaseDate: releaseDate,
directors: _splitStr(director.toString()),
actors: _splitStr(actorStr.toString()),
genres: _splitStr(classStr.toString()),
summary: content.toString(),
rating: double.tryParse(scoreStr.toString()),
status: status,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
if (!mounted) return;
final provider = context.read<AppProvider>();
await provider.addMovie(movie);
if (mounted) {
setState(() {
_localMovie = provider.movies.firstWhere((m) => m.id == movie.id);
});
ToastUtil.show(context, '已添加到${_statusLabel(status)}');
}
}
List<String> _splitStr(String s) => s
.split(RegExp(r'[,/、]'))
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toList();
String _statusLabel(String status) {
switch (status) {
case 'watched':
return '已看';
case 'watching':
return '在看';
case 'want_to_watch':
return '想看';
default:
return '';
}
}
void _showAddSheet() {
final colors = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
backgroundColor: colors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 16),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Center(
child: Container(
width: 36,
height: 4,
margin: const EdgeInsets.only(bottom: 14),
decoration: BoxDecoration(
color: colors.onSurface.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(2)))),
Text('添加到',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: colors.onSurface)),
const SizedBox(height: 14),
_sheetItem(
ctx, colors, Icons.check_circle_outline, '已看', 'watched'),
_sheetItem(
ctx, colors, Icons.play_circle_outline, '在看', 'watching'),
_sheetItem(
ctx, colors, Icons.bookmark_outline, '想看', 'want_to_watch'),
]),
),
),
);
}
Widget _sheetItem(BuildContext ctx, ColorScheme colors, IconData icon,
String label, String status) {
return InkWell(
onTap: () {
Navigator.pop(ctx);
_addMovie(status);
},
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
margin: const EdgeInsets.only(bottom: 4),
child: Row(children: [
Icon(icon, size: 22, color: colors.onSurface.withValues(alpha: 0.6)),
const SizedBox(width: 12),
Text(label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: colors.onSurface)),
]),
),
);
}
// ── Build ──────────────────────────────────────────────
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colors.surface,
floatingActionButton:
(!_loading && _error == null && _localMovie == null && _data != null)
? FloatingActionButton(
onPressed: _showAddSheet,
backgroundColor: colors.primary,
child: Icon(Icons.add, color: colors.onPrimary))
: null,
body: _loading
? Center(
child: CircularProgressIndicator(
color: colors.primary, strokeWidth: 2))
: _error != null
? _buildError(colors)
: _buildBody(colors),
);
}
Widget _buildError(ColorScheme colors) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline,
size: 48, color: colors.onSurface.withValues(alpha: 0.2)),
const SizedBox(height: 16),
Text(_error!,
style: TextStyle(
fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4))),
const SizedBox(height: 16),
TextButton(
onPressed: () {
setState(() {
_loading = true;
_error = null;
});
_load();
},
child: Text('重试', style: TextStyle(color: colors.primary))),
],
));
}
Widget _buildBody(ColorScheme colors) {
if (_detailStyle == 1) return _buildImmersiveBody(colors);
final m = _data!;
final pic = m['vod_pic'] ?? '';
final name = m['vod_name'] ?? '';
final isEnd = m['vod_isend'] ?? 0;
final year = m['vod_year'] ?? '';
final area = m['vod_area'] ?? '';
final typeName = m['type_name'] ?? '';
final classStr = m['vod_class'] ?? '';
final score = m['vod_score'] ?? '';
final metaParts =
[year, area].where((s) => s.toString().isNotEmpty).join(' · ');
final typeParts =
[typeName, classStr].where((s) => s.toString().isNotEmpty).join(' / ');
return Column(children: [
// 顶部AppBar + 海报信息区
Container(
color: colors.surface,
child: SafeArea(
bottom: false,
child: Column(children: [
// AppBar
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(children: [
GestureDetector(
onTap: () => Navigator.pop(context),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
shape: BoxShape.circle),
child: Icon(Icons.arrow_back,
size: 20, color: colors.onSurface)),
),
const Spacer(),
GestureDetector(
onTap: () => setState(() => _detailStyle = _detailStyle == 0 ? 1 : 0),
child: Container(
width: 36,
height:36,
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
shape: BoxShape.circle),
child: Icon(
_detailStyle == 0
? Icons.crop_landscape_rounded
: Icons.grid_view_rounded,
size: 18,
color: colors.onSurface)),
),
]),
),
// 海报 + 信息
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 海报
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: SizedBox(
width: 120,
height: 170,
child: pic.toString().isNotEmpty
? Image.network(pic,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
_posterPlaceholder(colors))
: _posterPlaceholder(colors),
),
),
const SizedBox(width: 16),
// 信息
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: colors.onSurface)),
const SizedBox(height: 8),
// 评分
if (score.toString().isNotEmpty && score != '0.0') ...[
Row(children: [
Icon(Icons.star_rounded, size: 16, color: const Color(0xFFF59E0B)),
const SizedBox(width: 3),
Text('$score', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)),
Text(' /10', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))),
]),
const SizedBox(height: 2),
Text('评分来源于网络资源收集,并非官方评分', style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.25))),
const SizedBox(height: 8),
],
// 完结状态
_endTag(isEnd),
if (metaParts.isNotEmpty) ...[
const SizedBox(height: 8),
Text(metaParts,
style: TextStyle(
fontSize: 12,
color: colors.onSurface
.withValues(alpha: 0.5))),
],
if (typeParts.isNotEmpty) ...[
const SizedBox(height: 3),
Text(typeParts,
style: TextStyle(
fontSize: 11,
color: colors.onSurface
.withValues(alpha: 0.4)),
maxLines: 1,
overflow: TextOverflow.ellipsis),
],
// 本地状态
if (_localMovie != null) ...[
const SizedBox(height: 10),
_buildLocalStatus(colors),
],
]),
),
]),
),
])),
),
// Tab 栏
Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: colors.outlineVariant, width: 0.5))),
child: Row(children: [
_buildTabButton('概要', 0),
_buildTabButton('演职人员', 1),
]),
),
// 内容区
Expanded(
child:
_currentTab == 0 ? _buildOverview(colors) : _buildStaffTab(colors),
),
]);
}
// ── 沉浸式布局 ──────────────────────────────────────────
Widget _buildImmersiveBody(ColorScheme colors) {
final m = _data!;
final pic = m['vod_pic'] ?? '';
final name = m['vod_name'] ?? '';
final isEnd = m['vod_isend'] ?? 0;
final year = m['vod_year'] ?? '';
final area = m['vod_area'] ?? '';
final typeName = m['type_name'] ?? '';
final classStr = m['vod_class'] ?? '';
final score = m['vod_score'] ?? '';
final metaParts = [year, area].where((s) => s.toString().isNotEmpty).join(' · ');
final typeParts = [typeName, classStr].where((s) => s.toString().isNotEmpty).join(' / ');
return Column(children: [
// 全宽海报区
Stack(children: [
// 海报图
SizedBox(
width: double.infinity,
height: 320,
child: pic.toString().isNotEmpty
? Image.network(pic, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(color: colors.surfaceContainerHighest))
: Container(color: colors.surfaceContainerHighest,
child: Icon(Icons.movie_outlined, size: 64, color: colors.onSurface.withValues(alpha: 0.1))),
),
// 渐变遮罩
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black.withValues(alpha: 0.8)],
stops: const [0.35, 1.0],
),
),
),
),
// 顶部按钮
SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(children: [
GestureDetector(
onTap: () => Navigator.pop(context),
child: Container(
width: 36, height: 36,
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.3), shape: BoxShape.circle),
child: const Icon(Icons.arrow_back, size: 20, color: Colors.white)),
),
const Spacer(),
GestureDetector(
onTap: () => setState(() => _detailStyle = 0),
child: Container(
width: 36, height: 36,
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.3), shape: BoxShape.circle),
child: const Icon(Icons.grid_view_rounded, size: 18, color: Colors.white)),
),
]),
),
),
// 底部信息叠加
Positioned(
left: 16, right: 16, bottom: 18,
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
Text(name, maxLines: 2, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: Colors.white)),
const SizedBox(height: 8),
Row(children: [
if (score.toString().isNotEmpty && score != '0.0') ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.star_rounded, size: 18, color: Colors.amber.shade400),
const SizedBox(width: 3),
Text('$score', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Colors.white)),
],
),
),
const SizedBox(width: 10),
],
_endTag(isEnd),
if (metaParts.isNotEmpty) ...[
const SizedBox(width: 8),
Text(metaParts, style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: 0.7))),
],
]),
if (typeParts.isNotEmpty) ...[
const SizedBox(height: 4),
Text(typeParts, maxLines: 1, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: 0.5))),
],
if (_localMovie != null) ...[
const SizedBox(height: 8),
_buildLocalStatus(colors),
],
]),
),
]),
// Tab 栏
Container(
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: colors.outlineVariant, width: 0.5))),
child: Row(children: [
_buildTabButton('概要', 0),
_buildTabButton('演职人员', 1),
]),
),
// 内容区
Expanded(
child: _currentTab == 0 ? _buildOverview(colors) : _buildStaffTab(colors),
),
]);
}
Widget _posterPlaceholder(ColorScheme colors) {
return Container(
color: colors.surfaceContainerHighest,
child: Center(
child: Icon(Icons.movie_outlined,
size: 32, color: colors.onSurface.withValues(alpha: 0.15))));
}
Widget _endTag(int isEnd) {
final finished = isEnd == 1;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: finished
? const Color(0xFF16A34A).withValues(alpha: 0.1)
: const Color(0xFFF59E0B).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
),
child: Text(finished ? '已完结' : '连载中',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: finished
? const Color(0xFF16A34A)
: const Color(0xFFF59E0B))),
);
}
Widget _buildLocalStatus(ColorScheme colors) {
final status = _localMovie!.status;
final label = _statusLabel(status);
Color dotColor;
switch (status) {
case 'watched':
dotColor = colors.primary;
break;
case 'watching':
dotColor = const Color(0xFF666666);
break;
default:
dotColor = const Color(0xFF999999);
break;
}
return Row(children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle)),
const SizedBox(width: 6),
Text('已在本地 · $label',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: colors.onSurface.withValues(alpha: 0.6))),
]);
}
Widget _buildTabButton(String label, int index) {
final colors = Theme.of(context).colorScheme;
final selected = _currentTab == index;
return GestureDetector(
onTap: () => setState(() => _currentTab = index),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 11),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: selected ? colors.primary : Colors.transparent,
width: 2))),
child: Text(label,
style: TextStyle(
fontSize: 13,
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
color: selected
? colors.primary
: colors.onSurface.withValues(alpha: 0.4))),
),
);
}
// ── 概要 Tab ──────────────────────────────────────────
Widget _buildOverview(ColorScheme colors) {
final m = _data!;
final isManual = m['is_manual_optimized'] ?? 0;
return ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 40),
children: [
// 信息行
_infoRow(colors, '导演', m['vod_director']),
_infoRow(colors, '主演', _formatActors()),
_infoRow(colors, '语言', m['vod_lang']),
_infoRow(colors, '时长', m['vod_duration']),
_infoRow(colors, '上映', m['vod_pubdate']),
const SizedBox(height: 16),
// 标签
if (isManual == 1 || (m['vod_tag'] ?? '').toString().isNotEmpty) ...[
_buildTags(colors),
const SizedBox(height: 16),
],
// 分隔线
Container(height: 0.5, color: colors.outlineVariant),
const SizedBox(height: 16),
// 简介
_buildSynopsis(colors),
],
);
}
Widget _infoRow(ColorScheme colors, String label, dynamic value) {
final text = value?.toString() ?? '';
if (text.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.symmetric(vertical: 5),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
SizedBox(
width: 44,
child: Text(label,
style: TextStyle(
fontSize: 12,
color: colors.onSurface.withValues(alpha: 0.4)))),
const SizedBox(width: 8),
Expanded(
child: Text(text,
style: TextStyle(
fontSize: 12,
color: colors.onSurface.withValues(alpha: 0.75),
height: 1.5))),
]),
);
}
String _formatActors() {
final staffStr = _data?['vod_staff'] ?? '';
if (staffStr.toString().isNotEmpty) {
try {
final staff = json.decode(staffStr.toString()) as List;
final actors = staff.where((s) => s['position'] == '演员').toList();
if (actors.isNotEmpty) {
return actors.map((s) {
final name = s['name'] ?? '';
final role = s['role'] ?? '';
if (role.toString().isNotEmpty) return '$name$role';
return name;
}).join('');
}
} catch (_) {}
}
return _data?['vod_actor'] ?? '';
}
Widget _buildTags(ColorScheme colors) {
final m = _data!;
final isManual = m['is_manual_optimized'] ?? 0;
final tagStr = m['vod_tag'] ?? '';
final tags =
tagStr.toString().split(',').where((t) => t.trim().isNotEmpty).toList();
return Wrap(
spacing: 6,
runSpacing: 6,
children: [
if (isManual == 1)
_tag('官方优化', const Color(0xFF16A34A), highlight: true),
...tags.map(
(t) => _tag(t.trim(), colors.onSurface.withValues(alpha: 0.5))),
],
);
}
Widget _tag(String text, Color color, {bool highlight = false}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: highlight
? const Color(0xFF16A34A).withValues(alpha: 0.1)
: Colors.transparent,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: highlight
? const Color(0xFF16A34A).withValues(alpha: 0.3)
: color.withValues(alpha: 0.2),
width: 0.5),
),
child: Text(text,
style: TextStyle(
fontSize: 11,
color: highlight ? const Color(0xFF16A34A) : color)),
);
}
Widget _buildSynopsis(ColorScheme colors) {
final m = _data!;
final blurb = m['vod_blurb'] ?? '';
final content = m['vod_content'] ?? '';
final fullText = content.toString().isNotEmpty
? content.toString().replaceAll(RegExp(r'<[^>]*>'), '')
: blurb.toString();
final isLong = fullText.length > 120;
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
AnimatedCrossFade(
duration: const Duration(milliseconds: 200),
crossFadeState:
_expanded ? CrossFadeState.showSecond : CrossFadeState.showFirst,
firstChild: Text(fullText,
style: TextStyle(
fontSize: 13,
color: colors.onSurface.withValues(alpha: 0.7),
height: 1.8),
maxLines: 4,
overflow: TextOverflow.ellipsis),
secondChild: Text(fullText,
style: TextStyle(
fontSize: 13,
color: colors.onSurface.withValues(alpha: 0.7),
height: 1.8)),
),
if (isLong)
GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(_expanded ? '收起' : '展开全文',
style: TextStyle(
fontSize: 12,
color: colors.primary,
fontWeight: FontWeight.w500)),
),
),
]);
}
// ── 演职人员 Tab ──────────────────────────────────────────
Widget _buildStaffTab(ColorScheme colors) {
if (_staffLoading)
return Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: colors.primary)));
if (_staffList.isEmpty)
return Center(
child: Text('暂无演职信息',
style: TextStyle(
fontSize: 13,
color: colors.onSurface.withValues(alpha: 0.35))));
return GridView.builder(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 40),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 10,
mainAxisSpacing: 14,
childAspectRatio: 0.7),
itemCount: _staffList.length,
itemBuilder: (context, index) =>
_buildStaffCard(colors, _staffList[index]),
);
}
Widget _buildStaffCard(ColorScheme colors, Map<String, dynamic> s) {
final name = s['name'] ?? '';
final position = s['position'] ?? '';
final role = s['role'] ?? '';
final pic = s['actor_pic'] ?? '';
final sub = [position, if (role.toString().isNotEmpty) role].join(' · ');
return Column(children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: double.infinity,
height: 100,
child: pic.toString().isNotEmpty
? Image.network(pic,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
_avatarPlaceholder(colors, name))
: _avatarPlaceholder(colors, name),
),
),
const SizedBox(height: 6),
Text(name,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: colors.onSurface),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center),
const SizedBox(height: 2),
Text(sub,
style: TextStyle(
fontSize: 9, color: colors.onSurface.withValues(alpha: 0.4)),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center),
]);
}
Widget _avatarPlaceholder(ColorScheme colors, String name) {
final ch = name.isNotEmpty ? name.characters.first : '?';
return Container(
color: colors.surfaceContainerHighest,
child: Center(
child: Text(ch,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w600,
color: colors.onSurface.withValues(alpha: 0.25)))),
);
}
}

View File

@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../models/data_models.dart'; import '../../models/data_models.dart';
import '../../providers/app_provider.dart'; import '../../providers/app_provider.dart';
import '../../utils/user_prefs.dart';
import '../../widgets/movie_status_bar.dart'; import '../../widgets/movie_status_bar.dart';
import '../../widgets/movie_list_item.dart'; import '../../widgets/movie_list_item.dart';
import '../../widgets/animated_star_rating.dart'; import '../../widgets/animated_star_rating.dart';
@@ -18,7 +17,6 @@ class MovieTabPage extends StatefulWidget {
} }
class _MovieTabPageState extends State<MovieTabPage> { class _MovieTabPageState extends State<MovieTabPage> {
int _layoutStyle = 0;
final List<Movie> _items = []; final List<Movie> _items = [];
bool _hasMore = true; bool _hasMore = true;
bool _isLoading = false; bool _isLoading = false;
@@ -36,7 +34,6 @@ class _MovieTabPageState extends State<MovieTabPage> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_layoutStyle = UserPrefs().movieLayoutStyle;
_scrollController = ScrollController()..addListener(_onScroll); _scrollController = ScrollController()..addListener(_onScroll);
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
final provider = context.read<AppProvider>(); final provider = context.read<AppProvider>();
@@ -165,7 +162,7 @@ class _MovieTabPageState extends State<MovieTabPage> {
onRefresh: _refresh, onRefresh: _refresh,
color: colors.primary, color: colors.primary,
backgroundColor: colors.surface, backgroundColor: colors.surface,
child: _layoutStyle == 1 ? _buildListView() : _buildGridView(), child: provider.movieLayoutStyle == 1 ? _buildListView() : provider.movieLayoutStyle == 2 ? _buildCoverCardView() : _buildGridView(),
); );
}, },
); );
@@ -256,6 +253,97 @@ class _MovieTabPageState extends State<MovieTabPage> {
return parts.join(' · '); return parts.join(' · ');
} }
// ─── 大图卡片样式 ───────────────────────────────────────
Widget _buildCoverCardView() {
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 100),
itemCount: _items.length + (_hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= _items.length) return _buildLoadMoreIndicator();
return _buildCoverCard(_items[index]);
},
);
}
Widget _buildCoverCard(Movie movie) {
final colors = Theme.of(context).colorScheme;
return GestureDetector(
onTap: () => Navigator.pushNamed(context, '/movie-detail', arguments: movie),
onLongPress: () => _showDeleteDialog(context, movie),
child: Container(
height: 200,
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
color: colors.surfaceContainerHigh,
),
clipBehavior: Clip.antiAlias,
child: Stack(fit: StackFit.expand, children: [
// 海报背景
if (movie.posterPath != null && movie.posterPath!.isNotEmpty)
FadeInLocalImage(path: movie.posterPath, fit: BoxFit.cover,
errorWidget: Container(color: colors.surfaceContainerHighest))
else
Container(color: colors.surfaceContainerHighest,
child: Icon(Icons.movie_outlined, size: 48, color: colors.onSurface.withValues(alpha: 0.15))),
// 底部渐变遮罩
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black.withValues(alpha: 0.75)],
stops: const [0.4, 1.0],
),
),
),
),
// 底部信息
Positioned(
left: 14, right: 14, bottom: 14,
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
Text(movie.title, maxLines: 1, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: Colors.white)),
const SizedBox(height: 4),
Row(children: [
Expanded(
child: Text(_buildSubtitle(movie), maxLines: 1, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: 0.7))),
),
if (movie.rating != null) ...[
const SizedBox(width: 8),
Icon(Icons.star_rounded, size: 16, color: Colors.amber.shade400),
const SizedBox(width: 2),
Text(movie.rating!.toStringAsFixed(1),
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Colors.white)),
],
]),
]),
),
]),
),
);
}
Widget _buildCoverCardSkeleton() {
final colors = Theme.of(context).colorScheme;
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 100),
itemCount: 4,
itemBuilder: (_, __) => Container(
height: 200,
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(14),
),
),
);
}
void _showDeleteDialog(BuildContext context, Movie movie) { void _showDeleteDialog(BuildContext context, Movie movie) {
final colors = Theme.of(context).colorScheme; final colors = Theme.of(context).colorScheme;
showDialog( showDialog(
@@ -286,7 +374,12 @@ class _MovieTabPageState extends State<MovieTabPage> {
); );
} }
Widget _buildSkeleton() => _layoutStyle == 1 ? _buildListSkeleton() : const MovieSkeletonGrid(); Widget _buildSkeleton() {
final layoutStyle = context.read<AppProvider>().movieLayoutStyle;
if (layoutStyle == 1) return _buildListSkeleton();
if (layoutStyle == 2) return _buildCoverCardSkeleton();
return const MovieSkeletonGrid();
}
Widget _buildListSkeleton() { Widget _buildListSkeleton() {
final colors = Theme.of(context).colorScheme; final colors = Theme.of(context).colorScheme;

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:webview_flutter/webview_flutter.dart'; import 'package:webview_flutter/webview_flutter.dart';
import 'package:url_launcher/url_launcher.dart';
import '../models/data_models.dart'; import '../models/data_models.dart';
import '../providers/app_provider.dart'; import '../providers/app_provider.dart';
import '../utils/user_prefs.dart'; import '../utils/user_prefs.dart';
@@ -15,6 +16,8 @@ import 'sync/backup_page.dart';
import '../widgets/fade_in_local_image.dart'; import '../widgets/fade_in_local_image.dart';
import 'statistics_page.dart'; import 'statistics_page.dart';
import 'changelog_page.dart'; import 'changelog_page.dart';
import 'legal_page.dart';
import 'enhanced_search_settings_page.dart';
import 'sync/cloud_sync_page.dart'; import 'sync/cloud_sync_page.dart';
import 'app_icon_picker_page.dart'; import 'app_icon_picker_page.dart';
import 'tag_management_page.dart'; import 'tag_management_page.dart';
@@ -166,21 +169,15 @@ class _ProfilePageState extends State<ProfilePage> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
GestureDetector( Text(_nickname, style: TextStyle(
onTap: () => _editNickname(context), fontSize: 18, fontWeight: FontWeight.w600,
child: Text(_nickname, style: TextStyle( color: hasData ? Colors.white : colors.onSurface)),
fontSize: 18, fontWeight: FontWeight.w600,
color: hasData ? Colors.white : colors.onSurface)),
),
const SizedBox(height: 4), const SizedBox(height: 4),
GestureDetector( Text(_motto, maxLines: 1, overflow: TextOverflow.ellipsis,
onTap: () => _editMotto(context), style: TextStyle(fontSize: 13,
child: Text(_motto, maxLines: 1, overflow: TextOverflow.ellipsis, color: hasData
style: TextStyle(fontSize: 12, ? Colors.white.withValues(alpha: 0.85)
color: hasData : colors.onSurface.withValues(alpha: 0.6))),
? Colors.white.withValues(alpha: 0.7)
: colors.onSurface.withValues(alpha: 0.5))),
),
], ],
), ),
), ),
@@ -223,8 +220,8 @@ class _ProfilePageState extends State<ProfilePage> {
Text(value, style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: hasData ? Colors.white : Theme.of(context).colorScheme.onSurface)), Text(value, style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: hasData ? Colors.white : Theme.of(context).colorScheme.onSurface)),
const SizedBox(height: 2), const SizedBox(height: 2),
Text(label, style: TextStyle(fontSize: 11, color: hasData Text(label, style: TextStyle(fontSize: 11, color: hasData
? Colors.white.withValues(alpha: 0.7) ? Colors.white.withValues(alpha: 0.85)
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5))), : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6))),
], ],
), ),
); );
@@ -528,88 +525,6 @@ class _ProfilePageState extends State<ProfilePage> {
} }
} }
void _editNickname(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final controller = TextEditingController(text: _nickname);
showDialog(
context: context,
builder: (context) => 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: TextField(controller: controller,
style: TextStyle(fontSize: 14, color: colors.onSurface),
decoration: InputDecoration(
hintText: '输入昵称',
hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.3)),
filled: true,
fillColor: colors.surfaceContainerHighest,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: colors.primary, width: 1.5)),
)),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))),
ElevatedButton(
onPressed: () async {
final newNickname = controller.text.trim();
if (newNickname.isNotEmpty) {
await _userPrefs.setNickname(newNickname);
setState(() => _nickname = newNickname);
}
Navigator.pop(context);
},
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: const Text('确定'),
),
],
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
);
}
void _editMotto(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final controller = TextEditingController(text: _motto);
showDialog(
context: context,
builder: (context) => 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: TextField(controller: controller, maxLines: 2,
style: TextStyle(fontSize: 14, color: colors.onSurface),
decoration: InputDecoration(
hintText: '输入座右铭',
hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.3)),
filled: true,
fillColor: colors.surfaceContainerHighest,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: colors.primary, width: 1.5)),
)),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))),
ElevatedButton(
onPressed: () async {
final newMotto = controller.text.trim();
await _userPrefs.setMotto(newMotto);
setState(() => _motto = newMotto);
Navigator.pop(context);
},
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: const Text('确定'),
),
],
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
);
}
// ─── 备份弹窗 ──────────────────────────────────────────────────────── // ─── 备份弹窗 ────────────────────────────────────────────────────────
void _showBackupOptions(BuildContext context) { void _showBackupOptions(BuildContext context) {
@@ -708,6 +623,20 @@ class _SettingsPageState extends State<SettingsPage> {
_buildColorSchemeSelector(), _buildColorSchemeSelector(),
Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant),
_buildSectionHeader('其他设置'), _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( _buildSwitchItem(
icon: Icons.swipe_vertical_outlined, icon: Icons.swipe_vertical_outlined,
title: '底部导航栏滚动隐藏', title: '底部导航栏滚动隐藏',
@@ -725,6 +654,20 @@ class _SettingsPageState extends State<SettingsPage> {
), ),
Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant),
_buildSectionHeader('帮助'), _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.code_outlined,
title: '开发日志',
subtitle: '在浏览器中查看项目开发记录',
onTap: () => launchUrl(Uri.parse('http://docmost.iletter.top/s/technologyNote/p/mook-note-lHmPTswdDC')),
),
Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant),
_buildActionItem( _buildActionItem(
icon: Icons.update_outlined, icon: Icons.update_outlined,
title: '更新日志', title: '更新日志',
@@ -732,12 +675,20 @@ class _SettingsPageState extends State<SettingsPage> {
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const ChangelogPage())), onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const ChangelogPage())),
), ),
Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant),
_buildLinkItem( _buildActionItem(
context: context, icon: Icons.description_outlined,
icon: Icons.help_outline, title: '用户服务协议',
title: '使用说明', subtitle: '查看用户服务协议',
subtitle: '查看应用使用指南', onTap: () => Navigator.push(context, MaterialPageRoute(
url: 'https://mooknote.iletter.top/#/guide', 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), Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant),
], ],
@@ -750,6 +701,82 @@ class _SettingsPageState extends State<SettingsPage> {
setState(() => _hideBottomNavOnScroll = 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 _themeModeLabels = ['跟随系统', '浅色模式', '深色模式'];
static const _themeModeIcons = [Icons.brightness_auto, Icons.light_mode, Icons.dark_mode]; static const _themeModeIcons = [Icons.brightness_auto, Icons.light_mode, Icons.dark_mode];
@@ -967,30 +994,6 @@ class _SettingsPageState extends State<SettingsPage> {
); );
} }
Widget _buildLinkItem({required BuildContext context, required IconData icon, required String title, required String subtitle, required String url}) {
final colors = Theme.of(context).colorScheme;
return InkWell(
onTap: () => _launchUrl(context, url),
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.open_in_new, color: colors.onSurface.withValues(alpha: 0.25), size: 18),
],
),
),
);
}
Widget _buildActionItem({required IconData icon, required String title, required String subtitle, required VoidCallback onTap}) { Widget _buildActionItem({required IconData icon, required String title, required String subtitle, required VoidCallback onTap}) {
final colors = Theme.of(context).colorScheme; final colors = Theme.of(context).colorScheme;
return InkWell( return InkWell(
@@ -1081,10 +1084,6 @@ class _SettingsPageState extends State<SettingsPage> {
} catch (e) { debugPrint('清理图片目录失败: $e'); } } catch (e) { debugPrint('清理图片目录失败: $e'); }
return deletedCount; return deletedCount;
} }
void _launchUrl(BuildContext context, String url) {
Navigator.push(context, MaterialPageRoute(builder: (_) => WebViewPage(url: url)));
}
} }
// ─── 主界面设置 ──────────────────────────────────────────────────────── // ─── 主界面设置 ────────────────────────────────────────────────────────
@@ -1277,6 +1276,7 @@ class _LayoutSettingsPageState extends State<LayoutSettingsPage> {
_buildSection('影视布局', [ _buildSection('影视布局', [
ButtonSegment(value: 0, icon: Icon(Icons.grid_view_outlined, size: 16), label: Text('海报网格', style: TextStyle(fontSize: 12))), 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: 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)), ], _movieLayout, (v) => _setLayout('movie', v)),
_buildSection('阅读布局', [ _buildSection('阅读布局', [
ButtonSegment(value: 0, icon: Icon(Icons.grid_view_outlined, size: 16), label: Text('封面网格', style: TextStyle(fontSize: 12))), ButtonSegment(value: 0, icon: Icon(Icons.grid_view_outlined, size: 16), label: Text('封面网格', style: TextStyle(fontSize: 12))),
@@ -1295,7 +1295,7 @@ class _LayoutSettingsPageState extends State<LayoutSettingsPage> {
void _setLayout(String type, int value) async { void _setLayout(String type, int value) async {
switch (type) { switch (type) {
case 'note': await _userPrefs.setNoteLayoutStyle(value); setState(() => _noteLayout = value); case 'note': await _userPrefs.setNoteLayoutStyle(value); setState(() => _noteLayout = value);
case 'movie': await _userPrefs.setMovieLayoutStyle(value); setState(() => _movieLayout = 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); case 'book': await _userPrefs.setBookLayoutStyle(value); setState(() => _bookLayout = value);
} }
} }

View File

@@ -49,16 +49,16 @@ class _BackupPageState extends State<BackupPage> {
body: _isLoading body: _isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: ListView( : ListView(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(20),
children: [ children: [
// 自动备份开关 - 紧凑一行 // 自动备份开关 - 紧凑一行
_buildAutoBackupSection(colors), _buildAutoBackupSection(colors),
const SizedBox(height: 24), const SizedBox(height: 20),
// 手动备份 // 手动备份
_buildSectionTitle(colors, '手动备份'), _buildSectionTitle(colors, '手动备份'),
const SizedBox(height: 12), const SizedBox(height: 10),
_buildActionCard( _buildActionCard(
colors: colors, colors: colors,
title: '导出数据', title: '导出数据',
@@ -68,7 +68,7 @@ class _BackupPageState extends State<BackupPage> {
isLoading: _isExporting, isLoading: _isExporting,
onTap: _exportData, onTap: _exportData,
), ),
const SizedBox(height: 12), const SizedBox(height: 8),
_buildActionCard( _buildActionCard(
colors: colors, colors: colors,
title: '导入数据', title: '导入数据',
@@ -80,7 +80,7 @@ class _BackupPageState extends State<BackupPage> {
isDestructive: true, isDestructive: true,
), ),
const SizedBox(height: 32), const SizedBox(height: 24),
// 使用说明 // 使用说明
_buildInfoSection(colors), _buildInfoSection(colors),
@@ -126,7 +126,7 @@ class _BackupPageState extends State<BackupPage> {
bool isDestructive = false, bool isDestructive = false,
}) { }) {
return Container( return Container(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surfaceContainerHigh, color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@@ -137,23 +137,19 @@ class _BackupPageState extends State<BackupPage> {
Row( Row(
children: [ children: [
Container( Container(
width: 44, width: 32,
height: 44, height: 32,
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surface, color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isDestructive ? Colors.red.withOpacity(0.3) : colors.outline,
width: 0.5,
),
), ),
child: Icon( child: Icon(
icon, icon,
size: 22, size: 18,
color: isDestructive ? Colors.red : colors.onSurface.withValues(alpha: 0.6), color: isDestructive ? Colors.red : colors.onSurface.withValues(alpha: 0.6),
), ),
), ),
const SizedBox(width: 16), const SizedBox(width: 10),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -161,18 +157,18 @@ class _BackupPageState extends State<BackupPage> {
Text( Text(
title, title,
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w500,
color: colors.onSurface, color: colors.onSurface,
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 1),
Text( Text(
description, description,
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 11,
color: colors.onSurface.withValues(alpha: 0.6), color: colors.onSurface.withValues(alpha: 0.4),
height: 1.4, height: 1.3,
), ),
), ),
], ],
@@ -180,12 +176,12 @@ class _BackupPageState extends State<BackupPage> {
), ),
], ],
), ),
const SizedBox(height: 20), const SizedBox(height: 12),
GestureDetector( GestureDetector(
onTap: isLoading ? null : onTap, onTap: isLoading ? null : onTap,
child: Container( child: Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 14), padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isLoading ? colors.onSurface.withValues(alpha: 0.25) : colors.primary, color: isLoading ? colors.onSurface.withValues(alpha: 0.25) : colors.primary,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@@ -193,8 +189,8 @@ class _BackupPageState extends State<BackupPage> {
child: Center( child: Center(
child: isLoading child: isLoading
? SizedBox( ? SizedBox(
width: 20, width: 18,
height: 20, height: 18,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(colors.onPrimary), valueColor: AlwaysStoppedAnimation(colors.onPrimary),
@@ -203,7 +199,7 @@ class _BackupPageState extends State<BackupPage> {
: Text( : Text(
buttonText, buttonText,
style: TextStyle( style: TextStyle(
fontSize: 15, fontSize: 13,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: colors.onPrimary, color: colors.onPrimary,
), ),
@@ -219,7 +215,7 @@ class _BackupPageState extends State<BackupPage> {
/// 构建信息说明区域 /// 构建信息说明区域
Widget _buildInfoSection(ColorScheme colors) { Widget _buildInfoSection(ColorScheme colors) {
return Container( return Container(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surfaceContainerHigh, color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@@ -233,9 +229,8 @@ class _BackupPageState extends State<BackupPage> {
width: 32, width: 32,
height: 32, height: 32,
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surface, color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all(color: colors.outline, width: 0.5),
), ),
child: Icon( child: Icon(
Icons.info_outline, Icons.info_outline,
@@ -243,60 +238,47 @@ class _BackupPageState extends State<BackupPage> {
color: colors.onSurface.withValues(alpha: 0.6), color: colors.onSurface.withValues(alpha: 0.6),
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 10),
Text( Text(
'使用说明', '使用说明',
style: TextStyle( style: TextStyle(
fontSize: 15, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w500,
color: colors.onSurface, color: colors.onSurface,
), ),
), ),
], ],
), ),
const SizedBox(height: 16),
_buildInfoItem(colors, '1', '导出数据会生成一个 .zip 文件,包含所有数据和图片'),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildInfoItem(colors, '2', '选择保存路径后,可以通过微信、邮件等方式发送备份文件'), _buildInfoItem(colors, '导出数据会生成一个 .zip 文件,包含所有数据和图片'),
const SizedBox(height: 12), const SizedBox(height: 8),
_buildInfoItem(colors, '3', '在新设备上选择导入数据,选择备份文件即可恢复'), _buildInfoItem(colors, '选择保存路径后,可以通过微信、邮件等方式发送备份文件'),
const SizedBox(height: 12), const SizedBox(height: 8),
_buildInfoItem(colors, '4', '导入数据会完全覆盖当前设备的数据,请谨慎操作'), _buildInfoItem(colors, '在新设备上选择导入数据,选择备份文件即可恢复'),
const SizedBox(height: 8),
_buildInfoItem(colors, '导入数据会完全覆盖当前设备的数据,请谨慎操作'),
], ],
), ),
); );
} }
/// 构建信息项 /// 构建信息项
Widget _buildInfoItem(ColorScheme colors, String number, String text) { Widget _buildInfoItem(ColorScheme colors, String text) {
return Row( return Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( Padding(
width: 20, padding: const EdgeInsets.only(top: 8),
height: 20, child: Icon(Icons.circle,
decoration: BoxDecoration( size: 4, color: colors.onSurface.withValues(alpha: 0.25)),
color: colors.outline,
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: Text(
number,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: colors.onSurface.withValues(alpha: 0.6),
),
),
),
), ),
const SizedBox(width: 12), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
text, text,
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 13,
color: colors.onSurface.withValues(alpha: 0.6), color: colors.onSurface.withValues(alpha: 0.5),
height: 1.5, height: 1.5,
), ),
), ),
@@ -318,29 +300,17 @@ class _BackupPageState extends State<BackupPage> {
return AlertDialog( return AlertDialog(
backgroundColor: colors.surface, backgroundColor: colors.surface,
elevation: 0, elevation: 0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: Column( title: Text(title,
children: [ style: TextStyle(
Container( fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
width: 48,
height: 48,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.check, color: colors.primary, size: 24),
),
const SizedBox(height: 16),
Text(title,
style: TextStyle(
fontSize: 17, fontWeight: FontWeight.w600, color: colors.onSurface)),
],
),
titlePadding: const EdgeInsets.fromLTRB(24, 24, 24, 0), titlePadding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const SizedBox(height: 12), const SizedBox(height: 8),
Icon(Icons.check_circle_outline, color: colors.primary, size: 40),
const SizedBox(height: 16),
Text( Text(
content, content,
style: TextStyle( style: TextStyle(
@@ -370,13 +340,17 @@ class _BackupPageState extends State<BackupPage> {
contentPadding: const EdgeInsets.fromLTRB(24, 0, 24, 0), contentPadding: const EdgeInsets.fromLTRB(24, 0, 24, 0),
actionsPadding: const EdgeInsets.fromLTRB(16, 20, 16, 16), actionsPadding: const EdgeInsets.fromLTRB(16, 20, 16, 16),
actions: [ actions: [
TextButton( ElevatedButton(
onPressed: () => Navigator.pop(ctx), onPressed: () => Navigator.pop(ctx),
style: TextButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: colors.primary,
foregroundColor: colors.onPrimary,
elevation: 0,
minimumSize: const Size(120, 40), minimumSize: const Size(120, 40),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
), ),
child: Text('确定', style: TextStyle(fontSize: 14, color: colors.primary)), child: const Text('确定', style: TextStyle(fontSize: 14)),
), ),
], ],
); );
@@ -426,14 +400,14 @@ class _BackupPageState extends State<BackupPage> {
return AlertDialog( return AlertDialog(
backgroundColor: colors.surface, backgroundColor: colors.surface,
elevation: 0, elevation: 0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: Row( title: Row(
children: [ children: [
Container( Container(
width: 40, width: 40,
height: 40, height: 40,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.red.withOpacity(0.08), color: Colors.red.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
child: const Icon(Icons.warning_amber_rounded, color: Colors.red, size: 22), child: const Icon(Icons.warning_amber_rounded, color: Colors.red, size: 22),
@@ -441,7 +415,7 @@ class _BackupPageState extends State<BackupPage> {
const SizedBox(width: 12), const SizedBox(width: 12),
Text('确认导入', Text('确认导入',
style: TextStyle( style: TextStyle(
fontSize: 17, fontWeight: FontWeight.w600, color: colors.onSurface)), fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
], ],
), ),
titlePadding: const EdgeInsets.fromLTRB(24, 24, 24, 0), titlePadding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
@@ -459,21 +433,22 @@ class _BackupPageState extends State<BackupPage> {
TextButton( TextButton(
onPressed: () => Navigator.pop(ctx, false), onPressed: () => Navigator.pop(ctx, false),
style: TextButton.styleFrom( style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), foregroundColor: colors.onSurface.withValues(alpha: 0.6),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
), ),
child: Text('取消', child: const Text('取消', style: TextStyle(fontSize: 14)),
style: TextStyle(
color: colors.onSurface.withValues(alpha: 0.6), fontSize: 14)),
), ),
TextButton( ElevatedButton(
onPressed: () => Navigator.pop(ctx, true), onPressed: () => Navigator.pop(ctx, true),
style: TextButton.styleFrom( style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), backgroundColor: colors.error,
foregroundColor: colors.onError,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
), ),
child: const Text('确认导入', child: const Text('确认导入', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
style: TextStyle(color: Colors.red, fontSize: 14, fontWeight: FontWeight.w600)),
), ),
], ],
); );
@@ -520,7 +495,7 @@ class _BackupPageState extends State<BackupPage> {
/// 构建自动备份区域 - 紧凑一行 /// 构建自动备份区域 - 紧凑一行
Widget _buildAutoBackupSection(ColorScheme colors) { Widget _buildAutoBackupSection(ColorScheme colors) {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surfaceContainerHigh, color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@@ -529,17 +504,16 @@ class _BackupPageState extends State<BackupPage> {
child: Row( child: Row(
children: [ children: [
Container( Container(
width: 40, width: 32,
height: 40, height: 32,
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surface, color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
border: Border.all(color: colors.outline, width: 0.5),
), ),
child: Icon(Icons.schedule, child: Icon(Icons.schedule,
size: 20, color: colors.onSurface.withValues(alpha: 0.6)), size: 18, color: colors.onSurface.withValues(alpha: 0.6)),
), ),
const SizedBox(width: 12), const SizedBox(width: 10),
Expanded( Expanded(
child: _backupDirPath != null && _autoBackupEnabled child: _backupDirPath != null && _autoBackupEnabled
? Column( ? Column(
@@ -548,7 +522,7 @@ class _BackupPageState extends State<BackupPage> {
Text( Text(
'自动本地备份', '自动本地备份',
style: TextStyle( style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface), fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
@@ -563,7 +537,7 @@ class _BackupPageState extends State<BackupPage> {
: Text( : Text(
'自动本地备份', '自动本地备份',
style: TextStyle( style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface), fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface),
), ),
), ),
Switch( Switch(

View File

@@ -15,7 +15,7 @@ class CloudSyncPage extends StatelessWidget {
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
children: [ children: [
_buildSectionTitle(colors, '选择备份方式'), _buildSectionTitle(colors, '选择备份方式'),
const SizedBox(height: 12), const SizedBox(height: 10),
_buildOption( _buildOption(
colors: colors, colors: colors,
icon: Icons.storage_outlined, icon: Icons.storage_outlined,
@@ -24,7 +24,7 @@ class CloudSyncPage extends StatelessWidget {
onTap: () => onTap: () =>
Navigator.push(context, MaterialPageRoute(builder: (_) => const WebDAVSyncPage())), Navigator.push(context, MaterialPageRoute(builder: (_) => const WebDAVSyncPage())),
), ),
const SizedBox(height: 28), const SizedBox(height: 20),
_buildInfo(colors), _buildInfo(colors),
], ],
), ),
@@ -40,7 +40,7 @@ class CloudSyncPage extends StatelessWidget {
BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(2))), BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(2))),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(title, Text(title,
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface)), style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.onSurface)),
]); ]);
} }
@@ -55,10 +55,10 @@ class CloudSyncPage extends StatelessWidget {
return GestureDetector( return GestureDetector(
onTap: enabled ? onTap : null, onTap: enabled ? onTap : null,
child: Container( child: Container(
padding: const EdgeInsets.all(18), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: enabled ? colors.surface : colors.surfaceContainerHighest, color: enabled ? colors.surface : colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(12),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withValues(alpha: 0.03), color: Colors.black.withValues(alpha: 0.03),
@@ -68,27 +68,27 @@ class CloudSyncPage extends StatelessWidget {
), ),
child: Row(children: [ child: Row(children: [
Container( Container(
width: 44, width: 36,
height: 44, height: 36,
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)), color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)),
child: Icon(icon, child: Icon(icon,
color: enabled color: enabled
? colors.onSurface.withValues(alpha: 0.6) ? colors.onSurface.withValues(alpha: 0.6)
: colors.onSurface.withValues(alpha: 0.3), : colors.onSurface.withValues(alpha: 0.3),
size: 22)), size: 20)),
const SizedBox(width: 14), const SizedBox(width: 12),
Expanded( Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(title, Text(title,
style: TextStyle( style: TextStyle(
fontSize: 15, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w500,
color: enabled ? colors.onSurface : colors.onSurface.withValues(alpha: 0.3))), color: enabled ? colors.onSurface : colors.onSurface.withValues(alpha: 0.3))),
const SizedBox(height: 3), const SizedBox(height: 2),
Text(subtitle, Text(subtitle,
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 11,
color: enabled color: enabled
? colors.onSurface.withValues(alpha: 0.4) ? colors.onSurface.withValues(alpha: 0.4)
: colors.onSurface.withValues(alpha: 0.25))), : colors.onSurface.withValues(alpha: 0.25))),
@@ -104,10 +104,10 @@ class CloudSyncPage extends StatelessWidget {
Widget _buildInfo(ColorScheme colors) { Widget _buildInfo(ColorScheme colors) {
return Container( return Container(
padding: const EdgeInsets.all(18), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surface, color: colors.surface,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(12),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withValues(alpha: 0.03), color: Colors.black.withValues(alpha: 0.03),
@@ -118,8 +118,8 @@ class CloudSyncPage extends StatelessWidget {
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [ Row(children: [
Container( Container(
width: 36, width: 32,
height: 36, height: 32,
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)),
child: Icon(Icons.info_outline, child: Icon(Icons.info_outline,
@@ -127,9 +127,9 @@ class CloudSyncPage extends StatelessWidget {
const SizedBox(width: 10), const SizedBox(width: 10),
Text('使用说明', Text('使用说明',
style: TextStyle( style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface)), fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface)),
]), ]),
const SizedBox(height: 14), const SizedBox(height: 12),
_infoItem(colors, 'WebDAV 备份:将数据备份到支持 WebDAV 的云盘'), _infoItem(colors, 'WebDAV 备份:将数据备份到支持 WebDAV 的云盘'),
const SizedBox(height: 8), const SizedBox(height: 8),
_infoItem(colors, '建议定期备份到本地或云盘'), _infoItem(colors, '建议定期备份到本地或云盘'),

View File

@@ -149,7 +149,7 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
return AlertDialog( return AlertDialog(
backgroundColor: colors.surface, backgroundColor: colors.surface,
elevation: 0, elevation: 0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: Column( title: Column(
children: [ children: [
Container( Container(
@@ -270,19 +270,19 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
return AlertDialog( return AlertDialog(
backgroundColor: colors.surface, backgroundColor: colors.surface,
elevation: 0, elevation: 0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: Row( title: Row(
children: [ children: [
Container( Container(
width: 40, width: 40,
height: 40, height: 40,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.red.withOpacity(0.08), borderRadius: BorderRadius.circular(10)), color: Colors.red.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(10)),
child: const Icon(Icons.warning_amber_rounded, color: Colors.red, size: 22), child: const Icon(Icons.warning_amber_rounded, color: Colors.red, size: 22),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Text('清除配置', Text('清除配置',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: colors.onSurface)), style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
], ],
), ),
titlePadding: const EdgeInsets.fromLTRB(24, 24, 24, 0), titlePadding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
@@ -297,18 +297,20 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
TextButton( TextButton(
onPressed: () => Navigator.pop(ctx, false), onPressed: () => Navigator.pop(ctx, false),
style: TextButton.styleFrom( style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), foregroundColor: colors.onSurface.withValues(alpha: 0.6),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
child: Text('取消', child: const Text('取消', style: TextStyle(fontSize: 14)),
style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6), fontSize: 14)),
), ),
TextButton( ElevatedButton(
onPressed: () => Navigator.pop(ctx, true), onPressed: () => Navigator.pop(ctx, true),
style: TextButton.styleFrom( style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), backgroundColor: colors.error,
foregroundColor: colors.onError,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
child: const Text('清除', child: const Text('清除', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
style: TextStyle(color: Colors.red, fontSize: 14, fontWeight: FontWeight.w600)),
), ),
], ],
); );
@@ -340,30 +342,30 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
body: _isLoading && !_isConfigured body: _isLoading && !_isConfigured
? Center(child: CircularProgressIndicator(strokeWidth: 2, color: colors.primary)) ? Center(child: CircularProgressIndicator(strokeWidth: 2, color: colors.primary))
: ListView( : ListView(
padding: const EdgeInsets.symmetric(horizontal: 24), padding: const EdgeInsets.symmetric(horizontal: 20),
children: [ children: [
const SizedBox(height: 8), const SizedBox(height: 4),
// 已连接提示 // 已连接提示
if (_isConfigured) _buildConnectedBanner(colors), if (_isConfigured) _buildConnectedBanner(colors),
// 服务器配置 // 服务器配置
_buildSectionLabel(colors, '服务器配置'), _buildSectionLabel(colors, '服务器配置'),
const SizedBox(height: 16), const SizedBox(height: 12),
_buildInput( _buildInput(
colors: colors, colors: colors,
controller: _urlController, controller: _urlController,
hint: '服务器地址,如 https://dav.example.com', hint: '服务器地址,如 https://dav.example.com',
icon: Icons.link, icon: Icons.link,
), ),
const SizedBox(height: 12), const SizedBox(height: 8),
_buildInput( _buildInput(
colors: colors, colors: colors,
controller: _usernameController, controller: _usernameController,
hint: '用户名', hint: '用户名',
icon: Icons.person_outline, icon: Icons.person_outline,
), ),
const SizedBox(height: 12), const SizedBox(height: 8),
_buildInput( _buildInput(
colors: colors, colors: colors,
controller: _passwordController, controller: _passwordController,
@@ -378,23 +380,42 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
color: colors.onSurface.withValues(alpha: 0.3)), color: colors.onSurface.withValues(alpha: 0.3)),
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 8),
_buildInput( _buildInput(
colors: colors, colors: colors,
controller: _pathController, controller: _pathController,
hint: '同步路径,如 /mooknote', hint: '同步路径,如 /mooknote',
icon: Icons.folder_outlined, icon: Icons.folder_outlined,
), ),
const SizedBox(height: 24), const SizedBox(height: 16),
// 测试并保存 // 测试并保存
_buildBtn(colors, '测试并保存', onTap: _isLoading ? null : _saveConfig), _buildBtn(colors, '测试并保存', onTap: _isLoading ? null : _saveConfig),
const SizedBox(height: 40), const SizedBox(height: 28),
if (_isConfigured) ...[ if (_isConfigured) ...[
// 手动同步
_buildSectionLabel(colors, '手动同步'),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildDirectionChip(
colors, '上传到云端', SyncDirection.upload, Icons.upload)),
const SizedBox(width: 12),
Expanded(
child: _buildDirectionChip(
colors, '下载到本地', SyncDirection.download, Icons.download)),
],
),
const SizedBox(height: 16),
_buildBtn(colors, '立即同步', onTap: _isLoading ? null : _syncData, loading: _isLoading),
const SizedBox(height: 24),
// 自动同步 // 自动同步
_buildSectionLabel(colors, '自动同步'), _buildSectionLabel(colors, '自动同步'),
const SizedBox(height: 12), const SizedBox(height: 10),
_buildSwitchRow( _buildSwitchRow(
colors: colors, colors: colors,
icon: Icons.sync, icon: Icons.sync,
@@ -413,35 +434,16 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
), ),
], ],
const SizedBox(height: 32), const SizedBox(height: 24),
// 手动同步
_buildSectionLabel(colors, '手动同步'),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildDirectionChip(
colors, '上传到云端', SyncDirection.upload, Icons.upload)),
const SizedBox(width: 12),
Expanded(
child: _buildDirectionChip(
colors, '下载到本地', SyncDirection.download, Icons.download)),
],
),
const SizedBox(height: 20),
_buildBtn(colors, '立即同步', onTap: _isLoading ? null : _syncData, loading: _isLoading),
const SizedBox(height: 32),
// 清除配置 // 清除配置
_buildTextBtn(colors, '清除配置', onTap: _isLoading ? null : _clearConfig), _buildTextBtn(colors, '清除配置', onTap: _isLoading ? null : _clearConfig),
const SizedBox(height: 8), const SizedBox(height: 4),
], ],
const SizedBox(height: 32), const SizedBox(height: 24),
_buildTips(colors), _buildTips(colors),
const SizedBox(height: 60), const SizedBox(height: 40),
], ],
), ),
); );
@@ -451,8 +453,8 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
Widget _buildConnectedBanner(ColorScheme colors) { Widget _buildConnectedBanner(ColorScheme colors) {
return Container( return Container(
margin: const EdgeInsets.only(bottom: 24), margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surfaceContainerHighest, color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@@ -525,7 +527,7 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: colors.primary, width: 1), borderSide: BorderSide(color: colors.primary, width: 1),
), ),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15), contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
), ),
); );
} }
@@ -536,21 +538,21 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
onTap: onTap, onTap: onTap,
child: Container( child: Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 15), padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: disabled ? colors.onSurface.withValues(alpha: 0.15) : colors.primary, color: disabled ? colors.onSurface.withValues(alpha: 0.15) : colors.primary,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(8),
), ),
child: Center( child: Center(
child: loading child: loading
? SizedBox( ? SizedBox(
width: 20, width: 18,
height: 20, height: 18,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, valueColor: AlwaysStoppedAnimation(colors.onPrimary))) strokeWidth: 2, valueColor: AlwaysStoppedAnimation(colors.onPrimary)))
: Text(text, : Text(text,
style: TextStyle( style: TextStyle(
fontSize: 15, fontWeight: FontWeight.w600, color: colors.onPrimary)), fontSize: 14, fontWeight: FontWeight.w500, color: colors.onPrimary)),
), ),
), ),
); );
@@ -561,7 +563,7 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
onTap: onTap, onTap: onTap,
child: Center( child: Center(
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 8),
child: Text( child: Text(
text, text,
style: TextStyle( style: TextStyle(
@@ -583,22 +585,22 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
required ValueChanged<bool>? onChanged, required ValueChanged<bool>? onChanged,
}) { }) {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surfaceContainerHigh, color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(8),
), ),
child: Row( child: Row(
children: [ children: [
Icon(icon, Icon(icon,
size: 20, size: 18,
color: value ? colors.primary : colors.onSurface.withValues(alpha: 0.3)), color: value ? colors.primary : colors.onSurface.withValues(alpha: 0.3)),
const SizedBox(width: 12), const SizedBox(width: 10),
Expanded( Expanded(
child: Text( child: Text(
value ? sub : label, value ? sub : label,
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 13,
color: value ? colors.onSurface.withValues(alpha: 0.6) : colors.onSurface), color: value ? colors.onSurface.withValues(alpha: 0.6) : colors.onSurface),
), ),
), ),
@@ -624,19 +626,19 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
return GestureDetector( return GestureDetector(
onTap: onTap, onTap: onTap,
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surfaceContainerHigh, color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(8),
), ),
child: Row( child: Row(
children: [ children: [
const SizedBox(width: 32), const SizedBox(width: 28),
Text(label, Text(label,
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4))), style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4))),
const Spacer(), const Spacer(),
Text(value, Text(value,
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6))), style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6))),
const SizedBox(width: 4), const SizedBox(width: 4),
Icon(Icons.chevron_right, Icon(Icons.chevron_right,
size: 16, color: colors.onSurface.withValues(alpha: 0.25)), size: 16, color: colors.onSurface.withValues(alpha: 0.25)),
@@ -651,10 +653,10 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
return GestureDetector( return GestureDetector(
onTap: () => setState(() => _syncDirection = dir), onTap: () => setState(() => _syncDirection = dir),
child: Container( child: Container(
padding: const EdgeInsets.symmetric(vertical: 14), padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: selected ? colors.primary : colors.surfaceContainerHigh, color: selected ? colors.primary : colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(8),
), ),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -680,7 +682,7 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
Widget _buildTips(ColorScheme colors) { Widget _buildTips(ColorScheme colors) {
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surfaceContainerHigh, color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),

View File

@@ -116,26 +116,53 @@ class _TagManagementPageState extends State<TagManagementPage> {
]), ]),
body: Column( body: Column(
children: [ children: [
const SizedBox(height: 12), const SizedBox(height: 8),
// 搜索栏 // 搜索栏
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 20), padding: const EdgeInsets.symmetric(horizontal: 20),
child: TextField( child: Container(
controller: _searchController, height: 36,
style: TextStyle(fontSize: 13, color: colors.onSurface), decoration: BoxDecoration(
decoration: InputDecoration( color: colors.surfaceContainerHigh,
hintText: '搜索标签...', borderRadius: BorderRadius.circular(10),
hintStyle: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.3)), border: Border.all(color: colors.outlineVariant, width: 0.5),
prefixIcon: Icon(Icons.search, size: 18, color: colors.onSurface.withValues(alpha: 0.3)), ),
suffixIcon: _searchQuery.isNotEmpty child: Row(
? GestureDetector(onTap: () { _searchController.clear(); setState(() => _searchQuery = ''); }, children: [
child: Icon(Icons.close, size: 18, color: colors.onSurface.withValues(alpha: 0.3))) const SizedBox(width: 10),
: null, Icon(Icons.search, size: 16, color: colors.onSurface.withValues(alpha: 0.3)),
filled: true, fillColor: colors.surface, const SizedBox(width: 6),
contentPadding: const EdgeInsets.symmetric(vertical: 10), Expanded(
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), child: TextField(
controller: _searchController,
style: TextStyle(fontSize: 13, color: colors.onSurface),
decoration: InputDecoration(
hintText: '搜索标签',
hintStyle: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.3)),
isDense: true,
contentPadding: EdgeInsets.zero,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
disabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
focusedErrorBorder: InputBorder.none,
filled: false,
),
onChanged: (v) => setState(() => _searchQuery = v.trim()),
),
),
if (_searchQuery.isNotEmpty)
GestureDetector(
onTap: () { _searchController.clear(); setState(() => _searchQuery = ''); FocusManager.instance.primaryFocus?.unfocus(); },
child: Padding(
padding: const EdgeInsets.only(right: 8),
child: Icon(Icons.close, size: 15, color: colors.onSurface.withValues(alpha: 0.3)),
),
),
if (_searchQuery.isEmpty) const SizedBox(width: 10),
],
), ),
onChanged: (v) => setState(() => _searchQuery = v.trim()),
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),

View File

@@ -50,6 +50,9 @@ class AppProvider extends ChangeNotifier {
// 观影选中的状态 (0: 已看1: 想看2: 在看) // 观影选中的状态 (0: 已看1: 想看2: 在看)
int _movieStatusIndex = 0; int _movieStatusIndex = 0;
// 影视列表布局样式 (0: 网格, 1: 列表, 2: 大图卡片)
int _movieLayoutStyle = 0;
// 阅读选中的状态 (0: 读完1: 在读2: 准备读) // 阅读选中的状态 (0: 读完1: 在读2: 准备读)
int _bookStatusIndex = 0; int _bookStatusIndex = 0;
@@ -81,6 +84,7 @@ class AppProvider extends ChangeNotifier {
// 从用户偏好恢复默认启动标签 // 从用户偏好恢复默认启动标签
void initMainTabIndex() { void initMainTabIndex() {
final userPrefs = UserPrefs(); final userPrefs = UserPrefs();
_movieLayoutStyle = userPrefs.movieLayoutStyle;
final defaultIndex = userPrefs.defaultMainTabIndex; final defaultIndex = userPrefs.defaultMainTabIndex;
// 确保选中的标签是启用的 // 确保选中的标签是启用的
final showMovie = userPrefs.showMovieTab; final showMovie = userPrefs.showMovieTab;
@@ -159,6 +163,7 @@ class AppProvider extends ChangeNotifier {
int get mainTabIndex => _mainTabIndex; int get mainTabIndex => _mainTabIndex;
int get bottomNavIndex => _bottomNavIndex; int get bottomNavIndex => _bottomNavIndex;
int get movieStatusIndex => _movieStatusIndex; int get movieStatusIndex => _movieStatusIndex;
int get movieLayoutStyle => _movieLayoutStyle;
int get bookStatusIndex => _bookStatusIndex; int get bookStatusIndex => _bookStatusIndex;
bool get drawerOpen => _drawerOpen; bool get drawerOpen => _drawerOpen;
bool get bottomNavVisible => _bottomNavVisible; bool get bottomNavVisible => _bottomNavVisible;
@@ -238,6 +243,12 @@ class AppProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void setMovieLayoutStyle(int style) {
_movieLayoutStyle = style;
UserPrefs().setMovieLayoutStyle(style);
notifyListeners();
}
void setBookStatusIndex(int index) { void setBookStatusIndex(int index) {
_bookStatusIndex = index; _bookStatusIndex = index;
notifyListeners(); notifyListeners();

View File

@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'server_config.dart';
/// 更新日志数据模型 /// 更新日志数据模型
class ChangelogItem { class ChangelogItem {
@@ -29,7 +30,7 @@ class ChangelogItem {
/// 版本更新检查服务 /// 版本更新检查服务
class ChangelogService { class ChangelogService {
static const _apiUrl = 'https://api.mooknote.iletter.top/api/changelog'; static final _apiUrl = '${ServerConfig.apiBase}/changelog';
/// 获取更新日志列表 /// 获取更新日志列表
static Future<List<ChangelogItem>> fetchChangelog() async { static Future<List<ChangelogItem>> fetchChangelog() async {
@@ -69,6 +70,7 @@ class ChangelogService {
final rest = s.substring(dot + 1).replaceAll('.', ''); // "19" 或 "188" final rest = s.substring(dot + 1).replaceAll('.', ''); // "19" 或 "188"
return double.tryParse('$major$rest') ?? 0; return double.tryParse('$major$rest') ?? 0;
} }
final aVal = toNum(a); final aVal = toNum(a);
final bVal = toNum(b); final bVal = toNum(b);
debugPrint('[Update] compare: "$a"→$aVal vs "$b"→$bVal'); debugPrint('[Update] compare: "$a"→$aVal vs "$b"→$bVal');

View File

@@ -0,0 +1,16 @@
import 'package:flutter/foundation.dart';
/// 服务端地址配置
class ServerConfig {
ServerConfig._();
static final String baseUrl = kDebugMode
? 'http://192.168.31.48:27047'
: 'http://api.mooknote.iletter.top';
static final String apiBase = '$baseUrl/api';
static final String vipBaseUrl = kDebugMode
? 'http://192.168.31.48:8081'
: 'http://vipapi.mooknote.iletter.top';
}

View File

@@ -3,9 +3,9 @@ import 'dart:convert';
import 'dart:io' show Platform; import 'dart:io' show Platform;
import 'dart:math'; import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'user_prefs.dart'; import 'user_prefs.dart';
import 'server_config.dart';
/// 匿名用户统计服务(静默运行,对用户不可见) /// 匿名用户统计服务(静默运行,对用户不可见)
/// ///
@@ -18,9 +18,7 @@ class UsageStatsService with WidgetsBindingObserver {
final UserPrefs _prefs = UserPrefs(); final UserPrefs _prefs = UserPrefs();
/// 统计服务器地址debug 走局域网release 走线上 /// 统计服务器地址debug 走局域网release 走线上
static String serverUrl = kDebugMode static String serverUrl = '${ServerConfig.baseUrl}/';
? 'http://192.168.31.48:27047/'
: 'http://api.mooknote.iletter.top/';
Timer? _heartbeatTimer; Timer? _heartbeatTimer;
bool _started = false; bool _started = false;

View File

@@ -133,6 +133,49 @@ class UserPrefs {
String get deviceId => prefs.getString('deviceId') ?? ''; String get deviceId => prefs.getString('deviceId') ?? '';
Future<bool> setDeviceId(String value) => prefs.setString('deviceId', value); Future<bool> setDeviceId(String value) => prefs.setString('deviceId', value);
// ========== 搜索历史 ==========
/// 搜索历史记录
List<String> get searchHistory => prefs.getStringList('searchHistory') ?? [];
Future<bool> setSearchHistory(List<String> value) => prefs.setStringList('searchHistory', value);
/// 添加搜索记录(最多 20 条)
Future<void> addSearchHistory(String keyword) async {
final list = searchHistory;
list.remove(keyword);
list.insert(0, keyword);
if (list.length > 50) { list.removeRange(50, list.length); }
await setSearchHistory(list);
}
/// 删除单条搜索记录
Future<void> removeSearchHistory(String keyword) async {
final list = searchHistory;
list.remove(keyword);
await setSearchHistory(list);
}
/// 清空搜索历史
Future<void> clearSearchHistory() => setSearchHistory([]);
// ========== 增强搜索 ==========
/// 是否开启增强搜索
bool get enhancedSearchEnabled => prefs.getBool('enhancedSearchEnabled') ?? false;
Future<bool> setEnhancedSearchEnabled(bool value) => prefs.setBool('enhancedSearchEnabled', value);
/// 影视增强搜索 Token
String get movieSearchToken => prefs.getString('movieSearchToken') ?? '';
Future<bool> setMovieSearchToken(String value) => prefs.setString('movieSearchToken', value);
/// 书籍增强搜索 Token
String get bookSearchToken => prefs.getString('bookSearchToken') ?? '';
Future<bool> setBookSearchToken(String value) => prefs.setString('bookSearchToken', value);
/// 上次搜索的 Tab: 0=影视, 1=书籍
int get lastSearchTab => prefs.getInt('lastSearchTab') ?? 0;
Future<bool> setLastSearchTab(int value) => prefs.setInt('lastSearchTab', value);
// ========== 版本更新 ========== // ========== 版本更新 ==========
/// 已忽略的版本号(不再提示更新) /// 已忽略的版本号(不再提示更新)

View File

@@ -97,6 +97,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.7" version: "3.0.7"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
cupertino_icons: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -341,6 +349,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
gbk_codec:
dependency: "direct main"
description:
name: gbk_codec
sha256: "3af5311fc9393115e3650ae6023862adf998051a804a08fb804f042724999f61"
url: "https://pub.dev"
source: hosted
version: "0.4.0"
hooks: hooks:
dependency: transitive dependency: transitive
description: description:
@@ -349,6 +365,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.2" version: "2.0.2"
html:
dependency: transitive
description:
name: html
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
url: "https://pub.dev"
source: hosted
version: "0.15.6"
http: http:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@@ -36,6 +36,7 @@ dependencies:
shelf: ^1.4.1 shelf: ^1.4.1
wakelock_plus: ^1.2.5 wakelock_plus: ^1.2.5
pointer_interceptor: ^0.10.1+2 pointer_interceptor: ^0.10.1+2
gbk_codec: ^0.4.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: