diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html new file mode 100644 index 0000000..38d1d1d --- /dev/null +++ b/android/build/reports/problems/problems-report.html @@ -0,0 +1,663 @@ + + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/lib/pages/book_detail_page.dart b/lib/pages/book_detail_page.dart new file mode 100644 index 0000000..e978fc2 --- /dev/null +++ b/lib/pages/book_detail_page.dart @@ -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 createState() => _BookDetailPageState(); +} + +class _BookDetailPageState extends State { + Map? _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 _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(); + final match = provider.books.where((b) => !b.isDeleted && b.title == title).toList(); + if (match.isNotEmpty) { + setState(() => _localBook = match.first); + } + } + + String _decodeText(List bytes) { + if (bytes.length >= 2) { + // UTF-16 LE BOM: FF FE + if (bytes[0] == 0xFF && bytes[1] == 0xFE) { + final codes = []; + 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 = []; + 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 = []; + 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 _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 _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(); + await provider.addBook(book); + + if (mounted) { + setState(() { + _localBook = provider.books.firstWhere((b) => b.id == book.id); + }); + ToastUtil.show(context, '已添加到${_statusLabel(status)}'); + } + } + + List _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? 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> links = []; + if (onlineStr != null && onlineStr.toString().isNotEmpty) { + try { + final list = json.decode(onlineStr.toString()) as List; + links = list.map((e) => e as Map).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 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 opac) { + final items = >[]; + 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 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))), + ]); + } +} diff --git a/lib/pages/enhanced_search_settings_page.dart b/lib/pages/enhanced_search_settings_page.dart new file mode 100644 index 0000000..a9d91e9 --- /dev/null +++ b/lib/pages/enhanced_search_settings_page.dart @@ -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 createState() => + _EnhancedSearchSettingsPageState(); +} + +class _EnhancedSearchSettingsPageState + extends State { + 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 _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?> _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; + } + } + } catch (_) {} + return null; + } + + Future _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 _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))), + ], + ), + ); + } +} diff --git a/lib/pages/legal_page.dart b/lib/pages/legal_page.dart new file mode 100644 index 0000000..631cfbb --- /dev/null +++ b/lib/pages/legal_page.dart @@ -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 createState() => _LegalPageState(); +} + +class _LegalPageState extends State { + String _content = ''; + bool _isLoading = true; + String? _error; + + static final String _baseUrl = ServerConfig.baseUrl; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _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)), + ), + ], + ), + ); + } +} diff --git a/lib/pages/main_content_page.dart b/lib/pages/main_content_page.dart index e056570..03bc5f9 100644 --- a/lib/pages/main_content_page.dart +++ b/lib/pages/main_content_page.dart @@ -8,6 +8,7 @@ import 'movies/movie_tab_page.dart'; import 'book/book_tab_page.dart'; import 'note/note_tab_page.dart'; import 'search_page.dart'; +import 'online_search_page.dart'; import 'sync/webdav_sync_page.dart'; /// 主内容页 - 观影/阅读/笔记标签页(PageView 滑动切换) @@ -88,14 +89,41 @@ class _MainContentPageState extends State { Widget _buildAppBar(BuildContext context) { return Consumer( builder: (context, provider, child) { + final colors = Theme.of(context).colorScheme; return AppBar( + titleSpacing: 8, + leadingWidth: 44, title: Text(_getAppBarTitle(provider)), + actionsPadding: const EdgeInsets.only(right: 4), actions: [ _buildCloudSyncButton(context), IconButton( icon: const Icon(Icons.search), 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 { } } + // ─── 云备份 ────────────────────────────────────────── Widget _buildCloudSyncButton(BuildContext context) { @@ -133,18 +162,18 @@ class _MainContentPageState extends State { final bc = Theme.of(ctx).colorScheme; return SafeArea( child: Padding( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), + padding: const EdgeInsets.fromLTRB(16, 6, 16, 16), child: Column(mainAxisSize: MainAxisSize.min, children: [ 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)), )), - Text('云备份', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: bc.onSurface)), - const SizedBox(height: 20), + Text('云备份', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: bc.onSurface)), + 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), - 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), - 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), ]), ), @@ -157,19 +186,19 @@ class _MainContentPageState extends State { return GestureDetector( onTap: onTap, child: Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(12), decoration: BoxDecoration( 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), ), 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))), - const SizedBox(width: 16), + 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: 12), 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))), - const SizedBox(height: 2), - Text(desc, style: TextStyle(fontSize: 12, color: enabled ? colors.onSurface.withValues(alpha: 0.4) : colors.onSurface.withValues(alpha: 0.2))), + Text(title, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: enabled ? colors.onSurface : colors.onSurface.withValues(alpha: 0.25))), + const SizedBox(height: 1), + 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)), ]), diff --git a/lib/pages/movie_detail_page.dart b/lib/pages/movie_detail_page.dart new file mode 100644 index 0000000..b45c02f --- /dev/null +++ b/lib/pages/movie_detail_page.dart @@ -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 createState() => _MovieDetailPageState(); +} + +class _MovieDetailPageState extends State { + Map? _data; + List> _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 _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 _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) + .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(); + final match = + provider.movies.where((m) => !m.isDeleted && m.title == name).toList(); + if (match.isNotEmpty) { + setState(() { + _localMovie = match.first; + }); + } + } + + Future _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(); + await provider.addMovie(movie); + + if (mounted) { + setState(() { + _localMovie = provider.movies.firstWhere((m) => m.id == movie.id); + }); + ToastUtil.show(context, '已添加到${_statusLabel(status)}'); + } + } + + List _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 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)))), + ); + } +} diff --git a/lib/pages/movies/movie_tab_page.dart b/lib/pages/movies/movie_tab_page.dart index a25b10d..b504842 100644 --- a/lib/pages/movies/movie_tab_page.dart +++ b/lib/pages/movies/movie_tab_page.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../models/data_models.dart'; import '../../providers/app_provider.dart'; -import '../../utils/user_prefs.dart'; import '../../widgets/movie_status_bar.dart'; import '../../widgets/movie_list_item.dart'; import '../../widgets/animated_star_rating.dart'; @@ -18,7 +17,6 @@ class MovieTabPage extends StatefulWidget { } class _MovieTabPageState extends State { - int _layoutStyle = 0; final List _items = []; bool _hasMore = true; bool _isLoading = false; @@ -36,7 +34,6 @@ class _MovieTabPageState extends State { @override void initState() { super.initState(); - _layoutStyle = UserPrefs().movieLayoutStyle; _scrollController = ScrollController()..addListener(_onScroll); WidgetsBinding.instance.addPostFrameCallback((_) { final provider = context.read(); @@ -165,7 +162,7 @@ class _MovieTabPageState extends State { onRefresh: _refresh, color: colors.primary, 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 { 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) { final colors = Theme.of(context).colorScheme; showDialog( @@ -286,7 +374,12 @@ class _MovieTabPageState extends State { ); } - Widget _buildSkeleton() => _layoutStyle == 1 ? _buildListSkeleton() : const MovieSkeletonGrid(); + Widget _buildSkeleton() { + final layoutStyle = context.read().movieLayoutStyle; + if (layoutStyle == 1) return _buildListSkeleton(); + if (layoutStyle == 2) return _buildCoverCardSkeleton(); + return const MovieSkeletonGrid(); + } Widget _buildListSkeleton() { final colors = Theme.of(context).colorScheme; diff --git a/lib/pages/online_search_page.dart b/lib/pages/online_search_page.dart new file mode 100644 index 0000000..d973ec0 --- /dev/null +++ b/lib/pages/online_search_page.dart @@ -0,0 +1,1065 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import '../utils/server_config.dart'; +import '../utils/user_prefs.dart'; +import '../utils/toast_util.dart'; +import 'movie_detail_page.dart'; +import 'book_detail_page.dart'; + +/// 在线搜索影视/书籍 +class OnlineSearchPage extends StatefulWidget { + const OnlineSearchPage({super.key}); + + @override + State createState() => _OnlineSearchPageState(); +} + +class _OnlineSearchPageState extends State { + final _searchController = TextEditingController(); + final _focusNode = FocusNode(); + final _movieScrollController = ScrollController(); + final _bookScrollController = ScrollController(); + final _userPrefs = UserPrefs(); + + String _query = ''; + bool _hasSearched = false; + List _history = []; + + List> _movieList = []; + int _moviePage = 1; + int _moviePageCount = 1; + bool _movieLoading = false; + bool _movieLoadingMore = false; + int _movieTotal = 0; + + List> _bookList = []; + int _bookPage = 1; + int _bookPageCount = 1; + bool _bookLoading = false; + bool _bookLoadingMore = false; + int _bookTotal = 0; + + @override + void initState() { + super.initState(); + _movieScrollController.addListener(_onMovieScroll); + _bookScrollController.addListener(_onBookScroll); + _history = _userPrefs.searchHistory; + _currentTab = _userPrefs.lastSearchTab; + } + + @override + void dispose() { + _searchController.dispose(); + _focusNode.dispose(); + _movieScrollController.dispose(); + _bookScrollController.dispose(); + super.dispose(); + } + + void _onMovieScroll() { + if (_movieScrollController.position.pixels >= + _movieScrollController.position.maxScrollExtent - 200 && + !_movieLoadingMore && + _moviePage < _moviePageCount) { + _loadMoreMovies(); + } + } + + void _onBookScroll() { + if (_bookScrollController.position.pixels >= + _bookScrollController.position.maxScrollExtent - 200 && + !_bookLoadingMore && + _bookPage < _bookPageCount) { + _loadMoreBooks(); + } + } + + void _doSearch() { + final q = _searchController.text.trim(); + if (q.isEmpty) return; + _focusNode.unfocus(); + setState(() { + _query = q; + _hasSearched = true; + _movieList = []; + _moviePage = 1; + _moviePageCount = 1; + _movieTotal = 0; + _bookList = []; + _bookPage = 1; + _bookPageCount = 1; + _bookTotal = 0; + }); + _userPrefs.addSearchHistory(q).then((_) { + setState(() { + _history = _userPrefs.searchHistory; + }); + }); + if (_currentTab == 0) { + _searchMovies(q, 1); + } else { + _searchBooks(q, 1); + } + } + + Future _searchMovies(String keyword, int page) async { + final token = UserPrefs().movieSearchToken; + if (token.isEmpty) { + if (mounted) ToastUtil.show(context, '请先在设置中配置影视搜索 Token'); + return; + } + + setState(() { + if (page == 1) { + _movieLoading = true; + } else { + _movieLoadingMore = true; + } + }); + + try { + final url = + '${ServerConfig.vipBaseUrl}/api/movie/list?movieName=${Uri.encodeComponent(keyword)}&token=$token&page=$page'; + final resp = + await http.get(Uri.parse(url)).timeout(const Duration(seconds: 10)); + if (!mounted) return; + + if (resp.statusCode == 200) { + final data = json.decode(resp.body); + if (data['code'] == 0 && data['data'] != null) { + final list = (data['data']['list'] as List?) + ?.map((e) => e as Map) + .toList() ?? + []; + setState(() { + if (page == 1) { + _movieList = list; + } else { + _movieList.addAll(list); + } + _movieTotal = data['data']['total'] ?? 0; + _moviePage = data['data']['page'] ?? page; + _moviePageCount = data['data']['pagecount'] ?? page; + }); + } else if (data['code'] == 401 || + data['code'] == 403 || + data['msg']?.toString().contains('token') == true || + data['msg']?.toString().contains('过期') == true) { + if (mounted) + setState(() { + _movieLoading = false; + _movieLoadingMore = false; + }); + _showTokenExpiredDialog(); + return; + } + } + } catch (_) {} + + if (mounted) { + setState(() { + _movieLoading = false; + _movieLoadingMore = false; + }); + } + } + + void _loadMoreMovies() { + _searchMovies(_query, _moviePage + 1); + } + + Future _searchBooks(String keyword, int page) async { + final token = UserPrefs().bookSearchToken; + if (token.isEmpty) return; + + setState(() { + if (page == 1) { + _bookLoading = true; + } else { + _bookLoadingMore = true; + } + }); + + try { + final url = + '${ServerConfig.vipBaseUrl}/api/book/list?title=${Uri.encodeComponent(keyword)}&token=$token&page=$page'; + final resp = + await http.get(Uri.parse(url)).timeout(const Duration(seconds: 10)); + if (!mounted) return; + + if (resp.statusCode == 200) { + final data = json.decode(resp.body); + if (data['code'] == 0 && data['data'] != null) { + final list = (data['data']['list'] as List?) + ?.map((e) => e as Map) + .toList() ?? + []; + setState(() { + if (page == 1) { + _bookList = list; + } else { + _bookList.addAll(list); + } + _bookTotal = data['data']['total'] ?? 0; + _bookPage = data['data']['page'] ?? page; + _bookPageCount = data['data']['pagecount'] ?? page; + }); + } else if (data['code'] == 401 || + data['code'] == 403 || + data['msg']?.toString().contains('token') == true || + data['msg']?.toString().contains('过期') == true) { + if (mounted) + setState(() { + _bookLoading = false; + _bookLoadingMore = false; + }); + _showTokenExpiredDialog(); + return; + } + } + } catch (_) {} + + if (mounted) { + setState(() { + _bookLoading = false; + _bookLoadingMore = false; + }); + } + } + + void _loadMoreBooks() { + _searchBooks(_query, _bookPage + 1); + } + + int _currentTab = 0; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: colors.surface, + appBar: AppBar( + titleSpacing: 8, + title: _buildSearchBar(colors), + actions: [ + TextButton( + onPressed: _doSearch, + child: Text('搜索', + style: TextStyle(fontSize: 14, color: colors.primary)), + ), + ], + bottom: _hasSearched + ? PreferredSize( + preferredSize: const Size.fromHeight(40), + child: Container( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: colors.outlineVariant, width: 0.5))), + child: Row(children: [ + _buildTabButton(colors, '影视', 0, _movieTotal), + _buildTabButton(colors, '书籍', 1, _bookTotal), + ]), + ), + ) + : null, + ), + body: _hasSearched + ? (_currentTab == 0 + ? _buildMovieResults(colors) + : _buildBookResults(colors)) + : _buildHistoryPanel(colors), + ); + } + + Widget _buildTabButton( + ColorScheme colors, String label, int index, int count) { + final selected = _currentTab == index; + return GestureDetector( + onTap: () { + if (_currentTab == index) return; + setState(() => _currentTab = index); + _userPrefs.setLastSearchTab(index); + // 切到新 tab 时,若该 tab 尚无数据则触发搜索 + if (_hasSearched && _query.isNotEmpty) { + if (index == 0 && _movieList.isEmpty && !_movieLoading) { + _searchMovies(_query, 1); + } else if (index == 1 && _bookList.isEmpty && !_bookLoading) { + _searchBooks(_query, 1); + } + } + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: selected ? colors.primary : Colors.transparent, + width: 2)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(label, + style: TextStyle( + fontSize: 13, + fontWeight: selected ? FontWeight.w600 : FontWeight.w400, + color: selected + ? colors.primary + : colors.onSurface.withValues(alpha: 0.4))), + if (count > 0) ...[ + const SizedBox(width: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: selected + ? colors.primary.withValues(alpha: 0.1) + : colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Text('$count', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w600, + color: selected + ? colors.primary + : colors.onSurface.withValues(alpha: 0.4))), + ), + ], + ], + ), + ), + ); + } + + Widget _buildSearchBar(ColorScheme colors) { + return Container( + height: 36, + decoration: BoxDecoration( + color: colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: colors.outlineVariant, width: 0.5), + ), + child: Row( + children: [ + const SizedBox(width: 10), + Icon(Icons.search, + size: 16, color: colors.onSurface.withValues(alpha: 0.3)), + const SizedBox(width: 6), + Expanded( + child: TextField( + controller: _searchController, + focusNode: _focusNode, + style: TextStyle(fontSize: 13, color: colors.onSurface), + textInputAction: TextInputAction.search, + 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, + filled: false, + ), + onSubmitted: (_) => _doSearch(), + ), + ), + if (_query.isNotEmpty) + GestureDetector( + onTap: () { + _searchController.clear(); + setState(() { + _query = ''; + _hasSearched = false; + _movieList = []; + }); + }, + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: Icon(Icons.close, + size: 15, color: colors.onSurface.withValues(alpha: 0.3)), + ), + ), + if (_query.isEmpty) const SizedBox(width: 10), + ], + ), + ); + } + + void _showTokenExpiredDialog() { + showDialog( + context: context, + builder: (ctx) { + final c = Theme.of(ctx).colorScheme; + return AlertDialog( + backgroundColor: c.surface, + elevation: 0, + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text('Token 已过期', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + color: c.onSurface)), + content: Text('当前 Token 已过期,请重新获取', + style: TextStyle( + fontSize: 14, + color: c.onSurface.withValues(alpha: 0.6), + height: 1.5)), + actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + actions: [ + ElevatedButton( + onPressed: () => Navigator.pop(ctx), + style: ElevatedButton.styleFrom( + backgroundColor: c.primary, + foregroundColor: c.onPrimary, + elevation: 0, + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8)), + ), + child: const Text('知道了', style: TextStyle(fontSize: 14)), + ), + ], + ); + }, + ); + } + + // ── 影视搜索结果 ────────────────────────────────────────────── + + Widget _buildMovieResults(ColorScheme colors) { + if (!_hasSearched) + return _buildEmptyState(colors, '搜索你想看的影视作品', Icons.movie_outlined); + if (UserPrefs().movieSearchToken.isEmpty) + return _buildEmptyState( + colors, '填入 Token 后可正常使用该功能', Icons.vpn_key_outlined); + if (_movieLoading) return _buildLoadingState(colors); + if (_movieList.isEmpty) + return _buildEmptyState(colors, '未找到相关内容', Icons.search_off_outlined); + + final hasMore = _moviePage < _moviePageCount; + final itemCount = _movieList.length + 1; // +1 for bottom indicator + + return ListView.builder( + controller: _movieScrollController, + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + itemCount: itemCount, + itemBuilder: (context, index) { + if (index == _movieList.length) { + return _buildBottomIndicator(colors, hasMore, + loadingMore: _movieLoadingMore); + } + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: GestureDetector( + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + MovieDetailPage(vodId: _movieList[index]['vod_id']))), + child: _buildMovieCard(colors, _movieList[index]), + ), + ); + }, + ); + } + + Widget _buildMovieCard(ColorScheme colors, Map m) { + final name = m['vod_name'] ?? ''; + final year = m['vod_year'] ?? ''; + final area = m['vod_area'] ?? ''; + final typeName = m['type_name'] ?? ''; + final className = m['vod_class'] ?? ''; + final director = m['vod_director'] ?? ''; + final pic = m['vod_pic'] ?? ''; + final isEnd = m['vod_isend'] ?? 0; + final isManual = m['is_manual_optimized'] ?? 0; + final tag = m['vod_tag'] ?? ''; + + return Container( + decoration: BoxDecoration( + color: colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + ), + clipBehavior: Clip.antiAlias, + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 海报 + SizedBox( + width: 100, + child: pic.toString().isNotEmpty + ? Image.network(pic, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => _posterPlaceholder(colors)) + : _posterPlaceholder(colors), + ), + // 信息 + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 第一行:名称 + 完结状态 + Row( + children: [ + Expanded( + child: Text(name, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: colors.onSurface), + maxLines: 1, + overflow: TextOverflow.ellipsis), + ), + const SizedBox(width: 6), + _buildStatusTag(colors, isEnd), + ], + ), + const SizedBox(height: 6), + // 第二行:年份+地区+类型 + _buildInfoRow(colors, [ + if (year.toString().isNotEmpty) year.toString(), + if (area.toString().isNotEmpty) area.toString(), + if (typeName.toString().isNotEmpty) typeName.toString(), + ]), + if (className.toString().isNotEmpty) ...[ + const SizedBox(height: 3), + Text(className, + style: TextStyle( + fontSize: 11, + color: colors.onSurface.withValues(alpha: 0.4)), + maxLines: 1, + overflow: TextOverflow.ellipsis), + ], + const SizedBox(height: 6), + // 第三行:官方优化 + 标签 + if (isManual == 1 || tag.toString().isNotEmpty) + _buildTagRow(colors, isManual, tag.toString()), + // 第四行:导演 + if (director.toString().isNotEmpty) ...[ + const SizedBox(height: 6), + Row( + children: [ + Icon(Icons.person_outline, + size: 12, + color: colors.onSurface.withValues(alpha: 0.35)), + const SizedBox(width: 4), + Expanded( + child: Text(director, + style: TextStyle( + fontSize: 11, + color: colors.onSurface + .withValues(alpha: 0.5)), + maxLines: 1, + overflow: TextOverflow.ellipsis), + ), + ], + ), + ], + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildStatusTag(ColorScheme colors, int isEnd) { + final isFinished = isEnd == 1; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: isFinished + ? const Color(0xFF16A34A).withValues(alpha: 0.1) + : const Color(0xFFF59E0B).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + isFinished ? '已完结' : '连载中', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: isFinished ? const Color(0xFF16A34A) : const Color(0xFFF59E0B), + ), + ), + ); + } + + Widget _buildInfoRow(ColorScheme colors, List items) { + return Wrap( + spacing: 6, + children: items + .map((s) => Text(s, + style: TextStyle( + fontSize: 11, + color: colors.onSurface.withValues(alpha: 0.5)))) + .toList(), + ); + } + + Widget _buildTagRow(ColorScheme colors, int isManual, String tag) { + final tags = tag.split(',').where((t) => t.trim().isNotEmpty).toList(); + return Wrap( + spacing: 4, + runSpacing: 4, + children: [ + if (isManual == 1) + _buildSmallTag('官方优化', const Color(0xFF16A34A), isHighlight: true), + ...tags.take(4).map((t) => + _buildSmallTag(t.trim(), colors.onSurface.withValues(alpha: 0.4))), + if (tags.length > 4) + Text('+${tags.length - 4}', + style: TextStyle( + fontSize: 10, + color: colors.onSurface.withValues(alpha: 0.3))), + ], + ); + } + + Widget _buildSmallTag(String text, Color textColor, + {bool isHighlight = false}) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: isHighlight + ? const Color(0xFF16A34A).withValues(alpha: 0.1) + : Colors.transparent, + borderRadius: BorderRadius.circular(3), + border: Border.all( + color: isHighlight + ? const Color(0xFF16A34A).withValues(alpha: 0.3) + : textColor.withValues(alpha: 0.2), + width: 0.5, + ), + ), + child: Text(text, + style: TextStyle( + fontSize: 10, + color: isHighlight ? const Color(0xFF16A34A) : textColor)), + ); + } + + Widget _posterPlaceholder(ColorScheme colors) { + return Container( + color: colors.surfaceContainerHighest, + child: Center( + child: Icon(Icons.movie_outlined, + size: 28, color: colors.onSurface.withValues(alpha: 0.2))), + ); + } + + // ── 书籍搜索结果 ────────────────────────────────────────────── + + Widget _buildBookResults(ColorScheme colors) { + if (!_hasSearched) + return _buildEmptyState(colors, '搜索你想看的书籍', Icons.menu_book_outlined); + if (UserPrefs().bookSearchToken.isEmpty) + return _buildEmptyState( + colors, '填入 Token 后可正常使用该功能', Icons.vpn_key_outlined); + if (_bookLoading) return _buildLoadingState(colors); + if (_bookList.isEmpty) + return _buildEmptyState(colors, '未找到相关书籍', Icons.search_off_outlined); + + final hasMore = _bookPage < _bookPageCount; + final itemCount = _bookList.length + 1; + + return ListView.builder( + controller: _bookScrollController, + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + itemCount: itemCount, + itemBuilder: (context, index) { + if (index == _bookList.length) { + return _buildBottomIndicator(colors, hasMore, + loadingMore: _bookLoadingMore); + } + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: GestureDetector( + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => BookDetailPage( + bookId: _bookList[index]['id'].toString()))), + child: _buildBookCard(colors, _bookList[index]), + ), + ); + }, + ); + } + + Widget _buildBookCard(ColorScheme colors, Map b) { + final title = b['title'] ?? ''; + final author = b['author'] ?? ''; + final press = b['press'] ?? ''; + final year = b['publishedDate'] ?? ''; + final cover = b['cover'] ?? ''; + final isbn = b['isbn'] ?? ''; + + return Container( + decoration: BoxDecoration( + color: colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + ), + clipBehavior: Clip.antiAlias, + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: 80, + child: cover.toString().isNotEmpty + ? Image.network(cover, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => + _bookCoverPlaceholder(colors)) + : _bookCoverPlaceholder(colors), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: colors.onSurface), + maxLines: 2, + overflow: TextOverflow.ellipsis), + if (author.toString().isNotEmpty) ...[ + const SizedBox(height: 3), + Text(author, + style: TextStyle( + fontSize: 11, + color: colors.onSurface.withValues(alpha: 0.5)), + maxLines: 1, + overflow: TextOverflow.ellipsis), + ], + if (press.toString().isNotEmpty) ...[ + const SizedBox(height: 3), + Text(press, + style: TextStyle( + fontSize: 11, + color: colors.onSurface.withValues(alpha: 0.4)), + maxLines: 1, + overflow: TextOverflow.ellipsis), + ], + if (year.toString().isNotEmpty) ...[ + const SizedBox(height: 3), + Text('出版年份 $year', + style: TextStyle( + fontSize: 10, + color: colors.onSurface.withValues(alpha: 0.35))), + ], + if (isbn.toString().isNotEmpty) ...[ + const SizedBox(height: 3), + Text('ISBN $isbn', + style: TextStyle( + fontSize: 10, + color: colors.onSurface.withValues(alpha: 0.35))), + ], + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _bookCoverPlaceholder(ColorScheme colors) { + return Container( + color: colors.surfaceContainerHighest, + child: Center( + child: Icon(Icons.menu_book_outlined, + size: 24, color: colors.onSurface.withValues(alpha: 0.2))), + ); + } + + // ── 通用状态 ────────────────────────────────────────────────── + + Widget _buildLoadingState(ColorScheme colors) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator( + strokeWidth: 2.5, color: colors.primary), + ), + const SizedBox(height: 16), + Text('正在搜索...', + style: TextStyle( + fontSize: 13, + color: colors.onSurface.withValues(alpha: 0.4))), + ], + ), + ); + } + + Widget _buildBottomIndicator(ColorScheme colors, bool hasMore, + {bool loadingMore = false}) { + if (hasMore && loadingMore) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, color: colors.primary)), + const SizedBox(width: 8), + Text('加载中...', + style: TextStyle( + fontSize: 12, + color: colors.onSurface.withValues(alpha: 0.4))), + ], + ), + ); + } + return Padding( + padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded(child: Container(height: 0.5, color: colors.outlineVariant)), + const SizedBox(width: 10), + Flexible( + child: Text( + '已经是所有数据啦,要是没有的话,请联系开发者添加哦~', + style: TextStyle( + fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3)), + textAlign: TextAlign.center, + ), + ), + const SizedBox(width: 10), + Expanded(child: Container(height: 0.5, color: colors.outlineVariant)), + ], + ), + ); + } + + // ── 搜索历史 ────────────────────────────────────────────────── + + Widget _buildHistoryPanel(ColorScheme colors) { + if (_history.isEmpty) + return _buildEmptyState(colors, '搜索你想看的影视作品', Icons.movie_outlined); + return ListView( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), + children: [ + Row( + children: [ + Text('搜索历史', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: colors.onSurface)), + const Spacer(), + GestureDetector( + onTap: () { + showDialog( + context: context, + builder: (ctx) { + final c = Theme.of(ctx).colorScheme; + return AlertDialog( + backgroundColor: c.surface, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + title: Text('清空搜索记录', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + color: c.onSurface)), + content: Text('确定删除全部搜索记录?', + style: TextStyle( + fontSize: 14, + color: c.onSurface.withValues(alpha: 0.6), + height: 1.5)), + actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + style: TextButton.styleFrom( + foregroundColor: + c.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: () { + Navigator.pop(ctx); + _userPrefs.clearSearchHistory(); + setState(() { + _history = []; + }); + }, + style: ElevatedButton.styleFrom( + backgroundColor: c.error, + foregroundColor: c.onError, + elevation: 0, + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8))), + child: + const Text('清空', style: TextStyle(fontSize: 14)), + ), + ], + ); + }, + ); + }, + child: Text('清空', + style: TextStyle( + fontSize: 12, + color: colors.onSurface.withValues(alpha: 0.4))), + ), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: _history + .map((kw) => GestureDetector( + onTap: () { + _searchController.text = kw; + _doSearch(); + }, + onLongPress: () { + showDialog( + context: context, + builder: (ctx) { + final c = Theme.of(ctx).colorScheme; + return AlertDialog( + backgroundColor: c.surface, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + title: Text('删除搜索记录', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + color: c.onSurface)), + content: Text('确定删除「$kw」?', + style: TextStyle( + fontSize: 14, + color: c.onSurface.withValues(alpha: 0.6), + height: 1.5)), + actionsPadding: + const EdgeInsets.fromLTRB(16, 0, 16, 16), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + style: TextButton.styleFrom( + foregroundColor: + c.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: () { + Navigator.pop(ctx); + _userPrefs.removeSearchHistory(kw).then((_) { + if (mounted) + setState(() { + _history = _userPrefs.searchHistory; + }); + }); + }, + style: ElevatedButton.styleFrom( + backgroundColor: c.error, + foregroundColor: c.onError, + elevation: 0, + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8)), + ), + child: const Text('删除', + style: TextStyle(fontSize: 14)), + ), + ], + ); + }, + ); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: colors.outlineVariant, width: 0.5), + ), + child: Text(kw, + style: TextStyle( + fontSize: 12, + color: colors.onSurface.withValues(alpha: 0.6))), + ), + )) + .toList(), + ), + ], + ); + } + + Widget _buildEmptyState(ColorScheme colors, String hint, IconData icon) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + ), + child: Icon(icon, + size: 36, color: colors.onSurface.withValues(alpha: 0.2)), + ), + const SizedBox(height: 20), + Text(hint, + style: TextStyle( + fontSize: 14, + color: colors.onSurface.withValues(alpha: 0.35))), + const SizedBox(height: 4), + Text('输入关键词后点击搜索', + style: TextStyle( + fontSize: 12, + color: colors.onSurface.withValues(alpha: 0.2))), + ], + ), + ); + } +} diff --git a/lib/pages/profile_page.dart b/lib/pages/profile_page.dart index 22d59b3..7544336 100644 --- a/lib/pages/profile_page.dart +++ b/lib/pages/profile_page.dart @@ -5,6 +5,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; import 'package:provider/provider.dart'; import 'package:webview_flutter/webview_flutter.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../models/data_models.dart'; import '../providers/app_provider.dart'; import '../utils/user_prefs.dart'; @@ -15,6 +16,8 @@ import 'sync/backup_page.dart'; import '../widgets/fade_in_local_image.dart'; import 'statistics_page.dart'; import 'changelog_page.dart'; +import 'legal_page.dart'; +import 'enhanced_search_settings_page.dart'; import 'sync/cloud_sync_page.dart'; import 'app_icon_picker_page.dart'; import 'tag_management_page.dart'; @@ -166,21 +169,15 @@ class _ProfilePageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - GestureDetector( - onTap: () => _editNickname(context), - child: Text(_nickname, style: TextStyle( - fontSize: 18, fontWeight: FontWeight.w600, - color: hasData ? Colors.white : colors.onSurface)), - ), + Text(_nickname, style: TextStyle( + fontSize: 18, fontWeight: FontWeight.w600, + color: hasData ? Colors.white : colors.onSurface)), const SizedBox(height: 4), - GestureDetector( - onTap: () => _editMotto(context), - child: Text(_motto, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 12, - color: hasData - ? Colors.white.withValues(alpha: 0.7) - : colors.onSurface.withValues(alpha: 0.5))), - ), + Text(_motto, maxLines: 1, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 13, + color: hasData + ? Colors.white.withValues(alpha: 0.85) + : colors.onSurface.withValues(alpha: 0.6))), ], ), ), @@ -223,8 +220,8 @@ class _ProfilePageState extends State { Text(value, style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: hasData ? Colors.white : Theme.of(context).colorScheme.onSurface)), const SizedBox(height: 2), Text(label, style: TextStyle(fontSize: 11, color: hasData - ? Colors.white.withValues(alpha: 0.7) - : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5))), + ? Colors.white.withValues(alpha: 0.85) + : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6))), ], ), ); @@ -528,88 +525,6 @@ class _ProfilePageState extends State { } } - 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) { @@ -708,6 +623,20 @@ class _SettingsPageState extends State { _buildColorSchemeSelector(), Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), _buildSectionHeader('其他设置'), + _buildActionItem( + icon: Icons.person_outline, + title: '个人信息', + subtitle: '修改昵称和座右铭', + onTap: () => _showProfileEditDialog(context), + ), + Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), + _buildActionItem( + icon: Icons.manage_search, + title: '增强搜索', + subtitle: '在线搜索影视和书籍信息', + onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const EnhancedSearchSettingsPage())), + ), + Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), _buildSwitchItem( icon: Icons.swipe_vertical_outlined, title: '底部导航栏滚动隐藏', @@ -725,6 +654,20 @@ class _SettingsPageState extends State { ), Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), _buildSectionHeader('帮助'), + _buildActionItem( + icon: Icons.language_outlined, + title: '查看官网', + subtitle: '在浏览器中打开官方网站', + onTap: () => launchUrl(Uri.parse('https://mooknote.iletter.top/#/')), + ), + Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), + _buildActionItem( + icon: Icons.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( icon: Icons.update_outlined, title: '更新日志', @@ -732,12 +675,20 @@ class _SettingsPageState extends State { onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const ChangelogPage())), ), Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), - _buildLinkItem( - context: context, - icon: Icons.help_outline, - title: '使用说明', - subtitle: '查看应用使用指南', - url: 'https://mooknote.iletter.top/#/guide', + _buildActionItem( + icon: Icons.description_outlined, + title: '用户服务协议', + subtitle: '查看用户服务协议', + onTap: () => Navigator.push(context, MaterialPageRoute( + builder: (_) => const LegalPage(slug: 'terms', title: '用户服务协议'))), + ), + Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), + _buildActionItem( + icon: Icons.shield_outlined, + title: '隐私政策', + subtitle: '查看隐私政策', + onTap: () => Navigator.push(context, MaterialPageRoute( + builder: (_) => const LegalPage(slug: 'privacy', title: '隐私政策'))), ), Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), ], @@ -750,6 +701,82 @@ class _SettingsPageState extends State { setState(() => _hideBottomNavOnScroll = value); } + void _showProfileEditDialog(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final nicknameController = TextEditingController(text: _userPrefs.nickname); + final mottoController = TextEditingController(text: _userPrefs.motto); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: colors.surface, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text('个人信息', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nicknameController, + style: TextStyle(fontSize: 14, color: colors.onSurface), + decoration: InputDecoration( + labelText: '昵称', + labelStyle: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.5)), + filled: true, + fillColor: colors.surfaceContainerHighest, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), + ), + ), + const SizedBox(height: 12), + TextField( + controller: mottoController, + maxLines: 2, + style: TextStyle(fontSize: 14, color: colors.onSurface), + decoration: InputDecoration( + labelText: '座右铭', + labelStyle: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.5)), + filled: true, + fillColor: colors.surfaceContainerHighest, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), + ), + ), + ], + ), + actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + style: TextButton.styleFrom( + foregroundColor: colors.onSurface.withValues(alpha: 0.6), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text('取消', style: TextStyle(fontSize: 14)), + ), + ElevatedButton( + onPressed: () async { + final nickname = nicknameController.text.trim(); + final motto = mottoController.text.trim(); + if (nickname.isNotEmpty) await _userPrefs.setNickname(nickname); + if (motto.isNotEmpty) await _userPrefs.setMotto(motto); + if (ctx.mounted) Navigator.pop(ctx); + if (context.mounted) ToastUtil.show(context, '已保存'); + }, + style: ElevatedButton.styleFrom( + backgroundColor: colors.primary, + foregroundColor: colors.onPrimary, + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text('保存', style: TextStyle(fontSize: 14)), + ), + ], + ), + ); + } + static const _themeModeLabels = ['跟随系统', '浅色模式', '深色模式']; static const _themeModeIcons = [Icons.brightness_auto, Icons.light_mode, Icons.dark_mode]; @@ -967,30 +994,6 @@ class _SettingsPageState extends State { ); } - 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}) { final colors = Theme.of(context).colorScheme; return InkWell( @@ -1081,10 +1084,6 @@ class _SettingsPageState extends State { } catch (e) { debugPrint('清理图片目录失败: $e'); } return deletedCount; } - - void _launchUrl(BuildContext context, String url) { - Navigator.push(context, MaterialPageRoute(builder: (_) => WebViewPage(url: url))); - } } // ─── 主界面设置 ──────────────────────────────────────────────────────── @@ -1277,6 +1276,7 @@ class _LayoutSettingsPageState extends State { _buildSection('影视布局', [ ButtonSegment(value: 0, icon: Icon(Icons.grid_view_outlined, size: 16), label: Text('海报网格', style: TextStyle(fontSize: 12))), ButtonSegment(value: 1, icon: Icon(Icons.view_list_outlined, size: 16), label: Text('列表', style: TextStyle(fontSize: 12))), + ButtonSegment(value: 2, icon: Icon(Icons.crop_landscape_outlined, size: 16), label: Text('大图卡片', style: TextStyle(fontSize: 12))), ], _movieLayout, (v) => _setLayout('movie', v)), _buildSection('阅读布局', [ ButtonSegment(value: 0, icon: Icon(Icons.grid_view_outlined, size: 16), label: Text('封面网格', style: TextStyle(fontSize: 12))), @@ -1295,7 +1295,7 @@ class _LayoutSettingsPageState extends State { void _setLayout(String type, int value) async { switch (type) { case 'note': await _userPrefs.setNoteLayoutStyle(value); setState(() => _noteLayout = value); - case 'movie': await _userPrefs.setMovieLayoutStyle(value); setState(() => _movieLayout = value); + case 'movie': await _userPrefs.setMovieLayoutStyle(value); setState(() => _movieLayout = value); if (mounted) context.read().setMovieLayoutStyle(value); case 'book': await _userPrefs.setBookLayoutStyle(value); setState(() => _bookLayout = value); } } diff --git a/lib/pages/sync/backup_page.dart b/lib/pages/sync/backup_page.dart index 801ef47..83c19ba 100644 --- a/lib/pages/sync/backup_page.dart +++ b/lib/pages/sync/backup_page.dart @@ -49,16 +49,16 @@ class _BackupPageState extends State { body: _isLoading ? const Center(child: CircularProgressIndicator()) : ListView( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.all(20), children: [ // 自动备份开关 - 紧凑一行 _buildAutoBackupSection(colors), - const SizedBox(height: 24), + const SizedBox(height: 20), // 手动备份 _buildSectionTitle(colors, '手动备份'), - const SizedBox(height: 12), + const SizedBox(height: 10), _buildActionCard( colors: colors, title: '导出数据', @@ -68,7 +68,7 @@ class _BackupPageState extends State { isLoading: _isExporting, onTap: _exportData, ), - const SizedBox(height: 12), + const SizedBox(height: 8), _buildActionCard( colors: colors, title: '导入数据', @@ -80,7 +80,7 @@ class _BackupPageState extends State { isDestructive: true, ), - const SizedBox(height: 32), + const SizedBox(height: 24), // 使用说明 _buildInfoSection(colors), @@ -126,7 +126,7 @@ class _BackupPageState extends State { bool isDestructive = false, }) { return Container( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(12), @@ -137,23 +137,19 @@ class _BackupPageState extends State { Row( children: [ Container( - width: 44, - height: 44, + width: 32, + height: 32, decoration: BoxDecoration( - color: colors.surface, - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: isDestructive ? Colors.red.withOpacity(0.3) : colors.outline, - width: 0.5, - ), + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), ), child: Icon( icon, - size: 22, + size: 18, color: isDestructive ? Colors.red : colors.onSurface.withValues(alpha: 0.6), ), ), - const SizedBox(width: 16), + const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -161,18 +157,18 @@ class _BackupPageState extends State { Text( title, style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, + fontSize: 13, + fontWeight: FontWeight.w500, color: colors.onSurface, ), ), - const SizedBox(height: 4), + const SizedBox(height: 1), Text( description, style: TextStyle( - fontSize: 13, - color: colors.onSurface.withValues(alpha: 0.6), - height: 1.4, + fontSize: 11, + color: colors.onSurface.withValues(alpha: 0.4), + height: 1.3, ), ), ], @@ -180,12 +176,12 @@ class _BackupPageState extends State { ), ], ), - const SizedBox(height: 20), + const SizedBox(height: 12), GestureDetector( onTap: isLoading ? null : onTap, child: Container( width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 14), + padding: const EdgeInsets.symmetric(vertical: 10), decoration: BoxDecoration( color: isLoading ? colors.onSurface.withValues(alpha: 0.25) : colors.primary, borderRadius: BorderRadius.circular(8), @@ -193,8 +189,8 @@ class _BackupPageState extends State { child: Center( child: isLoading ? SizedBox( - width: 20, - height: 20, + width: 18, + height: 18, child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation(colors.onPrimary), @@ -203,7 +199,7 @@ class _BackupPageState extends State { : Text( buttonText, style: TextStyle( - fontSize: 15, + fontSize: 13, fontWeight: FontWeight.w500, color: colors.onPrimary, ), @@ -219,7 +215,7 @@ class _BackupPageState extends State { /// 构建信息说明区域 Widget _buildInfoSection(ColorScheme colors) { return Container( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(12), @@ -233,9 +229,8 @@ class _BackupPageState extends State { width: 32, height: 32, decoration: BoxDecoration( - color: colors.surface, + color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), - border: Border.all(color: colors.outline, width: 0.5), ), child: Icon( Icons.info_outline, @@ -243,60 +238,47 @@ class _BackupPageState extends State { color: colors.onSurface.withValues(alpha: 0.6), ), ), - const SizedBox(width: 12), + const SizedBox(width: 10), Text( '使用说明', style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, + fontSize: 13, + fontWeight: FontWeight.w500, color: colors.onSurface, ), ), ], ), - const SizedBox(height: 16), - _buildInfoItem(colors, '1', '导出数据会生成一个 .zip 文件,包含所有数据和图片'), const SizedBox(height: 12), - _buildInfoItem(colors, '2', '选择保存路径后,可以通过微信、邮件等方式发送备份文件'), - const SizedBox(height: 12), - _buildInfoItem(colors, '3', '在新设备上选择导入数据,选择备份文件即可恢复'), - const SizedBox(height: 12), - _buildInfoItem(colors, '4', '导入数据会完全覆盖当前设备的数据,请谨慎操作'), + _buildInfoItem(colors, '导出数据会生成一个 .zip 文件,包含所有数据和图片'), + const SizedBox(height: 8), + _buildInfoItem(colors, '选择保存路径后,可以通过微信、邮件等方式发送备份文件'), + const SizedBox(height: 8), + _buildInfoItem(colors, '在新设备上选择导入数据,选择备份文件即可恢复'), + const SizedBox(height: 8), + _buildInfoItem(colors, '导入数据会完全覆盖当前设备的数据,请谨慎操作'), ], ), ); } /// 构建信息项 - Widget _buildInfoItem(ColorScheme colors, String number, String text) { + Widget _buildInfoItem(ColorScheme colors, String text) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 20, - height: 20, - decoration: BoxDecoration( - 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), - ), - ), - ), + Padding( + padding: const EdgeInsets.only(top: 8), + child: Icon(Icons.circle, + size: 4, color: colors.onSurface.withValues(alpha: 0.25)), ), - const SizedBox(width: 12), + const SizedBox(width: 8), Expanded( child: Text( text, style: TextStyle( fontSize: 13, - color: colors.onSurface.withValues(alpha: 0.6), + color: colors.onSurface.withValues(alpha: 0.5), height: 1.5, ), ), @@ -318,29 +300,17 @@ class _BackupPageState extends State { return AlertDialog( backgroundColor: colors.surface, elevation: 0, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), - title: Column( - children: [ - Container( - 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)), - ], - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text(title, + style: TextStyle( + fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), titlePadding: const EdgeInsets.fromLTRB(24, 24, 24, 0), content: Column( mainAxisSize: MainAxisSize.min, children: [ - const SizedBox(height: 12), + const SizedBox(height: 8), + Icon(Icons.check_circle_outline, color: colors.primary, size: 40), + const SizedBox(height: 16), Text( content, style: TextStyle( @@ -370,13 +340,17 @@ class _BackupPageState extends State { contentPadding: const EdgeInsets.fromLTRB(24, 0, 24, 0), actionsPadding: const EdgeInsets.fromLTRB(16, 20, 16, 16), actions: [ - TextButton( + ElevatedButton( onPressed: () => Navigator.pop(ctx), - style: TextButton.styleFrom( + style: ElevatedButton.styleFrom( + backgroundColor: colors.primary, + foregroundColor: colors.onPrimary, + elevation: 0, minimumSize: const Size(120, 40), 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 { return AlertDialog( backgroundColor: colors.surface, elevation: 0, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), title: Row( children: [ Container( width: 40, height: 40, decoration: BoxDecoration( - color: Colors.red.withOpacity(0.08), + color: Colors.red.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(10), ), child: const Icon(Icons.warning_amber_rounded, color: Colors.red, size: 22), @@ -441,7 +415,7 @@ class _BackupPageState extends State { const SizedBox(width: 12), Text('确认导入', 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), @@ -459,21 +433,22 @@ class _BackupPageState extends State { TextButton( onPressed: () => Navigator.pop(ctx, false), 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)), ), - child: Text('取消', - style: TextStyle( - color: colors.onSurface.withValues(alpha: 0.6), fontSize: 14)), + child: const Text('取消', style: TextStyle(fontSize: 14)), ), - TextButton( + ElevatedButton( onPressed: () => Navigator.pop(ctx, true), - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + style: ElevatedButton.styleFrom( + backgroundColor: colors.error, + foregroundColor: colors.onError, + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), - child: const Text('确认导入', - style: TextStyle(color: Colors.red, fontSize: 14, fontWeight: FontWeight.w600)), + child: const Text('确认导入', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), ), ], ); @@ -520,7 +495,7 @@ class _BackupPageState extends State { /// 构建自动备份区域 - 紧凑一行 Widget _buildAutoBackupSection(ColorScheme colors) { return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(12), @@ -529,17 +504,16 @@ class _BackupPageState extends State { child: Row( children: [ Container( - width: 40, - height: 40, + width: 32, + height: 32, decoration: BoxDecoration( - color: colors.surface, + color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10), - border: Border.all(color: colors.outline, width: 0.5), ), 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( child: _backupDirPath != null && _autoBackupEnabled ? Column( @@ -548,7 +522,7 @@ class _BackupPageState extends State { Text( '自动本地备份', style: TextStyle( - fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface), + fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface), ), const SizedBox(height: 2), Text( @@ -563,7 +537,7 @@ class _BackupPageState extends State { : Text( '自动本地备份', style: TextStyle( - fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface), + fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface), ), ), Switch( diff --git a/lib/pages/sync/cloud_sync_page.dart b/lib/pages/sync/cloud_sync_page.dart index 3ba2536..67366b2 100644 --- a/lib/pages/sync/cloud_sync_page.dart +++ b/lib/pages/sync/cloud_sync_page.dart @@ -15,7 +15,7 @@ class CloudSyncPage extends StatelessWidget { padding: const EdgeInsets.all(20), children: [ _buildSectionTitle(colors, '选择备份方式'), - const SizedBox(height: 12), + const SizedBox(height: 10), _buildOption( colors: colors, icon: Icons.storage_outlined, @@ -24,7 +24,7 @@ class CloudSyncPage extends StatelessWidget { onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const WebDAVSyncPage())), ), - const SizedBox(height: 28), + const SizedBox(height: 20), _buildInfo(colors), ], ), @@ -40,7 +40,7 @@ class CloudSyncPage extends StatelessWidget { BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(2))), const SizedBox(width: 8), 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( onTap: enabled ? onTap : null, child: Container( - padding: const EdgeInsets.all(18), + padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: enabled ? colors.surface : colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(14), + borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.03), @@ -68,27 +68,27 @@ class CloudSyncPage extends StatelessWidget { ), child: Row(children: [ Container( - width: 44, - height: 44, + width: 36, + height: 36, decoration: BoxDecoration( - color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)), + color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(icon, color: enabled ? colors.onSurface.withValues(alpha: 0.6) : colors.onSurface.withValues(alpha: 0.3), - size: 22)), - const SizedBox(width: 14), + size: 20)), + const SizedBox(width: 12), Expanded( child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, + fontSize: 13, + fontWeight: FontWeight.w500, color: enabled ? colors.onSurface : colors.onSurface.withValues(alpha: 0.3))), - const SizedBox(height: 3), + const SizedBox(height: 2), Text(subtitle, style: TextStyle( - fontSize: 12, + fontSize: 11, color: enabled ? colors.onSurface.withValues(alpha: 0.4) : colors.onSurface.withValues(alpha: 0.25))), @@ -104,10 +104,10 @@ class CloudSyncPage extends StatelessWidget { Widget _buildInfo(ColorScheme colors) { return Container( - padding: const EdgeInsets.all(18), + padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: colors.surface, - borderRadius: BorderRadius.circular(14), + borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.03), @@ -118,8 +118,8 @@ class CloudSyncPage extends StatelessWidget { child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Container( - width: 36, - height: 36, + width: 32, + height: 32, decoration: BoxDecoration( color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.info_outline, @@ -127,9 +127,9 @@ class CloudSyncPage extends StatelessWidget { const SizedBox(width: 10), Text('使用说明', 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 的云盘'), const SizedBox(height: 8), _infoItem(colors, '建议定期备份到本地或云盘'), diff --git a/lib/pages/sync/webdav_sync_page.dart b/lib/pages/sync/webdav_sync_page.dart index 50e1e51..a4d8898 100644 --- a/lib/pages/sync/webdav_sync_page.dart +++ b/lib/pages/sync/webdav_sync_page.dart @@ -149,7 +149,7 @@ class _WebDAVSyncPageState extends State { return AlertDialog( backgroundColor: colors.surface, elevation: 0, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), title: Column( children: [ Container( @@ -270,19 +270,19 @@ class _WebDAVSyncPageState extends State { return AlertDialog( backgroundColor: colors.surface, elevation: 0, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), title: Row( children: [ Container( width: 40, height: 40, 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), ), const SizedBox(width: 12), 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), @@ -297,18 +297,20 @@ class _WebDAVSyncPageState extends State { TextButton( onPressed: () => Navigator.pop(ctx, false), 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))), - child: Text('取消', - style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6), fontSize: 14)), + child: const Text('取消', style: TextStyle(fontSize: 14)), ), - TextButton( + ElevatedButton( onPressed: () => Navigator.pop(ctx, true), - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + style: ElevatedButton.styleFrom( + backgroundColor: colors.error, + foregroundColor: colors.onError, + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), - child: const Text('清除', - style: TextStyle(color: Colors.red, fontSize: 14, fontWeight: FontWeight.w600)), + child: const Text('清除', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), ), ], ); @@ -340,30 +342,30 @@ class _WebDAVSyncPageState extends State { body: _isLoading && !_isConfigured ? Center(child: CircularProgressIndicator(strokeWidth: 2, color: colors.primary)) : ListView( - padding: const EdgeInsets.symmetric(horizontal: 24), + padding: const EdgeInsets.symmetric(horizontal: 20), children: [ - const SizedBox(height: 8), + const SizedBox(height: 4), // 已连接提示 if (_isConfigured) _buildConnectedBanner(colors), // 服务器配置 _buildSectionLabel(colors, '服务器配置'), - const SizedBox(height: 16), + const SizedBox(height: 12), _buildInput( colors: colors, controller: _urlController, hint: '服务器地址,如 https://dav.example.com', icon: Icons.link, ), - const SizedBox(height: 12), + const SizedBox(height: 8), _buildInput( colors: colors, controller: _usernameController, hint: '用户名', icon: Icons.person_outline, ), - const SizedBox(height: 12), + const SizedBox(height: 8), _buildInput( colors: colors, controller: _passwordController, @@ -378,23 +380,42 @@ class _WebDAVSyncPageState extends State { color: colors.onSurface.withValues(alpha: 0.3)), ), ), - const SizedBox(height: 12), + const SizedBox(height: 8), _buildInput( colors: colors, controller: _pathController, hint: '同步路径,如 /mooknote', icon: Icons.folder_outlined, ), - const SizedBox(height: 24), + const SizedBox(height: 16), // 测试并保存 _buildBtn(colors, '测试并保存', onTap: _isLoading ? null : _saveConfig), - const SizedBox(height: 40), + const SizedBox(height: 28), 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, '自动同步'), - const SizedBox(height: 12), + const SizedBox(height: 10), _buildSwitchRow( colors: colors, icon: Icons.sync, @@ -413,35 +434,16 @@ class _WebDAVSyncPageState extends State { ), ], - const SizedBox(height: 32), - - // 手动同步 - _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), + const SizedBox(height: 24), // 清除配置 _buildTextBtn(colors, '清除配置', onTap: _isLoading ? null : _clearConfig), - const SizedBox(height: 8), + const SizedBox(height: 4), ], - const SizedBox(height: 32), + const SizedBox(height: 24), _buildTips(colors), - const SizedBox(height: 60), + const SizedBox(height: 40), ], ), ); @@ -451,8 +453,8 @@ class _WebDAVSyncPageState extends State { Widget _buildConnectedBanner(ColorScheme colors) { return Container( - margin: const EdgeInsets.only(bottom: 24), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), @@ -525,7 +527,7 @@ class _WebDAVSyncPageState extends State { borderRadius: BorderRadius.circular(10), 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 { onTap: onTap, child: Container( width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 15), + padding: const EdgeInsets.symmetric(vertical: 10), decoration: BoxDecoration( color: disabled ? colors.onSurface.withValues(alpha: 0.15) : colors.primary, - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(8), ), child: Center( child: loading ? SizedBox( - width: 20, - height: 20, + width: 18, + height: 18, child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation(colors.onPrimary))) : Text(text, 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 { onTap: onTap, child: Center( child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12), + padding: const EdgeInsets.symmetric(vertical: 8), child: Text( text, style: TextStyle( @@ -583,22 +585,22 @@ class _WebDAVSyncPageState extends State { required ValueChanged? onChanged, }) { return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), decoration: BoxDecoration( color: colors.surfaceContainerHigh, - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(8), ), child: Row( children: [ Icon(icon, - size: 20, + size: 18, color: value ? colors.primary : colors.onSurface.withValues(alpha: 0.3)), - const SizedBox(width: 12), + const SizedBox(width: 10), Expanded( child: Text( value ? sub : label, style: TextStyle( - fontSize: 14, + fontSize: 13, color: value ? colors.onSurface.withValues(alpha: 0.6) : colors.onSurface), ), ), @@ -624,19 +626,19 @@ class _WebDAVSyncPageState extends State { return GestureDetector( onTap: onTap, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), decoration: BoxDecoration( color: colors.surfaceContainerHigh, - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(8), ), child: Row( children: [ - const SizedBox(width: 32), + const SizedBox(width: 28), 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(), 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), Icon(Icons.chevron_right, size: 16, color: colors.onSurface.withValues(alpha: 0.25)), @@ -651,10 +653,10 @@ class _WebDAVSyncPageState extends State { return GestureDetector( onTap: () => setState(() => _syncDirection = dir), child: Container( - padding: const EdgeInsets.symmetric(vertical: 14), + padding: const EdgeInsets.symmetric(vertical: 10), decoration: BoxDecoration( color: selected ? colors.primary : colors.surfaceContainerHigh, - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(8), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, @@ -680,7 +682,7 @@ class _WebDAVSyncPageState extends State { Widget _buildTips(ColorScheme colors) { return Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(10), diff --git a/lib/pages/tag_management_page.dart b/lib/pages/tag_management_page.dart index 0c6f11d..f455dae 100644 --- a/lib/pages/tag_management_page.dart +++ b/lib/pages/tag_management_page.dart @@ -116,26 +116,53 @@ class _TagManagementPageState extends State { ]), body: Column( children: [ - const SizedBox(height: 12), + const SizedBox(height: 8), // 搜索栏 Padding( padding: const EdgeInsets.symmetric(horizontal: 20), - 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)), - prefixIcon: Icon(Icons.search, size: 18, color: colors.onSurface.withValues(alpha: 0.3)), - suffixIcon: _searchQuery.isNotEmpty - ? GestureDetector(onTap: () { _searchController.clear(); setState(() => _searchQuery = ''); }, - child: Icon(Icons.close, size: 18, color: colors.onSurface.withValues(alpha: 0.3))) - : null, - filled: true, fillColor: colors.surface, - contentPadding: const EdgeInsets.symmetric(vertical: 10), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), + child: Container( + height: 36, + decoration: BoxDecoration( + color: colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: colors.outlineVariant, width: 0.5), + ), + child: Row( + children: [ + const SizedBox(width: 10), + Icon(Icons.search, size: 16, color: colors.onSurface.withValues(alpha: 0.3)), + const SizedBox(width: 6), + Expanded( + child: TextField( + controller: _searchController, + style: TextStyle(fontSize: 13, color: colors.onSurface), + decoration: InputDecoration( + hintText: '搜索标签', + hintStyle: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.3)), + isDense: true, + contentPadding: EdgeInsets.zero, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + focusedErrorBorder: InputBorder.none, + filled: false, + ), + onChanged: (v) => setState(() => _searchQuery = v.trim()), + ), + ), + if (_searchQuery.isNotEmpty) + GestureDetector( + onTap: () { _searchController.clear(); setState(() => _searchQuery = ''); FocusManager.instance.primaryFocus?.unfocus(); }, + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: Icon(Icons.close, size: 15, color: colors.onSurface.withValues(alpha: 0.3)), + ), + ), + if (_searchQuery.isEmpty) const SizedBox(width: 10), + ], ), - onChanged: (v) => setState(() => _searchQuery = v.trim()), ), ), const SizedBox(height: 12), diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index ac87af3..cc18a83 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -50,6 +50,9 @@ class AppProvider extends ChangeNotifier { // 观影选中的状态 (0: 已看,1: 想看,2: 在看) int _movieStatusIndex = 0; + + // 影视列表布局样式 (0: 网格, 1: 列表, 2: 大图卡片) + int _movieLayoutStyle = 0; // 阅读选中的状态 (0: 读完,1: 在读,2: 准备读) int _bookStatusIndex = 0; @@ -81,6 +84,7 @@ class AppProvider extends ChangeNotifier { // 从用户偏好恢复默认启动标签 void initMainTabIndex() { final userPrefs = UserPrefs(); + _movieLayoutStyle = userPrefs.movieLayoutStyle; final defaultIndex = userPrefs.defaultMainTabIndex; // 确保选中的标签是启用的 final showMovie = userPrefs.showMovieTab; @@ -159,6 +163,7 @@ class AppProvider extends ChangeNotifier { int get mainTabIndex => _mainTabIndex; int get bottomNavIndex => _bottomNavIndex; int get movieStatusIndex => _movieStatusIndex; + int get movieLayoutStyle => _movieLayoutStyle; int get bookStatusIndex => _bookStatusIndex; bool get drawerOpen => _drawerOpen; bool get bottomNavVisible => _bottomNavVisible; @@ -238,6 +243,12 @@ class AppProvider extends ChangeNotifier { notifyListeners(); } + void setMovieLayoutStyle(int style) { + _movieLayoutStyle = style; + UserPrefs().setMovieLayoutStyle(style); + notifyListeners(); + } + void setBookStatusIndex(int index) { _bookStatusIndex = index; notifyListeners(); diff --git a/lib/utils/changelog_service.dart b/lib/utils/changelog_service.dart index 6617943..992ca1e 100644 --- a/lib/utils/changelog_service.dart +++ b/lib/utils/changelog_service.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:package_info_plus/package_info_plus.dart'; +import 'server_config.dart'; /// 更新日志数据模型 class ChangelogItem { @@ -29,7 +30,7 @@ class ChangelogItem { /// 版本更新检查服务 class ChangelogService { - static const _apiUrl = 'https://api.mooknote.iletter.top/api/changelog'; + static final _apiUrl = '${ServerConfig.apiBase}/changelog'; /// 获取更新日志列表 static Future> fetchChangelog() async { @@ -69,6 +70,7 @@ class ChangelogService { final rest = s.substring(dot + 1).replaceAll('.', ''); // "19" 或 "188" return double.tryParse('$major$rest') ?? 0; } + final aVal = toNum(a); final bVal = toNum(b); debugPrint('[Update] compare: "$a"→$aVal vs "$b"→$bVal'); diff --git a/lib/utils/server_config.dart b/lib/utils/server_config.dart new file mode 100644 index 0000000..c1dde62 --- /dev/null +++ b/lib/utils/server_config.dart @@ -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'; +} diff --git a/lib/utils/usage_stats_service.dart b/lib/utils/usage_stats_service.dart index 2179b77..48d5fd4 100644 --- a/lib/utils/usage_stats_service.dart +++ b/lib/utils/usage_stats_service.dart @@ -3,9 +3,9 @@ import 'dart:convert'; import 'dart:io' show Platform; import 'dart:math'; import 'package:flutter/material.dart'; -import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'user_prefs.dart'; +import 'server_config.dart'; /// 匿名用户统计服务(静默运行,对用户不可见) /// @@ -18,9 +18,7 @@ class UsageStatsService with WidgetsBindingObserver { final UserPrefs _prefs = UserPrefs(); /// 统计服务器地址,debug 走局域网,release 走线上 - static String serverUrl = kDebugMode - ? 'http://192.168.31.48:27047/' - : 'http://api.mooknote.iletter.top/'; + static String serverUrl = '${ServerConfig.baseUrl}/'; Timer? _heartbeatTimer; bool _started = false; diff --git a/lib/utils/user_prefs.dart b/lib/utils/user_prefs.dart index 9a6bc74..806b341 100644 --- a/lib/utils/user_prefs.dart +++ b/lib/utils/user_prefs.dart @@ -133,6 +133,49 @@ class UserPrefs { String get deviceId => prefs.getString('deviceId') ?? ''; Future setDeviceId(String value) => prefs.setString('deviceId', value); + // ========== 搜索历史 ========== + + /// 搜索历史记录 + List get searchHistory => prefs.getStringList('searchHistory') ?? []; + Future setSearchHistory(List value) => prefs.setStringList('searchHistory', value); + + /// 添加搜索记录(最多 20 条) + Future 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 removeSearchHistory(String keyword) async { + final list = searchHistory; + list.remove(keyword); + await setSearchHistory(list); + } + + /// 清空搜索历史 + Future clearSearchHistory() => setSearchHistory([]); + + // ========== 增强搜索 ========== + + /// 是否开启增强搜索 + bool get enhancedSearchEnabled => prefs.getBool('enhancedSearchEnabled') ?? false; + Future setEnhancedSearchEnabled(bool value) => prefs.setBool('enhancedSearchEnabled', value); + + /// 影视增强搜索 Token + String get movieSearchToken => prefs.getString('movieSearchToken') ?? ''; + Future setMovieSearchToken(String value) => prefs.setString('movieSearchToken', value); + + /// 书籍增强搜索 Token + String get bookSearchToken => prefs.getString('bookSearchToken') ?? ''; + Future setBookSearchToken(String value) => prefs.setString('bookSearchToken', value); + + /// 上次搜索的 Tab: 0=影视, 1=书籍 + int get lastSearchTab => prefs.getInt('lastSearchTab') ?? 0; + Future setLastSearchTab(int value) => prefs.setInt('lastSearchTab', value); + // ========== 版本更新 ========== /// 已忽略的版本号(不再提示更新) diff --git a/pubspec.lock b/pubspec.lock index 264a401..bb76c0e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -97,6 +97,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" cupertino_icons: dependency: "direct main" description: @@ -341,6 +349,14 @@ packages: description: flutter source: sdk 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: dependency: transitive description: @@ -349,6 +365,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" http: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index f11151e..b03eaab 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -36,6 +36,7 @@ dependencies: shelf: ^1.4.1 wakelock_plus: ^1.2.5 pointer_interceptor: ^0.10.1+2 + gbk_codec: ^0.4.0 dev_dependencies: flutter_test: