generated from dellevin/template
书籍接口与界面
This commit is contained in:
755
lib/pages/book_detail_page.dart
Normal file
755
lib/pages/book_detail_page.dart
Normal file
@@ -0,0 +1,755 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../utils/server_config.dart';
|
||||
import '../utils/user_prefs.dart';
|
||||
import '../models/data_models.dart';
|
||||
import '../providers/app_provider.dart';
|
||||
import '../utils/image_path_helper.dart';
|
||||
import '../utils/toast_util.dart';
|
||||
|
||||
/// 书籍详情页 - 在线版
|
||||
class BookDetailPage extends StatefulWidget {
|
||||
final String bookId;
|
||||
const BookDetailPage({super.key, required this.bookId});
|
||||
|
||||
@override
|
||||
State<BookDetailPage> createState() => _BookDetailPageState();
|
||||
}
|
||||
|
||||
class _BookDetailPageState extends State<BookDetailPage> {
|
||||
Map<String, dynamic>? _data;
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
Book? _localBook;
|
||||
String? _catalog;
|
||||
bool _catalogLoading = false;
|
||||
int _currentTab = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
String _resolveCoverUrl(String cover) {
|
||||
if (cover.startsWith('http')) return cover;
|
||||
return '${ServerConfig.vipBaseUrl}/mk_book$cover';
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final token = UserPrefs().bookSearchToken;
|
||||
try {
|
||||
final url = '${ServerConfig.vipBaseUrl}/api/book/detail?id=${widget.bookId}&token=$token';
|
||||
final resp = await http.get(Uri.parse(url)).timeout(const Duration(seconds: 10));
|
||||
if (!mounted) return;
|
||||
if (resp.statusCode == 200) {
|
||||
final json_ = json.decode(resp.body);
|
||||
if (json_['code'] == 0 && json_['data'] != null) {
|
||||
setState(() {
|
||||
_data = json_['data'];
|
||||
_loading = false;
|
||||
});
|
||||
_checkLocal();
|
||||
_loadCatalog();
|
||||
return;
|
||||
}
|
||||
}
|
||||
setState(() {
|
||||
_error = '加载失败';
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) setState(() {
|
||||
_error = '网络错误';
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _checkLocal() {
|
||||
final title = _data?['title'] ?? '';
|
||||
if (title.toString().isEmpty) return;
|
||||
final provider = context.read<AppProvider>();
|
||||
final match = provider.books.where((b) => !b.isDeleted && b.title == title).toList();
|
||||
if (match.isNotEmpty) {
|
||||
setState(() => _localBook = match.first);
|
||||
}
|
||||
}
|
||||
|
||||
String _decodeText(List<int> bytes) {
|
||||
if (bytes.length >= 2) {
|
||||
// UTF-16 LE BOM: FF FE
|
||||
if (bytes[0] == 0xFF && bytes[1] == 0xFE) {
|
||||
final codes = <int>[];
|
||||
for (var i = 2; i + 1 < bytes.length; i += 2) {
|
||||
codes.add(bytes[i] | (bytes[i + 1] << 8));
|
||||
}
|
||||
return String.fromCharCodes(codes);
|
||||
}
|
||||
// UTF-16 BE BOM: FE FF
|
||||
if (bytes[0] == 0xFE && bytes[1] == 0xFF) {
|
||||
final codes = <int>[];
|
||||
for (var i = 2; i + 1 < bytes.length; i += 2) {
|
||||
codes.add((bytes[i] << 8) | bytes[i + 1]);
|
||||
}
|
||||
return String.fromCharCodes(codes);
|
||||
}
|
||||
}
|
||||
// 无 BOM,按 UTF-16 LE 尝试(大部分中文 txt 是这种)
|
||||
if (bytes.length >= 2 && bytes.length % 2 == 0) {
|
||||
final codes = <int>[];
|
||||
for (var i = 0; i + 1 < bytes.length; i += 2) {
|
||||
codes.add(bytes[i] | (bytes[i + 1] << 8));
|
||||
}
|
||||
final text = String.fromCharCodes(codes);
|
||||
// 检查解码结果是否包含大量不可打印字符(说明不是 UTF-16)
|
||||
final printable = text.runes.where((r) => r >= 0x20 && r < 0xFFFF).length;
|
||||
if (printable > text.length * 0.8) return text;
|
||||
}
|
||||
// fallback: UTF-8
|
||||
try {
|
||||
return utf8.decode(bytes);
|
||||
} catch (_) {
|
||||
return String.fromCharCodes(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadCatalog() async {
|
||||
final bookmark = _data?['bookmark'] ?? '';
|
||||
if (bookmark.toString().isEmpty) return;
|
||||
setState(() => _catalogLoading = true);
|
||||
try {
|
||||
final bookmarkStr = bookmark.toString();
|
||||
final bookmarkUrl = bookmarkStr.startsWith('http')
|
||||
? bookmarkStr
|
||||
: '${ServerConfig.vipBaseUrl}/mk_book/$bookmarkStr';
|
||||
final resp = await http.get(
|
||||
Uri.parse(bookmarkUrl),
|
||||
headers: {'Accept-Encoding': 'identity'},
|
||||
).timeout(const Duration(seconds: 10));
|
||||
if (!mounted) return;
|
||||
if (resp.statusCode == 200) {
|
||||
final bytes = resp.bodyBytes;
|
||||
final text = _decodeText(bytes);
|
||||
setState(() {
|
||||
_catalog = text;
|
||||
_catalogLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
if (mounted) setState(() => _catalogLoading = false);
|
||||
}
|
||||
|
||||
Future<void> _addBook(String status) async {
|
||||
final m = _data!;
|
||||
final title = m['title'] ?? '';
|
||||
final author = m['author'] ?? '';
|
||||
final press = m['press'] ?? '';
|
||||
final isbn = m['isbn'] ?? '';
|
||||
final yearStr = m['publishedDate'] ?? '';
|
||||
final cover = m['cover'] ?? '';
|
||||
|
||||
DateTime? publishDate;
|
||||
if (yearStr.toString().isNotEmpty) {
|
||||
publishDate = DateTime.tryParse('${yearStr}-01-01');
|
||||
}
|
||||
|
||||
final bookId = const Uuid().v4();
|
||||
String? coverPath;
|
||||
|
||||
if (cover.toString().isNotEmpty) {
|
||||
try {
|
||||
final coverUrl = _resolveCoverUrl(cover.toString());
|
||||
final resp = await http.get(Uri.parse(coverUrl), headers: {
|
||||
'User-Agent': 'Mozilla/5.0'
|
||||
}).timeout(const Duration(seconds: 15));
|
||||
if (resp.statusCode == 200 && resp.bodyBytes.length < 10 * 1024 * 1024) {
|
||||
final fileName = 'cover_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||
final targetPath = await ImagePathHelper.instance.getBookCoverPath(bookId, fileName);
|
||||
await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath));
|
||||
await File(targetPath).writeAsBytes(resp.bodyBytes);
|
||||
coverPath = targetPath;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
final book = Book(
|
||||
id: bookId,
|
||||
title: title.toString(),
|
||||
coverPath: coverPath,
|
||||
authors: _splitStr(author.toString()),
|
||||
publisher: press.toString(),
|
||||
isbn: isbn.toString(),
|
||||
publishDate: publishDate,
|
||||
status: status,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
final provider = context.read<AppProvider>();
|
||||
await provider.addBook(book);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_localBook = provider.books.firstWhere((b) => b.id == book.id);
|
||||
});
|
||||
ToastUtil.show(context, '已添加到${_statusLabel(status)}');
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _splitStr(String s) => s
|
||||
.split(RegExp(r'[,,/、]'))
|
||||
.map((e) => e.trim())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
String _statusLabel(String status) {
|
||||
switch (status) {
|
||||
case 'read':
|
||||
return '已读';
|
||||
case 'reading':
|
||||
return '在读';
|
||||
case 'want_to_read':
|
||||
return '想读';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
void _showAddSheet() {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colors.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 16, 16),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.onSurface.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(2)))),
|
||||
Text('添加到',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colors.onSurface)),
|
||||
const SizedBox(height: 14),
|
||||
_sheetItem(ctx, colors, Icons.check_circle_outline, '已读', 'read'),
|
||||
_sheetItem(ctx, colors, Icons.play_circle_outline, '在读', 'reading'),
|
||||
_sheetItem(ctx, colors, Icons.bookmark_outline, '想读', 'want_to_read'),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sheetItem(BuildContext ctx, ColorScheme colors, IconData icon,
|
||||
String label, String status) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
_addBook(status);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
margin: const EdgeInsets.only(bottom: 4),
|
||||
child: Row(children: [
|
||||
Icon(icon, size: 22, color: colors.onSurface.withValues(alpha: 0.6)),
|
||||
const SizedBox(width: 12),
|
||||
Text(label,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colors.onSurface)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Build ──────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surface,
|
||||
floatingActionButton:
|
||||
(!_loading && _error == null && _localBook == null && _data != null)
|
||||
? FloatingActionButton(
|
||||
onPressed: _showAddSheet,
|
||||
backgroundColor: colors.primary,
|
||||
child: Icon(Icons.add, color: colors.onPrimary))
|
||||
: null,
|
||||
body: _loading
|
||||
? Center(child: CircularProgressIndicator(color: colors.primary, strokeWidth: 2))
|
||||
: _error != null
|
||||
? _buildError(colors)
|
||||
: _buildBody(colors),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildError(ColorScheme colors) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 48, color: colors.onSurface.withValues(alpha: 0.2)),
|
||||
const SizedBox(height: 16),
|
||||
Text(_error!, style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() { _loading = true; _error = null; });
|
||||
_load();
|
||||
},
|
||||
child: Text('重试', style: TextStyle(color: colors.primary))),
|
||||
],
|
||||
));
|
||||
}
|
||||
|
||||
Widget _buildBody(ColorScheme colors) {
|
||||
final m = _data!;
|
||||
final cover = m['cover'] ?? '';
|
||||
final title = m['title'] ?? '';
|
||||
final author = m['author'] ?? '';
|
||||
final press = m['press'] ?? '';
|
||||
final isbn = m['isbn'] ?? '';
|
||||
final year = m['publishedDate'] ?? '';
|
||||
final pages = m['pagination'];
|
||||
final coverUrl = cover.toString().isNotEmpty ? _resolveCoverUrl(cover.toString()) : '';
|
||||
|
||||
return Column(children: [
|
||||
// 顶部固定区域
|
||||
Container(
|
||||
color: colors.surface,
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Row(children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh,
|
||||
shape: BoxShape.circle),
|
||||
child: Icon(Icons.arrow_back, size: 20, color: colors.onSurface)),
|
||||
),
|
||||
]),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: SizedBox(
|
||||
width: 110,
|
||||
height: 160,
|
||||
child: coverUrl.isNotEmpty
|
||||
? Image.network(coverUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => _coverPlaceholder(colors))
|
||||
: _coverPlaceholder(colors),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: colors.onSurface)),
|
||||
if (author.toString().isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(author, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6))),
|
||||
],
|
||||
if (press.toString().isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(press, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.45))),
|
||||
],
|
||||
if (year.toString().isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text('出版年份:$year', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||
],
|
||||
if (isbn.toString().isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text('ISBN:$isbn', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||
],
|
||||
if (pages != null && pages != 0) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text('页数:$pages', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||
],
|
||||
if (_localBook != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
_buildLocalStatus(colors),
|
||||
],
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
|
||||
// Tab 栏
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: colors.outlineVariant, width: 0.5))),
|
||||
child: Row(children: [
|
||||
_buildTabButton('基础信息', 0),
|
||||
_buildTabButton('国图信息', 1),
|
||||
_buildTabButton('网购地址', 2),
|
||||
_buildTabButton('书籍目录', 3),
|
||||
]),
|
||||
),
|
||||
|
||||
// 内容区
|
||||
Expanded(
|
||||
child: _currentTab == 0
|
||||
? _buildBasicInfo(colors)
|
||||
: _currentTab == 1
|
||||
? _buildOpacTab(colors)
|
||||
: _currentTab == 2
|
||||
? _buildOnlineTab(colors)
|
||||
: _buildCatalogTab(colors),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _buildTabButton(String label, int index) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final selected = _currentTab == index;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _currentTab = index),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 11),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: selected ? colors.primary : Colors.transparent,
|
||||
width: 2))),
|
||||
child: Text(label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
|
||||
color: selected
|
||||
? colors.primary
|
||||
: colors.onSurface.withValues(alpha: 0.4))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── 基础信息 Tab ──────────────────────────────────────────
|
||||
|
||||
Widget _buildBasicInfo(ColorScheme colors) {
|
||||
final m = _data!;
|
||||
final tags = m['tags'] ?? '';
|
||||
final sub1 = m['sub1'] ?? '';
|
||||
final sub2 = m['sub2'] ?? '';
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 40),
|
||||
children: [
|
||||
// 分类
|
||||
_buildSectionTitle(colors, '分类', Icons.category_outlined),
|
||||
const SizedBox(height: 6),
|
||||
if (sub1.toString().isNotEmpty)
|
||||
_buildChipWrap(colors, sub1.toString().split(RegExp(r'[,,]')))
|
||||
else
|
||||
_buildEmptyHint(colors),
|
||||
const SizedBox(height: 16),
|
||||
// 标签
|
||||
_buildSectionTitle(colors, '标签', Icons.sell_outlined),
|
||||
const SizedBox(height: 6),
|
||||
if (tags.toString().isNotEmpty)
|
||||
_buildChipWrap(colors, tags.toString().split(RegExp(r'[,,]')))
|
||||
else
|
||||
_buildEmptyHint(colors),
|
||||
const SizedBox(height: 16),
|
||||
// 内容简介
|
||||
_buildSectionTitle(colors, '内容简介', Icons.article_outlined),
|
||||
const SizedBox(height: 8),
|
||||
if (sub2.toString().isNotEmpty)
|
||||
Text(sub2.toString().replaceAll(RegExp(r'<[^>]*>'), ''),
|
||||
style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.7), height: 1.7))
|
||||
else
|
||||
_buildEmptyHint(colors),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyHint(ColorScheme colors) {
|
||||
return Text('暂无该信息数据',
|
||||
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.3)));
|
||||
}
|
||||
|
||||
// ── 国图信息 Tab ──────────────────────────────────────────
|
||||
|
||||
Widget _buildOpacTab(ColorScheme colors) {
|
||||
final opacStr = _data?['opacInfo'];
|
||||
Map<String, dynamic>? opac;
|
||||
if (opacStr != null && opacStr.toString().isNotEmpty) {
|
||||
try { opac = json.decode(opacStr.toString()); } catch (_) {}
|
||||
}
|
||||
if (opac == null) {
|
||||
return Center(
|
||||
child: Text('暂无国图信息',
|
||||
style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.35))));
|
||||
}
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 40),
|
||||
children: [
|
||||
_buildOpacInfo(colors, opac),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ── 网购地址 Tab ──────────────────────────────────────────
|
||||
|
||||
Widget _buildOnlineTab(ColorScheme colors) {
|
||||
final onlineStr = _data?['online'];
|
||||
List<Map<String, dynamic>> links = [];
|
||||
if (onlineStr != null && onlineStr.toString().isNotEmpty) {
|
||||
try {
|
||||
final list = json.decode(onlineStr.toString()) as List;
|
||||
links = list.map((e) => e as Map<String, dynamic>).toList();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (links.isEmpty) {
|
||||
return Center(
|
||||
child: Text('暂无网购地址',
|
||||
style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.35))));
|
||||
}
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 40),
|
||||
itemCount: links.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) => _buildOnlineLink(colors, links[index]),
|
||||
);
|
||||
}
|
||||
|
||||
// ── 书籍目录 Tab ──────────────────────────────────────────
|
||||
|
||||
Widget _buildCatalogTab(ColorScheme colors) {
|
||||
if (_catalogLoading) {
|
||||
return Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: colors.primary)));
|
||||
}
|
||||
if (_catalog == null || _catalog!.isEmpty) {
|
||||
return Center(
|
||||
child: Text('暂无目录信息',
|
||||
style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.35))));
|
||||
}
|
||||
final lines = _catalog!.split('\n').where((l) => l.trim().isNotEmpty).toList();
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 40),
|
||||
itemCount: lines.length,
|
||||
itemBuilder: (context, index) {
|
||||
final text = lines[index].trim();
|
||||
final level = _detectLevel(text);
|
||||
final indent = level * 16.0;
|
||||
final isMain = level == 0;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(left: 8 + indent, top: isMain ? 10 : 4, bottom: isMain ? 2 : 1),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (isMain)
|
||||
Container(
|
||||
width: 3, height: 14,
|
||||
margin: const EdgeInsets.only(right: 8, top: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.primary.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(1.5),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(text, style: TextStyle(
|
||||
fontSize: isMain ? 13.5 : 12.5,
|
||||
fontWeight: isMain ? FontWeight.w600 : FontWeight.w400,
|
||||
color: isMain ? colors.onSurface.withValues(alpha: 0.85) : colors.onSurface.withValues(alpha: 0.55),
|
||||
height: 1.4,
|
||||
)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── 通用组件 ──────────────────────────────────────────────
|
||||
|
||||
Widget _buildSectionTitle(ColorScheme colors, String title, IconData icon) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 15, color: colors.primary.withValues(alpha: 0.7)),
|
||||
const SizedBox(width: 6),
|
||||
Text(title, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChipWrap(ColorScheme colors, List<String> items) {
|
||||
return Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: items.where((t) => t.trim().isNotEmpty).map((t) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: colors.outlineVariant, width: 0.5),
|
||||
),
|
||||
child: Text(t.trim(), style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.65))),
|
||||
)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOpacInfo(ColorScheme colors, Map<String, dynamic> opac) {
|
||||
final items = <List<String>>[];
|
||||
if (opac['title'] != null && opac['title'].toString().isNotEmpty) items.add(['题名', opac['title'].toString()]);
|
||||
if (opac['authors'] != null) {
|
||||
final authors = (opac['authors'] as List).map((e) => e.toString()).join(';');
|
||||
if (authors.isNotEmpty) items.add(['作者', authors]);
|
||||
}
|
||||
if (opac['publisher'] != null && opac['publisher'].toString().isNotEmpty) items.add(['出版社', opac['publisher'].toString()]);
|
||||
if (opac['pubdate'] != null && opac['pubdate'].toString().isNotEmpty) items.add(['出版日期', opac['pubdate'].toString()]);
|
||||
if (opac['isbn'] != null && opac['isbn'].toString().isNotEmpty) items.add(['ISBN', opac['isbn'].toString()]);
|
||||
if (opac['clc'] != null && opac['clc'].toString().isNotEmpty) items.add(['中图分类号', opac['clc'].toString()]);
|
||||
if (opac['tags'] != null && opac['tags'].toString().isNotEmpty) items.add(['主题词', opac['tags'].toString()]);
|
||||
|
||||
if (items.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Column(
|
||||
children: items.map((pair) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: Text(pair[0], style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(pair[1], style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.75), height: 1.4)),
|
||||
),
|
||||
],
|
||||
),
|
||||
)).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOnlineLink(ColorScheme colors, Map<String, dynamic> link) {
|
||||
final source = link['source'] ?? '';
|
||||
final url = link['url'] ?? '';
|
||||
if (source.toString().isEmpty || url.toString().isEmpty) return const SizedBox.shrink();
|
||||
return GestureDetector(
|
||||
onTap: () => launchUrl(Uri.parse(url.toString()), mode: LaunchMode.externalApplication),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.link, size: 16, color: colors.primary.withValues(alpha: 0.6)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(source.toString(), style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface)),
|
||||
const SizedBox(height: 2),
|
||||
Text(url.toString(), style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.35)), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.chevron_right, size: 16, color: colors.onSurface.withValues(alpha: 0.3)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 检测目录层级:0=主章节, 1=子章节
|
||||
int _detectLevel(String line) {
|
||||
// 主章节:第X章、第X篇、Chapter X、数字+点开头(如 "1. ")
|
||||
if (RegExp(r'^第[一二三四五六七八九十百千\d]+[章篇部回卷]').hasMatch(line)) return 0;
|
||||
if (RegExp(r'^Chapter\s+\d+', caseSensitive: false).hasMatch(line)) return 0;
|
||||
if (RegExp(r'^\d+[\.\s、]').hasMatch(line)) return 0;
|
||||
if (RegExp(r'^[一二三四五六七八九十]+[、..]').hasMatch(line)) return 0;
|
||||
// 子章节:第X节、数字.数字(如 "1.1 ")
|
||||
if (RegExp(r'^第[一二三四五六七八九十百千\d]+[节]').hasMatch(line)) return 1;
|
||||
if (RegExp(r'^\d+\.\d+[\.\s、]').hasMatch(line)) return 1;
|
||||
if (RegExp(r'^[((]\d+[))]').hasMatch(line)) return 1;
|
||||
if (line.startsWith(' ') || line.startsWith('\t')) return 1;
|
||||
// 默认主章节
|
||||
return 0;
|
||||
}
|
||||
|
||||
Widget _coverPlaceholder(ColorScheme colors) {
|
||||
return Container(
|
||||
color: colors.surfaceContainerHighest,
|
||||
child: Center(
|
||||
child: Icon(Icons.menu_book_outlined,
|
||||
size: 32, color: colors.onSurface.withValues(alpha: 0.15))));
|
||||
}
|
||||
|
||||
Widget _buildLocalStatus(ColorScheme colors) {
|
||||
final status = _localBook!.status;
|
||||
final label = _statusLabel(status);
|
||||
Color dotColor;
|
||||
switch (status) {
|
||||
case 'read':
|
||||
dotColor = colors.primary;
|
||||
break;
|
||||
case 'reading':
|
||||
dotColor = const Color(0xFF666666);
|
||||
break;
|
||||
default:
|
||||
dotColor = const Color(0xFF999999);
|
||||
break;
|
||||
}
|
||||
return Row(children: [
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle)),
|
||||
const SizedBox(width: 6),
|
||||
Text('已在本地 · $label',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colors.onSurface.withValues(alpha: 0.6))),
|
||||
]);
|
||||
}
|
||||
}
|
||||
458
lib/pages/enhanced_search_settings_page.dart
Normal file
458
lib/pages/enhanced_search_settings_page.dart
Normal file
@@ -0,0 +1,458 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../utils/user_prefs.dart';
|
||||
import '../utils/server_config.dart';
|
||||
|
||||
/// 增强搜索设置页面
|
||||
class EnhancedSearchSettingsPage extends StatefulWidget {
|
||||
const EnhancedSearchSettingsPage({super.key});
|
||||
|
||||
@override
|
||||
State<EnhancedSearchSettingsPage> createState() =>
|
||||
_EnhancedSearchSettingsPageState();
|
||||
}
|
||||
|
||||
class _EnhancedSearchSettingsPageState
|
||||
extends State<EnhancedSearchSettingsPage> {
|
||||
final _userPrefs = UserPrefs();
|
||||
final _movieTokenController = TextEditingController();
|
||||
final _bookTokenController = TextEditingController();
|
||||
|
||||
bool _enabled = false;
|
||||
// null=未验证/检查中, true=有效, false=无效
|
||||
bool? _movieTokenValid;
|
||||
bool? _bookTokenValid;
|
||||
String? _movieTokenMessage;
|
||||
String? _bookTokenMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_enabled = _userPrefs.enhancedSearchEnabled;
|
||||
_movieTokenController.text = _userPrefs.movieSearchToken;
|
||||
_bookTokenController.text = _userPrefs.bookSearchToken;
|
||||
if (_enabled) _verifyTokens();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_movieTokenController.dispose();
|
||||
_bookTokenController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _verifyTokens() async {
|
||||
final movieToken = _movieTokenController.text.trim();
|
||||
final bookToken = _bookTokenController.text.trim();
|
||||
|
||||
if (movieToken.isNotEmpty) {
|
||||
_checkToken(movieToken, 'movie').then((result) async {
|
||||
if (!mounted) return;
|
||||
final valid = result != null && result['valid'] == true;
|
||||
if (valid) {
|
||||
setState(() {
|
||||
_movieTokenValid = true;
|
||||
_movieTokenMessage = result['messageString'] as String?;
|
||||
});
|
||||
} else {
|
||||
// 当前类型失败,用另一种类型重试
|
||||
final retry = await _checkToken(movieToken, 'book');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_movieTokenValid = false;
|
||||
_movieTokenMessage = retry != null && retry['valid'] == true
|
||||
? '该 Token 可能是书籍类型,请检查是否填错位置'
|
||||
: ((result != null ? result['messageString'] as String? : null) ?? '验证失败');
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (bookToken.isNotEmpty) {
|
||||
_checkToken(bookToken, 'book').then((result) async {
|
||||
if (!mounted) return;
|
||||
final valid = result != null && result['valid'] == true;
|
||||
if (valid) {
|
||||
setState(() {
|
||||
_bookTokenValid = true;
|
||||
_bookTokenMessage = result['messageString'] as String?;
|
||||
});
|
||||
} else {
|
||||
final retry = await _checkToken(bookToken, 'movie');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_bookTokenValid = false;
|
||||
_bookTokenMessage = retry != null && retry['valid'] == true
|
||||
? '该 Token 可能是影视类型,请检查是否填错位置'
|
||||
: ((result != null ? result['messageString'] as String? : null) ?? '验证失败');
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> _checkToken(String token, String type) async {
|
||||
try {
|
||||
final url = '${ServerConfig.vipBaseUrl}/api/token/check?token=$token&type=$type';
|
||||
final resp =
|
||||
await http.get(Uri.parse(url)).timeout(const Duration(seconds: 8));
|
||||
if (resp.statusCode == 200) {
|
||||
final data = json.decode(resp.body);
|
||||
if (data['code'] == 0 && data['data'] != null) {
|
||||
return data['data'] as Map<String, dynamic>;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _toggle(bool value) async {
|
||||
if (value) {
|
||||
await _userPrefs.setMovieSearchToken(_movieTokenController.text.trim());
|
||||
await _userPrefs.setBookSearchToken(_bookTokenController.text.trim());
|
||||
await _userPrefs.setEnhancedSearchEnabled(true);
|
||||
setState(() => _enabled = true);
|
||||
_verifyTokens();
|
||||
} else {
|
||||
await _userPrefs.setEnhancedSearchEnabled(false);
|
||||
setState(() {
|
||||
_enabled = false;
|
||||
_movieTokenValid = null;
|
||||
_bookTokenValid = null;
|
||||
_movieTokenMessage = null;
|
||||
_bookTokenMessage = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surface,
|
||||
appBar: AppBar(
|
||||
title: const Text('增强搜索'),
|
||||
actions: [
|
||||
if (_enabled)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
tooltip: '刷新验证',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_movieTokenValid = null;
|
||||
_bookTokenValid = null;
|
||||
_movieTokenMessage = null;
|
||||
_bookTokenMessage = null;
|
||||
});
|
||||
_verifyTokens();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
_buildSwitchRow(colors),
|
||||
const SizedBox(height: 16),
|
||||
_buildStatusBanner(colors),
|
||||
const SizedBox(height: 20),
|
||||
_buildSectionLabel(colors, '影视增强搜索 Token'),
|
||||
const SizedBox(height: 8),
|
||||
_buildTokenInput(
|
||||
colors: colors,
|
||||
controller: _movieTokenController,
|
||||
hint: '输入影视搜索 Token',
|
||||
valid: _movieTokenValid,
|
||||
message: _movieTokenMessage,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildSectionLabel(colors, '书籍增强搜索 Token'),
|
||||
const SizedBox(height: 8),
|
||||
_buildTokenInput(
|
||||
colors: colors,
|
||||
controller: _bookTokenController,
|
||||
hint: '输入书籍搜索 Token',
|
||||
valid: _bookTokenValid,
|
||||
message: _bookTokenMessage,
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
_buildSaveButton(colors),
|
||||
const SizedBox(height: 32),
|
||||
_buildTips(colors),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSwitchRow(ColorScheme colors) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: colors.outlineVariant, width: 0.5),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: _enabled
|
||||
? colors.primary.withValues(alpha: 0.1)
|
||||
: colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(Icons.manage_search,
|
||||
size: 18,
|
||||
color: _enabled
|
||||
? colors.primary
|
||||
: colors.onSurface.withValues(alpha: 0.5)),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('增强搜索',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colors.onSurface)),
|
||||
const SizedBox(height: 1),
|
||||
Text(_enabled ? '已开启' : '未开启',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: colors.onSurface.withValues(alpha: 0.4))),
|
||||
],
|
||||
),
|
||||
),
|
||||
Switch(
|
||||
value: _enabled,
|
||||
onChanged: _toggle,
|
||||
activeThumbColor: colors.primary,
|
||||
activeTrackColor: colors.primary.withValues(alpha: 0.3),
|
||||
inactiveThumbColor: colors.surface,
|
||||
inactiveTrackColor: colors.outline,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusBanner(ColorScheme colors) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: _enabled
|
||||
? const Color(0xFF16A34A).withValues(alpha: 0.08)
|
||||
: colors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: _enabled
|
||||
? const Color(0xFF16A34A).withValues(alpha: 0.3)
|
||||
: colors.outlineVariant,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_enabled ? Icons.check_circle_outline : Icons.info_outline,
|
||||
size: 18,
|
||||
color: _enabled
|
||||
? const Color(0xFF16A34A)
|
||||
: colors.onSurface.withValues(alpha: 0.4),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_enabled ? '增强搜索已开启' : '填写 Token 后开启增强搜索',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: _enabled
|
||||
? const Color(0xFF16A34A)
|
||||
: colors.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionLabel(ColorScheme colors, String text) {
|
||||
return Text(text,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colors.onSurface.withValues(alpha: 0.5)));
|
||||
}
|
||||
|
||||
Widget _buildTokenInput({
|
||||
required ColorScheme colors,
|
||||
required TextEditingController controller,
|
||||
required String hint,
|
||||
bool? valid,
|
||||
String? message,
|
||||
}) {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: colors.outlineVariant, width: 0.5),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 12),
|
||||
Icon(Icons.key,
|
||||
size: 16, color: colors.onSurface.withValues(alpha: 0.3)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
style: TextStyle(fontSize: 13, color: colors.onSurface),
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 13,
|
||||
color: colors.onSurface.withValues(alpha: 0.3)),
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
filled: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_enabled && valid != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6, left: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
valid ? Icons.check_circle : Icons.cancel,
|
||||
size: 14,
|
||||
color: valid ? const Color(0xFF16A34A) : colors.error,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message ?? (valid ? 'Token 有效' : 'Token 无效'),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: valid ? const Color(0xFF16A34A) : colors.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_enabled && valid == null && controller.text.trim().isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6, left: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 1.5,
|
||||
color: colors.onSurface.withValues(alpha: 0.3))),
|
||||
const SizedBox(width: 6),
|
||||
Text('验证中...',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: colors.onSurface.withValues(alpha: 0.4))),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _saveAndVerify() async {
|
||||
await _userPrefs.setMovieSearchToken(_movieTokenController.text.trim());
|
||||
await _userPrefs.setBookSearchToken(_bookTokenController.text.trim());
|
||||
setState(() {
|
||||
_movieTokenValid = null;
|
||||
_bookTokenValid = null;
|
||||
_movieTokenMessage = null;
|
||||
_bookTokenMessage = null;
|
||||
});
|
||||
_verifyTokens();
|
||||
}
|
||||
|
||||
Widget _buildSaveButton(ColorScheme colors) {
|
||||
return GestureDetector(
|
||||
onTap: _saveAndVerify,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.primary,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Center(
|
||||
child: Text('保存并验证', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: colors.onPrimary)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTips(ColorScheme colors) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('说明',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colors.onSurface.withValues(alpha: 0.4),
|
||||
letterSpacing: 0.5)),
|
||||
const SizedBox(height: 10),
|
||||
_tip(colors, '增强搜索可在线检索影视和书籍的详细信息'),
|
||||
_tip(colors, 'Token 过期或失效后需重新获取并填写'),
|
||||
_tip(colors, '作者会在 QQ 群不定期发放增强搜索的token'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tip(ColorScheme colors, String text) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 7),
|
||||
child: Icon(Icons.circle,
|
||||
size: 4, color: colors.onSurface.withValues(alpha: 0.25)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(text,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: colors.onSurface.withValues(alpha: 0.5),
|
||||
height: 1.5))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
120
lib/pages/legal_page.dart
Normal file
120
lib/pages/legal_page.dart
Normal file
@@ -0,0 +1,120 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../utils/server_config.dart';
|
||||
|
||||
/// 用户服务协议 / 隐私政策查看页面
|
||||
class LegalPage extends StatefulWidget {
|
||||
final String slug;
|
||||
final String title;
|
||||
|
||||
const LegalPage({super.key, required this.slug, required this.title});
|
||||
|
||||
@override
|
||||
State<LegalPage> createState() => _LegalPageState();
|
||||
}
|
||||
|
||||
class _LegalPageState extends State<LegalPage> {
|
||||
String _content = '';
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
|
||||
static final String _baseUrl = ServerConfig.baseUrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final resp = await http.get(
|
||||
Uri.parse('$_baseUrl/api/pages/${widget.slug}'),
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (resp.statusCode == 200) {
|
||||
final data = json.decode(resp.body);
|
||||
setState(() {
|
||||
_content = data['content'] ?? '';
|
||||
_isLoading = false;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_error = '暂无内容';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = '加载失败,请检查网络';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surface,
|
||||
appBar: AppBar(title: Text(widget.title)),
|
||||
body: _isLoading
|
||||
? Center(child: CircularProgressIndicator(color: colors.primary))
|
||||
: _error != null
|
||||
? _buildError(colors)
|
||||
: _buildContent(colors),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(ColorScheme colors) {
|
||||
return Markdown(
|
||||
data: _content,
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 40),
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
h1: TextStyle(fontSize: 22, fontWeight: FontWeight.w600, color: colors.onSurface, height: 1.4),
|
||||
h2: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface, height: 1.4),
|
||||
h3: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface, height: 1.4),
|
||||
p: TextStyle(fontSize: 14, color: colors.onSurface, height: 1.8),
|
||||
code: TextStyle(fontSize: 13, color: colors.onSurface, backgroundColor: colors.surfaceContainerHighest),
|
||||
codeblockDecoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHighest,
|
||||
border: Border.all(color: colors.outline),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
codeblockPadding: const EdgeInsets.all(12),
|
||||
blockquote: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), fontStyle: FontStyle.italic),
|
||||
blockquoteDecoration: BoxDecoration(
|
||||
border: Border(left: BorderSide(color: colors.onSurface.withValues(alpha: 0.4), width: 4)),
|
||||
),
|
||||
blockquotePadding: const EdgeInsets.only(left: 12),
|
||||
listBullet: TextStyle(fontSize: 14, color: colors.onSurface),
|
||||
listIndent: 24,
|
||||
a: const TextStyle(fontSize: 14, color: Color(0xFF4A90D9), decoration: TextDecoration.underline),
|
||||
horizontalRuleDecoration: BoxDecoration(
|
||||
border: Border(top: BorderSide(color: colors.outline, width: 0.5)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildError(ColorScheme colors) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.article_outlined, size: 48, color: colors.onSurface.withValues(alpha: 0.25)),
|
||||
const SizedBox(height: 16),
|
||||
Text(_error!, style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: () { setState(() { _isLoading = true; _error = null; }); _load(); },
|
||||
child: Text('重试', style: TextStyle(color: colors.primary)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<MainContentPage> {
|
||||
Widget _buildAppBar(BuildContext context) {
|
||||
return Consumer<AppProvider>(
|
||||
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<MainContentPage> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ─── 云备份 ──────────────────────────────────────────
|
||||
|
||||
Widget _buildCloudSyncButton(BuildContext context) {
|
||||
@@ -133,18 +162,18 @@ class _MainContentPageState extends State<MainContentPage> {
|
||||
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<MainContentPage> {
|
||||
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)),
|
||||
]),
|
||||
|
||||
910
lib/pages/movie_detail_page.dart
Normal file
910
lib/pages/movie_detail_page.dart
Normal file
@@ -0,0 +1,910 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../utils/server_config.dart';
|
||||
import '../utils/user_prefs.dart';
|
||||
import '../models/data_models.dart';
|
||||
import '../providers/app_provider.dart';
|
||||
import '../utils/image_path_helper.dart';
|
||||
import '../utils/toast_util.dart';
|
||||
|
||||
/// 影视详情页 - 在线版
|
||||
class MovieDetailPage extends StatefulWidget {
|
||||
final int vodId;
|
||||
const MovieDetailPage({super.key, required this.vodId});
|
||||
|
||||
@override
|
||||
State<MovieDetailPage> createState() => _MovieDetailPageState();
|
||||
}
|
||||
|
||||
class _MovieDetailPageState extends State<MovieDetailPage> {
|
||||
Map<String, dynamic>? _data;
|
||||
List<Map<String, dynamic>> _staffList = [];
|
||||
bool _loading = true;
|
||||
bool _staffLoading = false;
|
||||
String? _error;
|
||||
bool _expanded = false;
|
||||
Movie? _localMovie;
|
||||
int _currentTab = 0;
|
||||
int _detailStyle = 0; // 0: 紧凑, 1: 沉浸式
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final token = UserPrefs().movieSearchToken;
|
||||
try {
|
||||
final url =
|
||||
'${ServerConfig.vipBaseUrl}/api/movie/detail?vodId=${widget.vodId}&token=$token';
|
||||
final resp =
|
||||
await http.get(Uri.parse(url)).timeout(const Duration(seconds: 10));
|
||||
if (!mounted) return;
|
||||
if (resp.statusCode == 200) {
|
||||
final json_ = json.decode(resp.body);
|
||||
if (json_['code'] == 0 && json_['data'] != null) {
|
||||
setState(() {
|
||||
_data = json_['data'];
|
||||
_loading = false;
|
||||
});
|
||||
_loadStaff();
|
||||
_checkLocal();
|
||||
return;
|
||||
}
|
||||
}
|
||||
setState(() {
|
||||
_error = '加载失败';
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted)
|
||||
setState(() {
|
||||
_error = '网络错误';
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadStaff() async {
|
||||
final staffStr = _data?['vod_staff'] ?? '';
|
||||
if (staffStr.toString().isEmpty) return;
|
||||
final token = UserPrefs().movieSearchToken;
|
||||
setState(() {
|
||||
_staffLoading = true;
|
||||
});
|
||||
try {
|
||||
final url = '${ServerConfig.vipBaseUrl}/api/actor/staff-pic?token=$token';
|
||||
final resp = await http.post(Uri.parse(url),
|
||||
body: staffStr.toString(),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}).timeout(const Duration(seconds: 10));
|
||||
if (!mounted) return;
|
||||
if (resp.statusCode == 200) {
|
||||
final json_ = json.decode(resp.body);
|
||||
if (json_['code'] == 0 && json_['data'] != null) {
|
||||
setState(() {
|
||||
_staffList = (json_['data'] as List)
|
||||
.map((e) => e as Map<String, dynamic>)
|
||||
.toList();
|
||||
_staffLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
if (mounted)
|
||||
setState(() {
|
||||
_staffLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _checkLocal() {
|
||||
final name = _data?['vod_name'] ?? '';
|
||||
if (name.toString().isEmpty) return;
|
||||
final provider = context.read<AppProvider>();
|
||||
final match =
|
||||
provider.movies.where((m) => !m.isDeleted && m.title == name).toList();
|
||||
if (match.isNotEmpty) {
|
||||
setState(() {
|
||||
_localMovie = match.first;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addMovie(String status) async {
|
||||
final m = _data!;
|
||||
final name = m['vod_name'] ?? '';
|
||||
final director = m['vod_director'] ?? '';
|
||||
final actorStr = m['vod_actor'] ?? '';
|
||||
final classStr = m['vod_class'] ?? '';
|
||||
final yearStr = m['vod_year'] ?? '';
|
||||
final scoreStr = m['vod_score'] ?? '';
|
||||
final content = m['vod_content'] ?? m['vod_blurb'] ?? '';
|
||||
final pic = m['vod_pic'] ?? '';
|
||||
|
||||
DateTime? releaseDate;
|
||||
if (yearStr.toString().isNotEmpty) {
|
||||
releaseDate = DateTime.tryParse('${yearStr}-01-01');
|
||||
}
|
||||
|
||||
final movieId = const Uuid().v4();
|
||||
String? posterPath;
|
||||
|
||||
if (pic.toString().isNotEmpty) {
|
||||
try {
|
||||
final resp = await http.get(Uri.parse(pic.toString()), headers: {
|
||||
'User-Agent': 'Mozilla/5.0'
|
||||
}).timeout(const Duration(seconds: 15));
|
||||
if (resp.statusCode == 200 &&
|
||||
resp.bodyBytes.length < 10 * 1024 * 1024) {
|
||||
final fileName =
|
||||
'poster_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||
final targetPath = await ImagePathHelper.instance
|
||||
.getMoviePosterPath(movieId, fileName);
|
||||
await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath));
|
||||
await File(targetPath).writeAsBytes(resp.bodyBytes);
|
||||
posterPath = targetPath;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
final movie = Movie(
|
||||
id: movieId,
|
||||
title: name.toString(),
|
||||
posterPath: posterPath,
|
||||
releaseDate: releaseDate,
|
||||
directors: _splitStr(director.toString()),
|
||||
actors: _splitStr(actorStr.toString()),
|
||||
genres: _splitStr(classStr.toString()),
|
||||
summary: content.toString(),
|
||||
rating: double.tryParse(scoreStr.toString()),
|
||||
status: status,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
final provider = context.read<AppProvider>();
|
||||
await provider.addMovie(movie);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_localMovie = provider.movies.firstWhere((m) => m.id == movie.id);
|
||||
});
|
||||
ToastUtil.show(context, '已添加到${_statusLabel(status)}');
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _splitStr(String s) => s
|
||||
.split(RegExp(r'[,,/、]'))
|
||||
.map((e) => e.trim())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
String _statusLabel(String status) {
|
||||
switch (status) {
|
||||
case 'watched':
|
||||
return '已看';
|
||||
case 'watching':
|
||||
return '在看';
|
||||
case 'want_to_watch':
|
||||
return '想看';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
void _showAddSheet() {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colors.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 16, 16),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.onSurface.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(2)))),
|
||||
Text('添加到',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colors.onSurface)),
|
||||
const SizedBox(height: 14),
|
||||
_sheetItem(
|
||||
ctx, colors, Icons.check_circle_outline, '已看', 'watched'),
|
||||
_sheetItem(
|
||||
ctx, colors, Icons.play_circle_outline, '在看', 'watching'),
|
||||
_sheetItem(
|
||||
ctx, colors, Icons.bookmark_outline, '想看', 'want_to_watch'),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sheetItem(BuildContext ctx, ColorScheme colors, IconData icon,
|
||||
String label, String status) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
_addMovie(status);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
margin: const EdgeInsets.only(bottom: 4),
|
||||
child: Row(children: [
|
||||
Icon(icon, size: 22, color: colors.onSurface.withValues(alpha: 0.6)),
|
||||
const SizedBox(width: 12),
|
||||
Text(label,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colors.onSurface)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Build ──────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surface,
|
||||
floatingActionButton:
|
||||
(!_loading && _error == null && _localMovie == null && _data != null)
|
||||
? FloatingActionButton(
|
||||
onPressed: _showAddSheet,
|
||||
backgroundColor: colors.primary,
|
||||
child: Icon(Icons.add, color: colors.onPrimary))
|
||||
: null,
|
||||
body: _loading
|
||||
? Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: colors.primary, strokeWidth: 2))
|
||||
: _error != null
|
||||
? _buildError(colors)
|
||||
: _buildBody(colors),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildError(ColorScheme colors) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.error_outline,
|
||||
size: 48, color: colors.onSurface.withValues(alpha: 0.2)),
|
||||
const SizedBox(height: 16),
|
||||
Text(_error!,
|
||||
style: TextStyle(
|
||||
fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
_load();
|
||||
},
|
||||
child: Text('重试', style: TextStyle(color: colors.primary))),
|
||||
],
|
||||
));
|
||||
}
|
||||
|
||||
Widget _buildBody(ColorScheme colors) {
|
||||
if (_detailStyle == 1) return _buildImmersiveBody(colors);
|
||||
final m = _data!;
|
||||
final pic = m['vod_pic'] ?? '';
|
||||
final name = m['vod_name'] ?? '';
|
||||
final isEnd = m['vod_isend'] ?? 0;
|
||||
final year = m['vod_year'] ?? '';
|
||||
final area = m['vod_area'] ?? '';
|
||||
final typeName = m['type_name'] ?? '';
|
||||
final classStr = m['vod_class'] ?? '';
|
||||
final score = m['vod_score'] ?? '';
|
||||
|
||||
final metaParts =
|
||||
[year, area].where((s) => s.toString().isNotEmpty).join(' · ');
|
||||
final typeParts =
|
||||
[typeName, classStr].where((s) => s.toString().isNotEmpty).join(' / ');
|
||||
|
||||
return Column(children: [
|
||||
// 顶部:AppBar + 海报信息区
|
||||
Container(
|
||||
color: colors.surface,
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(children: [
|
||||
// AppBar
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Row(children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh,
|
||||
shape: BoxShape.circle),
|
||||
child: Icon(Icons.arrow_back,
|
||||
size: 20, color: colors.onSurface)),
|
||||
),
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _detailStyle = _detailStyle == 0 ? 1 : 0),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height:36,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh,
|
||||
shape: BoxShape.circle),
|
||||
child: Icon(
|
||||
_detailStyle == 0
|
||||
? Icons.crop_landscape_rounded
|
||||
: Icons.grid_view_rounded,
|
||||
size: 18,
|
||||
color: colors.onSurface)),
|
||||
),
|
||||
]),
|
||||
),
|
||||
// 海报 + 信息
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 海报
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: SizedBox(
|
||||
width: 120,
|
||||
height: 170,
|
||||
child: pic.toString().isNotEmpty
|
||||
? Image.network(pic,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
_posterPlaceholder(colors))
|
||||
: _posterPlaceholder(colors),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// 信息
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(name,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colors.onSurface)),
|
||||
const SizedBox(height: 8),
|
||||
// 评分
|
||||
if (score.toString().isNotEmpty && score != '0.0') ...[
|
||||
Row(children: [
|
||||
Icon(Icons.star_rounded, size: 16, color: const Color(0xFFF59E0B)),
|
||||
const SizedBox(width: 3),
|
||||
Text('$score', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)),
|
||||
Text(' /10', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))),
|
||||
]),
|
||||
const SizedBox(height: 2),
|
||||
Text('评分来源于网络资源收集,并非官方评分', style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.25))),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
// 完结状态
|
||||
_endTag(isEnd),
|
||||
if (metaParts.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(metaParts,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: colors.onSurface
|
||||
.withValues(alpha: 0.5))),
|
||||
],
|
||||
if (typeParts.isNotEmpty) ...[
|
||||
const SizedBox(height: 3),
|
||||
Text(typeParts,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: colors.onSurface
|
||||
.withValues(alpha: 0.4)),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
// 本地状态
|
||||
if (_localMovie != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
_buildLocalStatus(colors),
|
||||
],
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
])),
|
||||
),
|
||||
|
||||
// Tab 栏
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: colors.outlineVariant, width: 0.5))),
|
||||
child: Row(children: [
|
||||
_buildTabButton('概要', 0),
|
||||
_buildTabButton('演职人员', 1),
|
||||
]),
|
||||
),
|
||||
|
||||
// 内容区
|
||||
Expanded(
|
||||
child:
|
||||
_currentTab == 0 ? _buildOverview(colors) : _buildStaffTab(colors),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── 沉浸式布局 ──────────────────────────────────────────
|
||||
|
||||
Widget _buildImmersiveBody(ColorScheme colors) {
|
||||
final m = _data!;
|
||||
final pic = m['vod_pic'] ?? '';
|
||||
final name = m['vod_name'] ?? '';
|
||||
final isEnd = m['vod_isend'] ?? 0;
|
||||
final year = m['vod_year'] ?? '';
|
||||
final area = m['vod_area'] ?? '';
|
||||
final typeName = m['type_name'] ?? '';
|
||||
final classStr = m['vod_class'] ?? '';
|
||||
final score = m['vod_score'] ?? '';
|
||||
final metaParts = [year, area].where((s) => s.toString().isNotEmpty).join(' · ');
|
||||
final typeParts = [typeName, classStr].where((s) => s.toString().isNotEmpty).join(' / ');
|
||||
|
||||
return Column(children: [
|
||||
// 全宽海报区
|
||||
Stack(children: [
|
||||
// 海报图
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 320,
|
||||
child: pic.toString().isNotEmpty
|
||||
? Image.network(pic, fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Container(color: colors.surfaceContainerHighest))
|
||||
: Container(color: colors.surfaceContainerHighest,
|
||||
child: Icon(Icons.movie_outlined, size: 64, color: colors.onSurface.withValues(alpha: 0.1))),
|
||||
),
|
||||
// 渐变遮罩
|
||||
Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.transparent, Colors.black.withValues(alpha: 0.8)],
|
||||
stops: const [0.35, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 顶部按钮
|
||||
SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Row(children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Container(
|
||||
width: 36, height: 36,
|
||||
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.3), shape: BoxShape.circle),
|
||||
child: const Icon(Icons.arrow_back, size: 20, color: Colors.white)),
|
||||
),
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _detailStyle = 0),
|
||||
child: Container(
|
||||
width: 36, height: 36,
|
||||
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.3), shape: BoxShape.circle),
|
||||
child: const Icon(Icons.grid_view_rounded, size: 18, color: Colors.white)),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
// 底部信息叠加
|
||||
Positioned(
|
||||
left: 16, right: 16, bottom: 18,
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
|
||||
Text(name, maxLines: 2, overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: Colors.white)),
|
||||
const SizedBox(height: 8),
|
||||
Row(children: [
|
||||
if (score.toString().isNotEmpty && score != '0.0') ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.star_rounded, size: 18, color: Colors.amber.shade400),
|
||||
const SizedBox(width: 3),
|
||||
Text('$score', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Colors.white)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
_endTag(isEnd),
|
||||
if (metaParts.isNotEmpty) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(metaParts, style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: 0.7))),
|
||||
],
|
||||
]),
|
||||
if (typeParts.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(typeParts, maxLines: 1, overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: 0.5))),
|
||||
],
|
||||
if (_localMovie != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
_buildLocalStatus(colors),
|
||||
],
|
||||
]),
|
||||
),
|
||||
]),
|
||||
|
||||
// Tab 栏
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: colors.outlineVariant, width: 0.5))),
|
||||
child: Row(children: [
|
||||
_buildTabButton('概要', 0),
|
||||
_buildTabButton('演职人员', 1),
|
||||
]),
|
||||
),
|
||||
|
||||
// 内容区
|
||||
Expanded(
|
||||
child: _currentTab == 0 ? _buildOverview(colors) : _buildStaffTab(colors),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _posterPlaceholder(ColorScheme colors) {
|
||||
return Container(
|
||||
color: colors.surfaceContainerHighest,
|
||||
child: Center(
|
||||
child: Icon(Icons.movie_outlined,
|
||||
size: 32, color: colors.onSurface.withValues(alpha: 0.15))));
|
||||
}
|
||||
|
||||
Widget _endTag(int isEnd) {
|
||||
final finished = isEnd == 1;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: finished
|
||||
? const Color(0xFF16A34A).withValues(alpha: 0.1)
|
||||
: const Color(0xFFF59E0B).withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(finished ? '已完结' : '连载中',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: finished
|
||||
? const Color(0xFF16A34A)
|
||||
: const Color(0xFFF59E0B))),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLocalStatus(ColorScheme colors) {
|
||||
final status = _localMovie!.status;
|
||||
final label = _statusLabel(status);
|
||||
Color dotColor;
|
||||
switch (status) {
|
||||
case 'watched':
|
||||
dotColor = colors.primary;
|
||||
break;
|
||||
case 'watching':
|
||||
dotColor = const Color(0xFF666666);
|
||||
break;
|
||||
default:
|
||||
dotColor = const Color(0xFF999999);
|
||||
break;
|
||||
}
|
||||
return Row(children: [
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle)),
|
||||
const SizedBox(width: 6),
|
||||
Text('已在本地 · $label',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colors.onSurface.withValues(alpha: 0.6))),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _buildTabButton(String label, int index) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final selected = _currentTab == index;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _currentTab = index),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 11),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: selected ? colors.primary : Colors.transparent,
|
||||
width: 2))),
|
||||
child: Text(label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
|
||||
color: selected
|
||||
? colors.primary
|
||||
: colors.onSurface.withValues(alpha: 0.4))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── 概要 Tab ──────────────────────────────────────────
|
||||
|
||||
Widget _buildOverview(ColorScheme colors) {
|
||||
final m = _data!;
|
||||
final isManual = m['is_manual_optimized'] ?? 0;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 40),
|
||||
children: [
|
||||
// 信息行
|
||||
_infoRow(colors, '导演', m['vod_director']),
|
||||
_infoRow(colors, '主演', _formatActors()),
|
||||
_infoRow(colors, '语言', m['vod_lang']),
|
||||
_infoRow(colors, '时长', m['vod_duration']),
|
||||
_infoRow(colors, '上映', m['vod_pubdate']),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 标签
|
||||
if (isManual == 1 || (m['vod_tag'] ?? '').toString().isNotEmpty) ...[
|
||||
_buildTags(colors),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// 分隔线
|
||||
Container(height: 0.5, color: colors.outlineVariant),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 简介
|
||||
_buildSynopsis(colors),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _infoRow(ColorScheme colors, String label, dynamic value) {
|
||||
final text = value?.toString() ?? '';
|
||||
if (text.isEmpty) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 5),
|
||||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
SizedBox(
|
||||
width: 44,
|
||||
child: Text(label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: colors.onSurface.withValues(alpha: 0.4)))),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(text,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: colors.onSurface.withValues(alpha: 0.75),
|
||||
height: 1.5))),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatActors() {
|
||||
final staffStr = _data?['vod_staff'] ?? '';
|
||||
if (staffStr.toString().isNotEmpty) {
|
||||
try {
|
||||
final staff = json.decode(staffStr.toString()) as List;
|
||||
final actors = staff.where((s) => s['position'] == '演员').toList();
|
||||
if (actors.isNotEmpty) {
|
||||
return actors.map((s) {
|
||||
final name = s['name'] ?? '';
|
||||
final role = s['role'] ?? '';
|
||||
if (role.toString().isNotEmpty) return '$name($role)';
|
||||
return name;
|
||||
}).join(',');
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
return _data?['vod_actor'] ?? '';
|
||||
}
|
||||
|
||||
Widget _buildTags(ColorScheme colors) {
|
||||
final m = _data!;
|
||||
final isManual = m['is_manual_optimized'] ?? 0;
|
||||
final tagStr = m['vod_tag'] ?? '';
|
||||
final tags =
|
||||
tagStr.toString().split(',').where((t) => t.trim().isNotEmpty).toList();
|
||||
return Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
if (isManual == 1)
|
||||
_tag('官方优化', const Color(0xFF16A34A), highlight: true),
|
||||
...tags.map(
|
||||
(t) => _tag(t.trim(), colors.onSurface.withValues(alpha: 0.5))),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tag(String text, Color color, {bool highlight = false}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: highlight
|
||||
? const Color(0xFF16A34A).withValues(alpha: 0.1)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: highlight
|
||||
? const Color(0xFF16A34A).withValues(alpha: 0.3)
|
||||
: color.withValues(alpha: 0.2),
|
||||
width: 0.5),
|
||||
),
|
||||
child: Text(text,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: highlight ? const Color(0xFF16A34A) : color)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSynopsis(ColorScheme colors) {
|
||||
final m = _data!;
|
||||
final blurb = m['vod_blurb'] ?? '';
|
||||
final content = m['vod_content'] ?? '';
|
||||
final fullText = content.toString().isNotEmpty
|
||||
? content.toString().replaceAll(RegExp(r'<[^>]*>'), '')
|
||||
: blurb.toString();
|
||||
final isLong = fullText.length > 120;
|
||||
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
AnimatedCrossFade(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
crossFadeState:
|
||||
_expanded ? CrossFadeState.showSecond : CrossFadeState.showFirst,
|
||||
firstChild: Text(fullText,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: colors.onSurface.withValues(alpha: 0.7),
|
||||
height: 1.8),
|
||||
maxLines: 4,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
secondChild: Text(fullText,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: colors.onSurface.withValues(alpha: 0.7),
|
||||
height: 1.8)),
|
||||
),
|
||||
if (isLong)
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _expanded = !_expanded),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(_expanded ? '收起' : '展开全文',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: colors.primary,
|
||||
fontWeight: FontWeight.w500)),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── 演职人员 Tab ──────────────────────────────────────────
|
||||
|
||||
Widget _buildStaffTab(ColorScheme colors) {
|
||||
if (_staffLoading)
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: colors.primary)));
|
||||
if (_staffList.isEmpty)
|
||||
return Center(
|
||||
child: Text('暂无演职信息',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: colors.onSurface.withValues(alpha: 0.35))));
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 40),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
crossAxisSpacing: 10,
|
||||
mainAxisSpacing: 14,
|
||||
childAspectRatio: 0.7),
|
||||
itemCount: _staffList.length,
|
||||
itemBuilder: (context, index) =>
|
||||
_buildStaffCard(colors, _staffList[index]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStaffCard(ColorScheme colors, Map<String, dynamic> s) {
|
||||
final name = s['name'] ?? '';
|
||||
final position = s['position'] ?? '';
|
||||
final role = s['role'] ?? '';
|
||||
final pic = s['actor_pic'] ?? '';
|
||||
final sub = [position, if (role.toString().isNotEmpty) role].join(' · ');
|
||||
|
||||
return Column(children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 100,
|
||||
child: pic.toString().isNotEmpty
|
||||
? Image.network(pic,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
_avatarPlaceholder(colors, name))
|
||||
: _avatarPlaceholder(colors, name),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(name,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colors.onSurface),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center),
|
||||
const SizedBox(height: 2),
|
||||
Text(sub,
|
||||
style: TextStyle(
|
||||
fontSize: 9, color: colors.onSurface.withValues(alpha: 0.4)),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _avatarPlaceholder(ColorScheme colors, String name) {
|
||||
final ch = name.isNotEmpty ? name.characters.first : '?';
|
||||
return Container(
|
||||
color: colors.surfaceContainerHighest,
|
||||
child: Center(
|
||||
child: Text(ch,
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colors.onSurface.withValues(alpha: 0.25)))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<MovieTabPage> {
|
||||
int _layoutStyle = 0;
|
||||
final List<Movie> _items = [];
|
||||
bool _hasMore = true;
|
||||
bool _isLoading = false;
|
||||
@@ -36,7 +34,6 @@ class _MovieTabPageState extends State<MovieTabPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_layoutStyle = UserPrefs().movieLayoutStyle;
|
||||
_scrollController = ScrollController()..addListener(_onScroll);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final provider = context.read<AppProvider>();
|
||||
@@ -165,7 +162,7 @@ class _MovieTabPageState extends State<MovieTabPage> {
|
||||
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<MovieTabPage> {
|
||||
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<MovieTabPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSkeleton() => _layoutStyle == 1 ? _buildListSkeleton() : const MovieSkeletonGrid();
|
||||
Widget _buildSkeleton() {
|
||||
final layoutStyle = context.read<AppProvider>().movieLayoutStyle;
|
||||
if (layoutStyle == 1) return _buildListSkeleton();
|
||||
if (layoutStyle == 2) return _buildCoverCardSkeleton();
|
||||
return const MovieSkeletonGrid();
|
||||
}
|
||||
|
||||
Widget _buildListSkeleton() {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
1065
lib/pages/online_search_page.dart
Normal file
1065
lib/pages/online_search_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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<ProfilePage> {
|
||||
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<ProfilePage> {
|
||||
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<ProfilePage> {
|
||||
}
|
||||
}
|
||||
|
||||
void _editNickname(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final controller = TextEditingController(text: _nickname);
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: colors.surface, elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
title: Text('修改昵称', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
|
||||
content: TextField(controller: controller,
|
||||
style: TextStyle(fontSize: 14, color: colors.onSurface),
|
||||
decoration: InputDecoration(
|
||||
hintText: '输入昵称',
|
||||
hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.3)),
|
||||
filled: true,
|
||||
fillColor: colors.surfaceContainerHighest,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none),
|
||||
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: colors.primary, width: 1.5)),
|
||||
)),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
final newNickname = controller.text.trim();
|
||||
if (newNickname.isNotEmpty) {
|
||||
await _userPrefs.setNickname(newNickname);
|
||||
setState(() => _nickname = newNickname);
|
||||
}
|
||||
Navigator.pop(context);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(backgroundColor: colors.primary, foregroundColor: colors.onPrimary, elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8)),
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _editMotto(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final controller = TextEditingController(text: _motto);
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: colors.surface, elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
title: Text('修改座右铭', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
|
||||
content: TextField(controller: controller, maxLines: 2,
|
||||
style: TextStyle(fontSize: 14, color: colors.onSurface),
|
||||
decoration: InputDecoration(
|
||||
hintText: '输入座右铭',
|
||||
hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.3)),
|
||||
filled: true,
|
||||
fillColor: colors.surfaceContainerHighest,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none),
|
||||
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: colors.primary, width: 1.5)),
|
||||
)),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
final newMotto = controller.text.trim();
|
||||
await _userPrefs.setMotto(newMotto);
|
||||
setState(() => _motto = newMotto);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(backgroundColor: colors.primary, foregroundColor: colors.onPrimary, elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8)),
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 备份弹窗 ────────────────────────────────────────────────────────
|
||||
|
||||
void _showBackupOptions(BuildContext context) {
|
||||
@@ -708,6 +623,20 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
_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<SettingsPage> {
|
||||
),
|
||||
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<SettingsPage> {
|
||||
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<SettingsPage> {
|
||||
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<SettingsPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLinkItem({required BuildContext context, required IconData icon, required String title, required String subtitle, required String url}) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return InkWell(
|
||||
onTap: () => _launchUrl(context, url),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)), child: Icon(icon, color: colors.onSurface.withValues(alpha: 0.6), size: 18)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(title, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface)),
|
||||
const SizedBox(height: 2),
|
||||
Text(subtitle, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||
]),
|
||||
),
|
||||
Icon(Icons.open_in_new, color: colors.onSurface.withValues(alpha: 0.25), size: 18),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionItem({required IconData icon, required String title, required String subtitle, required VoidCallback onTap}) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return InkWell(
|
||||
@@ -1081,10 +1084,6 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
} 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<LayoutSettingsPage> {
|
||||
_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<LayoutSettingsPage> {
|
||||
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<AppProvider>().setMovieLayoutStyle(value);
|
||||
case 'book': await _userPrefs.setBookLayoutStyle(value); setState(() => _bookLayout = value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,16 +49,16 @@ class _BackupPageState extends State<BackupPage> {
|
||||
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<BackupPage> {
|
||||
isLoading: _isExporting,
|
||||
onTap: _exportData,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 8),
|
||||
_buildActionCard(
|
||||
colors: colors,
|
||||
title: '导入数据',
|
||||
@@ -80,7 +80,7 @@ class _BackupPageState extends State<BackupPage> {
|
||||
isDestructive: true,
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 使用说明
|
||||
_buildInfoSection(colors),
|
||||
@@ -126,7 +126,7 @@ class _BackupPageState extends State<BackupPage> {
|
||||
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<BackupPage> {
|
||||
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<BackupPage> {
|
||||
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<BackupPage> {
|
||||
),
|
||||
],
|
||||
),
|
||||
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<BackupPage> {
|
||||
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<BackupPage> {
|
||||
: Text(
|
||||
buttonText,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colors.onPrimary,
|
||||
),
|
||||
@@ -219,7 +215,7 @@ class _BackupPageState extends State<BackupPage> {
|
||||
/// 构建信息说明区域
|
||||
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<BackupPage> {
|
||||
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<BackupPage> {
|
||||
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<BackupPage> {
|
||||
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<BackupPage> {
|
||||
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<BackupPage> {
|
||||
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<BackupPage> {
|
||||
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<BackupPage> {
|
||||
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<BackupPage> {
|
||||
/// 构建自动备份区域 - 紧凑一行
|
||||
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<BackupPage> {
|
||||
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<BackupPage> {
|
||||
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<BackupPage> {
|
||||
: Text(
|
||||
'自动本地备份',
|
||||
style: TextStyle(
|
||||
fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface),
|
||||
fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface),
|
||||
),
|
||||
),
|
||||
Switch(
|
||||
|
||||
@@ -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, '建议定期备份到本地或云盘'),
|
||||
|
||||
@@ -149,7 +149,7 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
|
||||
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<WebDAVSyncPage> {
|
||||
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<WebDAVSyncPage> {
|
||||
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<WebDAVSyncPage> {
|
||||
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<WebDAVSyncPage> {
|
||||
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<WebDAVSyncPage> {
|
||||
),
|
||||
],
|
||||
|
||||
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<WebDAVSyncPage> {
|
||||
|
||||
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<WebDAVSyncPage> {
|
||||
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<WebDAVSyncPage> {
|
||||
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<WebDAVSyncPage> {
|
||||
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<WebDAVSyncPage> {
|
||||
required ValueChanged<bool>? 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<WebDAVSyncPage> {
|
||||
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<WebDAVSyncPage> {
|
||||
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<WebDAVSyncPage> {
|
||||
|
||||
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),
|
||||
|
||||
@@ -116,26 +116,53 @@ class _TagManagementPageState extends State<TagManagementPage> {
|
||||
]),
|
||||
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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<List<ChangelogItem>> 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');
|
||||
|
||||
16
lib/utils/server_config.dart
Normal file
16
lib/utils/server_config.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 服务端地址配置
|
||||
class ServerConfig {
|
||||
ServerConfig._();
|
||||
|
||||
static final String baseUrl = kDebugMode
|
||||
? 'http://192.168.31.48:27047'
|
||||
: 'http://api.mooknote.iletter.top';
|
||||
|
||||
static final String apiBase = '$baseUrl/api';
|
||||
|
||||
static final String vipBaseUrl = kDebugMode
|
||||
? 'http://192.168.31.48:8081'
|
||||
: 'http://vipapi.mooknote.iletter.top';
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -133,6 +133,49 @@ class UserPrefs {
|
||||
String get deviceId => prefs.getString('deviceId') ?? '';
|
||||
Future<bool> setDeviceId(String value) => prefs.setString('deviceId', value);
|
||||
|
||||
// ========== 搜索历史 ==========
|
||||
|
||||
/// 搜索历史记录
|
||||
List<String> get searchHistory => prefs.getStringList('searchHistory') ?? [];
|
||||
Future<bool> setSearchHistory(List<String> value) => prefs.setStringList('searchHistory', value);
|
||||
|
||||
/// 添加搜索记录(最多 20 条)
|
||||
Future<void> addSearchHistory(String keyword) async {
|
||||
final list = searchHistory;
|
||||
list.remove(keyword);
|
||||
list.insert(0, keyword);
|
||||
if (list.length > 50) { list.removeRange(50, list.length); }
|
||||
await setSearchHistory(list);
|
||||
}
|
||||
|
||||
/// 删除单条搜索记录
|
||||
Future<void> removeSearchHistory(String keyword) async {
|
||||
final list = searchHistory;
|
||||
list.remove(keyword);
|
||||
await setSearchHistory(list);
|
||||
}
|
||||
|
||||
/// 清空搜索历史
|
||||
Future<void> clearSearchHistory() => setSearchHistory([]);
|
||||
|
||||
// ========== 增强搜索 ==========
|
||||
|
||||
/// 是否开启增强搜索
|
||||
bool get enhancedSearchEnabled => prefs.getBool('enhancedSearchEnabled') ?? false;
|
||||
Future<bool> setEnhancedSearchEnabled(bool value) => prefs.setBool('enhancedSearchEnabled', value);
|
||||
|
||||
/// 影视增强搜索 Token
|
||||
String get movieSearchToken => prefs.getString('movieSearchToken') ?? '';
|
||||
Future<bool> setMovieSearchToken(String value) => prefs.setString('movieSearchToken', value);
|
||||
|
||||
/// 书籍增强搜索 Token
|
||||
String get bookSearchToken => prefs.getString('bookSearchToken') ?? '';
|
||||
Future<bool> setBookSearchToken(String value) => prefs.setString('bookSearchToken', value);
|
||||
|
||||
/// 上次搜索的 Tab: 0=影视, 1=书籍
|
||||
int get lastSearchTab => prefs.getInt('lastSearchTab') ?? 0;
|
||||
Future<bool> setLastSearchTab(int value) => prefs.setInt('lastSearchTab', value);
|
||||
|
||||
// ========== 版本更新 ==========
|
||||
|
||||
/// 已忽略的版本号(不再提示更新)
|
||||
|
||||
Reference in New Issue
Block a user