diff --git a/lib/main.dart b/lib/main.dart index 54b5f60..bae362a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -29,10 +29,10 @@ void main() async { // 初始化数据库 final appProvider = AppProvider(); await appProvider.initDatabase(); - // 检查并恢复本地自动备份 - await _initAutoBackup(); - // 启动匿名用户统计(需配置服务器地址后生效) - await _initUsageStats(); + appProvider.initMainTabIndex(); + // 以下初始化不阻塞界面显示 + unawaited(_initAutoBackup()); + unawaited(_initUsageStats()); runApp(MyApp(appProvider: appProvider)); } diff --git a/lib/pages/book/book_form_page.dart b/lib/pages/book/book_form_page.dart index bd1baae..cc72e86 100644 --- a/lib/pages/book/book_form_page.dart +++ b/lib/pages/book/book_form_page.dart @@ -192,10 +192,11 @@ class _BookFormPageState extends State { height: 90, child: _buildInfoCard( label: '别名', - value: _alternateTitles.isEmpty - ? '' + value: _alternateTitles.isEmpty + ? '' : '${_alternateTitles.length}个:${_alternateTitles.join('、')}', icon: Icons.alternate_email_outlined, + scrollable: true, onTap: () => _showMultiValueDialog( title: '添加别名', initialValues: _alternateTitles, @@ -245,16 +246,24 @@ class _BookFormPageState extends State { height: 90, child: _buildInfoCard( label: '类型', - value: _genres.isEmpty - ? '' + value: _genres.isEmpty + ? '' : '${_genres.length}个:${_genres.join('、')}', icon: Icons.category_outlined, - onTap: () => _showMultiValueDialog( - title: '添加类型', - initialValues: _genres, - hint: '如:小说、历史、传记', - onConfirm: (values) => setState(() => _genres = values), - ), + onTap: () async { + final provider = context.read(); + final tags = await provider.getTags('book_genre'); + final existingNames = tags.map((t) => t['name'] as String).toList(); + if (mounted) { + _showMultiValueDialog( + title: '添加类型', + initialValues: _genres, + hint: '如:小说、历史、传记', + existingTags: existingNames, + onConfirm: (values) => setState(() => _genres = values), + ); + } + }, ), ), SizedBox( @@ -1376,6 +1385,7 @@ class _BookFormPageState extends State { required List initialValues, required String hint, required Function(List) onConfirm, + List existingTags = const [], }) async { final result = await showDialog>( context: context, @@ -1383,9 +1393,10 @@ class _BookFormPageState extends State { title: title, initialValues: initialValues, hint: hint, + existingTags: existingTags, ), ); - + if (result != null) { onConfirm(result); } @@ -1481,11 +1492,13 @@ class _MultiValueDialog extends StatefulWidget { final String title; final List initialValues; final String hint; + final List existingTags; const _MultiValueDialog({ required this.title, required this.initialValues, required this.hint, + this.existingTags = const [], }); @override @@ -1524,8 +1537,22 @@ class _MultiValueDialogState extends State<_MultiValueDialog> { }); } + void _toggleExistingTag(String tag) { + setState(() { + if (values.contains(tag)) { + values.remove(tag); + } else { + values.add(tag); + } + }); + } + @override Widget build(BuildContext context) { + final availableTags = widget.existingTags + .where((t) => !values.contains(t)) + .toList(); + return AlertDialog( backgroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), @@ -1535,83 +1562,174 @@ class _MultiValueDialogState extends State<_MultiValueDialog> { ), content: SizedBox( width: double.maxFinite, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 已添加的值列表 - if (values.isNotEmpty) - Wrap( - spacing: 8, - runSpacing: 8, - children: values.asMap().entries.map((entry) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: const Color(0xFFF5F5F5), - borderRadius: BorderRadius.circular(6), - ), - child: Row( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.55, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 可滚动的标签区域 + if (values.isNotEmpty || availableTags.isNotEmpty) + Flexible( + child: SingleChildScrollView( + child: Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - entry.value, - style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)), - ), - const SizedBox(width: 4), - GestureDetector( - onTap: () => _removeValue(entry.key), - child: const Icon(Icons.close, size: 14, color: Color(0xFF999999)), - ), + // 已选择的标签 + if (values.isNotEmpty) ...[ + const Text( + '已选择', + style: TextStyle(fontSize: 12, color: Color(0xFFAAAAAA)), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: values.map((v) { + return Container( + padding: const EdgeInsets.only(left: 12, right: 6, top: 7, bottom: 7), + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + v, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + ), + const SizedBox(width: 4), + GestureDetector( + onTap: () { + setState(() => values.remove(v)); + }, + child: Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.3), + shape: BoxShape.circle, + ), + child: const Icon(Icons.close, size: 12, color: Colors.white), + ), + ), + ], + ), + ); + }).toList(), + ), + if (availableTags.isNotEmpty) ...[ + const SizedBox(height: 16), + const Divider(height: 0.5, color: Color(0xFFEEEEEE)), + const SizedBox(height: 16), + ], + ], + // 已有类型 + if (availableTags.isNotEmpty) ...[ + const Text( + '已有类型', + style: TextStyle(fontSize: 12, color: Color(0xFFAAAAAA)), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: availableTags.map((tag) { + return GestureDetector( + onTap: () { + setState(() => values.add(tag)); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: const Color(0xFFE8E8E8), + width: 0.5, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.add, size: 14, color: Color(0xFF999999)), + const SizedBox(width: 4), + Text( + tag, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Color(0xFF555555), + ), + ), + ], + ), + ), + ); + }).toList(), + ), + ], ], ), - ); - }).toList(), - ), - if (values.isNotEmpty) const SizedBox(height: 16), - // 输入框 - Row( - children: [ - Expanded( - child: TextField( - controller: controller, - autofocus: true, - style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A)), - decoration: InputDecoration( - hintText: widget.hint, - hintStyle: const TextStyle(fontSize: 14, color: Color(0xFFAAAAAA)), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: Color(0xFFE0E0E0)), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: Color(0xFFE0E0E0)), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: Color(0xFF1A1A1A)), - ), - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), - ), - onSubmitted: _addValue, - ), - ), - const SizedBox(width: 8), - GestureDetector( - onTap: () => _addValue(controller.text), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: const Color(0xFF1A1A1A), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon(Icons.add, size: 20, color: Colors.white), ), ), + // 分隔线 + 固定底部输入框 + if (values.isNotEmpty || availableTags.isNotEmpty) ...[ + const SizedBox(height: 12), + const Divider(height: 0.5, color: Color(0xFFEEEEEE)), + const SizedBox(height: 12), ], - ), - ], + Row( + children: [ + Expanded( + child: TextField( + controller: controller, + autofocus: true, + style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A)), + decoration: InputDecoration( + hintText: widget.hint, + hintStyle: const TextStyle(fontSize: 14, color: Color(0xFFAAAAAA)), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFFE0E0E0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFFE0E0E0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFF1A1A1A)), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + ), + onSubmitted: _addValue, + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: () => _addValue(controller.text), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.add, size: 20, color: Colors.white), + ), + ), + ], + ), + ], + ), ), ), actions: [ @@ -1621,7 +1739,6 @@ class _MultiValueDialogState extends State<_MultiValueDialog> { ), ElevatedButton( onPressed: () { - // 如果输入框还有内容,先添加 _addValue(controller.text); Navigator.pop(context, values); }, diff --git a/lib/pages/main_content_page.dart b/lib/pages/main_content_page.dart index eb01fa9..95f9e74 100644 --- a/lib/pages/main_content_page.dart +++ b/lib/pages/main_content_page.dart @@ -427,79 +427,108 @@ class _MainContentPageState extends State { } } + IconData _getTabIcon(String label) { + switch (label) { + case '影视': + return Icons.movie_outlined; + case '阅读': + return Icons.menu_book_outlined; + case '笔记': + return Icons.notes; + default: + return Icons.circle; + } + } + /// 构建标签栏 Widget _buildTabBar(BuildContext context) { return Consumer( builder: (context, provider, child) { final tabs = _enabledTabs; - // 如果当前选中的标签被禁用了,切换到第一个启用的标签 final currentEnabledIndex = _mapToEnabledTabIndex(provider.mainTabIndex); final safeIndex = currentEnabledIndex < tabs.length ? currentEnabledIndex : 0; return Container( decoration: const BoxDecoration( color: Colors.white, - border: Border( - bottom: BorderSide(color: Color(0xFFE5E5E5), width: 0.5), - ), ), - child: Row( - children: tabs.asMap().entries.map((entry) { - final index = entry.key; - final tab = entry.value; - return _buildTabItem( - context, - tab.label, - index, - safeIndex, - () => provider.setMainTabIndex(tab.originalIndex), - ); - }).toList(), - ), - ); - }, - ); - } - - /// 构建单个标签项 - Widget _buildTabItem( - BuildContext context, - String label, - int index, - int currentIndex, - VoidCallback onTap, - ) { - final isSelected = index == currentIndex; - - return Expanded( - child: InkWell( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 16), child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text( - label, - style: TextStyle( - fontSize: 15, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, - color: isSelected - ? const Color(0xFF1A1A1A) - : const Color(0xFF999999), + Padding( + padding: const EdgeInsets.only(left: 20, right: 20, top: 14), + child: Row( + children: tabs.asMap().entries.map((entry) { + final index = entry.key; + final tab = entry.value; + final isSelected = index == safeIndex; + return Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => provider.setMainTabIndex(tab.originalIndex), + child: Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _getTabIcon(tab.label), + size: 22, + color: isSelected ? const Color(0xFF1A1A1A) : const Color(0xFFB0B0B0), + ), + const SizedBox(height: 6), + Text( + tab.label, + style: TextStyle( + fontSize: 13, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + color: isSelected ? const Color(0xFF1A1A1A) : const Color(0xFFB0B0B0), + ), + ), + ], + ), + ), + ), + ); + }).toList(), ), ), - if (isSelected) - Container( - margin: const EdgeInsets.only(top: 8), - width: 20, - height: 2, - color: const Color(0xFF1A1A1A), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: LayoutBuilder( + builder: (context, constraints) { + final indicatorWidth = 24.0; + final tabWidth = constraints.maxWidth / tabs.length; + final indicatorLeft = safeIndex * tabWidth + (tabWidth - indicatorWidth) / 2; + return SizedBox( + height: 3, + child: Stack( + children: [ + AnimatedPositioned( + duration: const Duration(milliseconds: 600), + curve: Curves.easeInOut, + left: indicatorLeft, + top: 0, + child: Container( + width: indicatorWidth, + height: 3, + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + borderRadius: BorderRadius.circular(1.5), + ), + ), + ), + ], + ), + ); + }, ), + ), + const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)), ], ), - ), - ), + ); + }, ); } diff --git a/lib/pages/movies/movie_form_page.dart b/lib/pages/movies/movie_form_page.dart index 4880de9..61c6d49 100644 --- a/lib/pages/movies/movie_form_page.dart +++ b/lib/pages/movies/movie_form_page.dart @@ -450,7 +450,7 @@ class _MovieFormPageState extends State { ? '' : '${_alternateTitles.length}个:${_alternateTitles.join('、')}', icon: Icons.alternate_email_outlined, - scrollHorizontal: true, + scrollable: true, onTap: () => _showMultiValueDialog( title: '添加别名', initialValues: _alternateTitles, @@ -519,16 +519,24 @@ class _MovieFormPageState extends State { height: 90, child: _buildInfoCard( label: '类型', - value: _genres.isEmpty - ? '' + value: _genres.isEmpty + ? '' : '${_genres.length}个:${_genres.join('、')}', icon: Icons.category_outlined, - onTap: () => _showMultiValueDialog( - title: '添加类型', - initialValues: _genres, - hint: '如:剧情、科幻、悬疑', - onConfirm: (values) => setState(() => _genres = values), - ), + onTap: () async { + final provider = context.read(); + final tags = await provider.getTags('movie_genre'); + final existingNames = tags.map((t) => t['name'] as String).toList(); + if (mounted) { + _showMultiValueDialog( + title: '添加类型', + initialValues: _genres, + hint: '如:剧情、科幻、悬疑', + existingTags: existingNames, + onConfirm: (values) => setState(() => _genres = values), + ); + } + }, ), ), @@ -721,6 +729,7 @@ class _MovieFormPageState extends State { required List initialValues, required Function(List) onConfirm, String hint = '', + List existingTags = const [], }) async { final result = await showDialog>( context: context, @@ -728,9 +737,10 @@ class _MovieFormPageState extends State { title: title, initialValues: initialValues, hint: hint, + existingTags: existingTags, ), ); - + if (result != null) { onConfirm(result); } @@ -1897,11 +1907,13 @@ class _MultiValueDialog extends StatefulWidget { final String title; final List initialValues; final String hint; + final List existingTags; const _MultiValueDialog({ required this.title, required this.initialValues, required this.hint, + this.existingTags = const [], }); @override @@ -1940,8 +1952,23 @@ class _MultiValueDialogState extends State<_MultiValueDialog> { }); } + void _toggleExistingTag(String tag) { + setState(() { + if (values.contains(tag)) { + values.remove(tag); + } else { + values.add(tag); + } + }); + } + @override Widget build(BuildContext context) { + // 未选中的已有标签 + final availableTags = widget.existingTags + .where((t) => !values.contains(t)) + .toList(); + return AlertDialog( backgroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), @@ -1951,83 +1978,174 @@ class _MultiValueDialogState extends State<_MultiValueDialog> { ), content: SizedBox( width: double.maxFinite, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 已添加的值列表 - if (values.isNotEmpty) - Wrap( - spacing: 8, - runSpacing: 8, - children: values.asMap().entries.map((entry) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: const Color(0xFFF5F5F5), - borderRadius: BorderRadius.circular(6), - ), - child: Row( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.55, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 可滚动的标签区域 + if (values.isNotEmpty || availableTags.isNotEmpty) + Flexible( + child: SingleChildScrollView( + child: Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - entry.value, - style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)), - ), - const SizedBox(width: 4), - GestureDetector( - onTap: () => _removeValue(entry.key), - child: const Icon(Icons.close, size: 14, color: Color(0xFF999999)), - ), + // 已选择的标签 + if (values.isNotEmpty) ...[ + const Text( + '已选择', + style: TextStyle(fontSize: 12, color: Color(0xFFAAAAAA)), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: values.map((v) { + return Container( + padding: const EdgeInsets.only(left: 12, right: 6, top: 7, bottom: 7), + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + v, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + ), + const SizedBox(width: 4), + GestureDetector( + onTap: () { + setState(() => values.remove(v)); + }, + child: Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.3), + shape: BoxShape.circle, + ), + child: const Icon(Icons.close, size: 12, color: Colors.white), + ), + ), + ], + ), + ); + }).toList(), + ), + if (availableTags.isNotEmpty) ...[ + const SizedBox(height: 16), + const Divider(height: 0.5, color: Color(0xFFEEEEEE)), + const SizedBox(height: 16), + ], + ], + // 已有类型 + if (availableTags.isNotEmpty) ...[ + const Text( + '已有类型', + style: TextStyle(fontSize: 12, color: Color(0xFFAAAAAA)), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: availableTags.map((tag) { + return GestureDetector( + onTap: () { + setState(() => values.add(tag)); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: const Color(0xFFE8E8E8), + width: 0.5, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.add, size: 14, color: Color(0xFF999999)), + const SizedBox(width: 4), + Text( + tag, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Color(0xFF555555), + ), + ), + ], + ), + ), + ); + }).toList(), + ), + ], ], ), - ); - }).toList(), - ), - if (values.isNotEmpty) const SizedBox(height: 16), - // 输入框 - Row( - children: [ - Expanded( - child: TextField( - controller: controller, - autofocus: true, - style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A)), - decoration: InputDecoration( - hintText: widget.hint, - hintStyle: const TextStyle(fontSize: 14, color: Color(0xFFAAAAAA)), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: Color(0xFFE0E0E0)), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: Color(0xFFE0E0E0)), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: Color(0xFF1A1A1A)), - ), - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), - ), - onSubmitted: _addValue, - ), - ), - const SizedBox(width: 8), - GestureDetector( - onTap: () => _addValue(controller.text), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: const Color(0xFF1A1A1A), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon(Icons.add, size: 20, color: Colors.white), ), ), + // 固定底部的输入框 + if (values.isNotEmpty || availableTags.isNotEmpty) ...[ + const SizedBox(height: 12), + const Divider(height: 0.5, color: Color(0xFFEEEEEE)), + const SizedBox(height: 12), ], - ), - ], + Row( + children: [ + Expanded( + child: TextField( + controller: controller, + autofocus: true, + style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A)), + decoration: InputDecoration( + hintText: widget.hint, + hintStyle: const TextStyle(fontSize: 14, color: Color(0xFFAAAAAA)), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFFE0E0E0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFFE0E0E0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFF1A1A1A)), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + ), + onSubmitted: _addValue, + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: () => _addValue(controller.text), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.add, size: 20, color: Colors.white), + ), + ), + ], + ), + ], + ), ), ), actions: [ @@ -2037,7 +2155,6 @@ class _MultiValueDialogState extends State<_MultiValueDialog> { ), ElevatedButton( onPressed: () { - // 如果输入框还有内容,先添加 _addValue(controller.text); Navigator.pop(context, values); }, diff --git a/lib/pages/note/note_tab_page.dart b/lib/pages/note/note_tab_page.dart index 8382828..6f7ba32 100644 --- a/lib/pages/note/note_tab_page.dart +++ b/lib/pages/note/note_tab_page.dart @@ -1,7 +1,9 @@ +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../providers/app_provider.dart'; import '../../models/data_models.dart'; +import '../../utils/user_prefs.dart'; import '../../widgets/note_list_item.dart'; /// 笔记标签页 @@ -13,42 +15,38 @@ class NoteTabPage extends StatefulWidget { } class _NoteTabPageState extends State { - // 使用分页加载 static const int _pageSize = 50; final List _displayedNotes = []; bool _isLoading = false; bool _hasMore = true; final ScrollController _scrollController = ScrollController(); + int _layoutStyle = 0; // 0: 列表, 1: 瀑布流 + @override void initState() { super.initState(); _scrollController.addListener(_onScroll); - // 延迟加载初始数据,避免阻塞UI + _layoutStyle = UserPrefs().noteLayoutStyle; WidgetsBinding.instance.addPostFrameCallback((_) { _loadMoreNotes(); }); } - // 用于检测Provider数据变化 int _lastNotesCount = 0; @override void didChangeDependencies() { super.didChangeDependencies(); - // 使用watch监听Provider数据变化 final provider = context.watch(); final allNotes = provider.notes; - - // 如果Provider中的笔记数量发生变化且本地列表已加载过数据 + if (allNotes.length != _lastNotesCount && _displayedNotes.isNotEmpty) { _lastNotesCount = allNotes.length; - // 清空本地列表并重新加载 setState(() { _displayedNotes.clear(); _hasMore = true; }); - // 延迟加载避免setState冲突 Future.microtask(() => _loadMoreNotes()); } else { _lastNotesCount = allNotes.length; @@ -73,14 +71,13 @@ class _NoteTabPageState extends State { setState(() => _isLoading = true); - // 使用微任务延迟加载,避免阻塞UI await Future.microtask(() { final provider = context.read(); final allNotes = provider.notes; - + final startIndex = _displayedNotes.length; final endIndex = (startIndex + _pageSize).clamp(0, allNotes.length); - + if (startIndex >= allNotes.length) { _hasMore = false; } else { @@ -109,75 +106,288 @@ class _NoteTabPageState extends State { Widget build(BuildContext context) { return Column( children: [ - // 笔记列表(分页加载) + // 笔记内容 Expanded( - child: _buildNoteList(context), + child: Consumer( + builder: (context, provider, child) { + final allNotes = provider.notes; + _syncDisplayedNotes(allNotes); + + if (allNotes.isEmpty && _displayedNotes.isEmpty) { + return _buildEmptyState(context); + } + + if (_layoutStyle == 1) { + return _buildWaterfallView(); + } + return _buildListView(); + }, + ), ), ], ); } - /// 构建笔记列表 - Widget _buildNoteList(BuildContext context) { - return Consumer( - builder: (context, provider, child) { - final allNotes = provider.notes; - - // 当Provider数据变化时,同步更新本地显示列表 - // 过滤掉已删除的笔记,保持顺序 - _syncDisplayedNotes(allNotes); - - if (allNotes.isEmpty && _displayedNotes.isEmpty) { - return _buildEmptyState(context); - } - - return RefreshIndicator( - onRefresh: _refresh, - color: const Color(0xFF1A1A1A), - backgroundColor: Colors.white, - child: ListView.builder( - controller: _scrollController, - padding: const EdgeInsets.fromLTRB(12, 10, 12, 100), - itemCount: _displayedNotes.length + (_hasMore ? 1 : 0), - itemBuilder: (context, index) { - if (index >= _displayedNotes.length) { - // 底部加载指示器 - return const Padding( - padding: EdgeInsets.symmetric(vertical: 16), - child: Center( - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Color(0xFF1A1A1A), - ), - ), - ), - ); - } - return NoteListItem(note: _displayedNotes[index]); - }, - ), - ); - }, + // ─── 列表视图 ──────────────────────────────────────────────────────── + + Widget _buildListView() { + return RefreshIndicator( + onRefresh: _refresh, + color: const Color(0xFF1A1A1A), + backgroundColor: Colors.white, + child: ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.fromLTRB(12, 10, 12, 100), + itemCount: _displayedNotes.length + (_hasMore ? 1 : 0), + itemBuilder: (context, index) { + if (index >= _displayedNotes.length) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center( + child: SizedBox( + width: 20, height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF1A1A1A)), + ), + ), + ); + } + return NoteListItem(note: _displayedNotes[index]); + }, + ), ); } - /// 同步显示列表与Provider数据 + // ─── 瀑布流视图 ────────────────────────────────────────────────────── + + Widget _buildWaterfallView() { + // 分为左右两列 + final leftItems = []; + final rightItems = []; + for (int i = 0; i < _displayedNotes.length; i++) { + if (i % 2 == 0) { + leftItems.add(_displayedNotes[i]); + } else { + rightItems.add(_displayedNotes[i]); + } + } + + return RefreshIndicator( + onRefresh: _refresh, + color: const Color(0xFF1A1A1A), + backgroundColor: Colors.white, + child: SingleChildScrollView( + controller: _scrollController, + padding: const EdgeInsets.fromLTRB(12, 8, 12, 100), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: Column(children: leftItems.map((n) => _buildWaterfallCard(n)).toList())), + const SizedBox(width: 8), + Expanded(child: Column(children: rightItems.map((n) => _buildWaterfallCard(n)).toList())), + ], + ), + ), + ); + } + + Widget _buildWaterfallCard(Note note) { + final contentText = _getPreviewText(note); + final images = note.images; + final hasImage = images.isNotEmpty; + final extraCount = images.length - 1; + + return GestureDetector( + onTap: () { + Navigator.pushNamed(context, '/note-detail', arguments: note).then((_) async { + await context.read().loadNotes(); + }); + }, + onLongPress: () => _showDeleteDialog(context, note), + child: Container( + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // 顶部图片 + if (hasImage) + Stack( + children: [ + ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(10)), + child: Image.file( + File(images.first), + width: double.infinity, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), + ), + ), + if (extraCount > 0) + Positioned( + top: 6, + right: 6, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '+$extraCount', + style: const TextStyle(fontSize: 11, color: Colors.white, fontWeight: FontWeight.w600), + ), + ), + ), + ], + ), + + // 底部文字区域 + Padding( + padding: const EdgeInsets.fromLTRB(10, 8, 10, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (note.title.isNotEmpty) + Text( + note.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Color(0xFF1A1A1A), + height: 1.3, + ), + ), + + if (contentText.isNotEmpty && contentText != '(无内容)') ...[ + if (note.title.isNotEmpty) const SizedBox(height: 4), + Text( + contentText, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 11, + color: Color(0xFF999999), + height: 1.4, + ), + ), + ], + + const SizedBox(height: 6), + Row( + children: [ + Expanded( + child: Text( + _formatTime(note.updatedAt), + style: const TextStyle(fontSize: 10, color: Color(0xFFCCCCCC)), + ), + ), + if (note.tags.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + note.tags.first, + style: const TextStyle(fontSize: 10, color: Color(0xFF999999)), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } + + String _getPreviewText(Note note) { + final text = note.content + .replaceAll(RegExp(r'#'), '') + .replaceAll(RegExp(r'\*'), '') + .replaceAll(RegExp(r'`'), '') + .replaceAll(RegExp(r'[\[\]\(\)]'), '') + .trim(); + return text.isEmpty ? '(无内容)' : text; + } + + String _formatTime(DateTime date) { + final now = DateTime.now(); + final diff = now.difference(date); + if (diff.inMinutes < 1) return '刚刚'; + if (diff.inHours < 1) return '${diff.inMinutes}分钟前'; + if (diff.inDays < 1) return '${diff.inHours}小时前'; + if (diff.inDays < 7) return '${diff.inDays}天前'; + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } + + void _showDeleteDialog(BuildContext context, Note note) { + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: const Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), + content: const Text('确定要删除这条笔记吗?删除后可在回收站恢复。', + style: TextStyle(fontSize: 14, color: Color(0xFF666666), height: 1.5)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + style: TextButton.styleFrom( + foregroundColor: const Color(0xFF666666), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + child: const Text('取消'), + ), + ElevatedButton( + onPressed: () async { + await context.read().removeNote(note.id); + Navigator.pop(context); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + 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 _syncDisplayedNotes(List allNotes) { - // 获取当前所有有效笔记的ID集合 final validNoteIds = allNotes.map((n) => n.id).toSet(); - - // 移除已删除的笔记(不在Provider中的) final initialLength = _displayedNotes.length; _displayedNotes.removeWhere((note) => !validNoteIds.contains(note.id)); - - // 检查是否有新增笔记(Provider中有但本地列表中没有的) + final displayedIds = _displayedNotes.map((n) => n.id).toSet(); final hasNewNotes = allNotes.any((note) => !displayedIds.contains(note.id)); - - // 检查已存在的笔记是否有更新(通过比较updatedAt) + bool hasUpdates = false; for (int i = 0; i < _displayedNotes.length; i++) { final localNote = _displayedNotes[i]; @@ -187,114 +397,46 @@ class _NoteTabPageState extends State { break; } } - - // 如果有笔记被移除、有新增、或有更新,更新列表 + if (_displayedNotes.length < initialLength || hasNewNotes || hasUpdates) { - // 清空并重新加载所有数据 _displayedNotes.clear(); _displayedNotes.addAll(allNotes); - _hasMore = false; // 已经有全部数据了 + _hasMore = false; } } - /// 构建空状态提示 + // ─── 空状态 ────────────────────────────────────────────────────────── + Widget _buildEmptyState(BuildContext context) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( - width: 80, - height: 80, + width: 80, height: 80, decoration: BoxDecoration( color: const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(20), ), - child: const Icon( - Icons.note_outlined, - size: 40, - color: Color(0xFFCCCCCC), - ), + child: const Icon(Icons.note_outlined, size: 40, color: Color(0xFFCCCCCC)), ), const SizedBox(height: 20), - const Text( - '暂无笔记', - style: TextStyle( - fontSize: 16, - color: Color(0xFF999999), - ), - ), + const Text('暂无笔记', style: TextStyle(fontSize: 16, color: Color(0xFF999999))), const SizedBox(height: 24), InkWell( - onTap: () { - Navigator.pushNamed(context, '/note-form'); - }, + onTap: () => Navigator.pushNamed(context, '/note-form'), child: Container( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), decoration: BoxDecoration( color: const Color(0xFF1A1A1A), borderRadius: BorderRadius.circular(8), ), - child: const Text( - '添加记录', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Colors.white, - ), - ), + child: const Text('添加记录', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Colors.white)), ), ), ], ), ); } - - /// 获取示例笔记数据 - List _getSampleNotes() { - final now = DateTime.now(); - // 示例数据(实际应从数据库获取) - return [ - Note( - id: '1', - title: '学习 Flutter 笔记', - content: '今天开始学习 Flutter 框架,感觉和 Vue 有很多相似之处,都是声明式 UI,组件化开发。Widget 的概念很有趣,一切皆 Widget。', - tags: ['学习', 'Flutter', '编程'], - createdAt: now.subtract(const Duration(days: 2)), - updatedAt: now.subtract(const Duration(days: 2)), - ), - Note( - id: '2', - title: '《活着》读后感', - content: '余华的《活着》真的是一部让人深思的作品。福贵的一生经历了太多的苦难,但他依然坚强地活着。生命的意义或许就在于活着本身。', - tags: ['阅读', '感悟', '书籍'], - createdAt: now.subtract(const Duration(days: 5)), - updatedAt: now.subtract(const Duration(days: 5)), - ), - Note( - id: '3', - title: '诺兰电影观后感', - content: '诺兰的电影总是充满想象力。《星际穿越》将科幻与亲情完美结合,五维空间的呈现方式令人震撼。配乐也是一绝。', - tags: ['观影', '科幻', '电影'], - createdAt: now.subtract(const Duration(days: 10)), - updatedAt: now.subtract(const Duration(days: 10)), - ), - Note( - id: '4', - title: 'Pandas 学习笔记', - content: 'Pandas 库的 DataFrame 操作非常强大,可以方便地进行数据清洗和分析。需要多练习熟练掌握常用操作。', - tags: ['Python', '数据分析', '技术'], - createdAt: now.subtract(const Duration(days: 30)), - updatedAt: now.subtract(const Duration(days: 30)), - ), - Note( - id: '5', - title: '春日随笔', - content: '春天来了,天气渐暖。周末去公园散步,看到花开得很好。生活中的小确幸值得记录。', - tags: ['生活', '随笔'], - createdAt: now.subtract(const Duration(hours: 5)), - updatedAt: now.subtract(const Duration(hours: 5)), - ), - ]; - } } diff --git a/lib/pages/profile_page.dart b/lib/pages/profile_page.dart index ba5652a..95fc3e3 100644 --- a/lib/pages/profile_page.dart +++ b/lib/pages/profile_page.dart @@ -111,25 +111,6 @@ class _ProfilePageState extends State { const SizedBox(height: 40), - // 版本信息 - Center( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: const Color(0xFFF5F5F5), - borderRadius: BorderRadius.circular(20), - ), - child: Text( - 'MookNote v$_version', - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: Color(0xFF666666), - ), - ), - ), - ), - // 底部留白,避免被 dock 栏遮挡 const SizedBox(height: 100), ], @@ -629,11 +610,13 @@ class SettingsPage extends StatefulWidget { class _SettingsPageState extends State { final UserPrefs _userPrefs = UserPrefs(); bool _hideBottomNavOnScroll = true; + int _noteLayoutStyle = 0; @override void initState() { super.initState(); _hideBottomNavOnScroll = _userPrefs.hideBottomNavOnScroll; + _noteLayoutStyle = _userPrefs.noteLayoutStyle; } @override @@ -692,6 +675,14 @@ class _SettingsPageState extends State { ); }, ), + const Divider(height: 0.5, indent: 24, endIndent: 24), + _buildSwitchItem( + icon: Icons.grid_view_outlined, + title: '笔记瀑布流布局', + subtitle: '使用双列瀑布流样式展示笔记', + value: _noteLayoutStyle == 1, + onChanged: _toggleNoteLayoutStyle, + ), // 使用说明 _buildSectionHeader('帮助'), _buildLinkItem( @@ -723,6 +714,12 @@ class _SettingsPageState extends State { setState(() => _hideBottomNavOnScroll = value); } + Future _toggleNoteLayoutStyle(bool value) async { + final v = value ? 1 : 0; + await _userPrefs.setNoteLayoutStyle(v); + setState(() => _noteLayoutStyle = v); + } + /// 构建开关项 Widget _buildSwitchItem({ required IconData icon, @@ -1159,6 +1156,7 @@ class _MainContentSettingsPageState extends State { bool _showMovieTab = true; bool _showBookTab = true; bool _showNoteTab = true; + int _defaultTabIndex = 0; @override void initState() { @@ -1172,6 +1170,7 @@ class _MainContentSettingsPageState extends State { _showMovieTab = _userPrefs.showMovieTab; _showBookTab = _userPrefs.showBookTab; _showNoteTab = _userPrefs.showNoteTab; + _defaultTabIndex = _userPrefs.defaultMainTabIndex; }); } @@ -1267,11 +1266,100 @@ class _MainContentSettingsPageState extends State { onChanged: _toggleNoteTab, ), const Divider(height: 0.5, indent: 24, endIndent: 24), + + // 默认启动标签 + _buildDefaultTabSelector(), + const Divider(height: 0.5, indent: 24, endIndent: 24), ], ), ); } + /// 构建默认启动标签选择器 + Widget _buildDefaultTabSelector() { + final options = [ + {'label': '影视', 'icon': Icons.movie_outlined, 'value': 0}, + {'label': '阅读', 'icon': Icons.menu_book_outlined, 'value': 1}, + {'label': '笔记', 'icon': Icons.note_outlined, 'value': 2}, + ]; + + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + leading: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.home_outlined, + color: Color(0xFF666666), + size: 24, + ), + ), + title: const Text( + '默认启动标签', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Color(0xFF1A1A1A), + ), + ), + subtitle: const Text( + '打开应用时默认显示的页面', + style: TextStyle( + fontSize: 13, + color: Color(0xFF999999), + ), + ), + trailing: SizedBox( + width: 80, + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _defaultTabIndex, + isDense: true, + icon: const Icon(Icons.chevron_right, color: Color(0xFFCCCCCC)), + selectedItemBuilder: (context) { + return options.map((opt) { + return Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 8), + child: Text( + opt['label'] as String, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: Color(0xFF1A1A1A), + ), + ), + ); + }).toList(); + }, + items: options.map((opt) { + return DropdownMenuItem( + value: opt['value'] as int, + child: Row( + children: [ + Icon(opt['icon'] as IconData, size: 20, color: const Color(0xFF666666)), + const SizedBox(width: 8), + Text(opt['label'] as String), + ], + ), + ); + }).toList(), + onChanged: (int? value) async { + if (value != null) { + await _userPrefs.setDefaultMainTabIndex(value); + setState(() => _defaultTabIndex = value); + } + }, + ), + ), + ), + ); + } + /// 构建开关项 Widget _buildSwitchItem({ required IconData icon, diff --git a/lib/pages/search_page.dart b/lib/pages/search_page.dart index 0ca45bf..9c93342 100644 --- a/lib/pages/search_page.dart +++ b/lib/pages/search_page.dart @@ -1,14 +1,14 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/app_provider.dart'; import '../models/data_models.dart'; -import '../utils/toast_util.dart'; import 'movies/movie_detail_page.dart'; import 'book/book_detail_page.dart'; import 'note/note_detail_page.dart'; -/// 搜索页面 - 统一搜索影视/书籍/笔记,标签区分 +/// 搜索页面 - 统一搜索影视/书籍/笔记 class SearchPage extends StatefulWidget { const SearchPage({super.key}); @@ -20,20 +20,18 @@ class _SearchPageState extends State { final _searchController = TextEditingController(); final _focusNode = FocusNode(); - // 类型筛选:默认全部选中 bool _showMovies = true; bool _showBooks = true; bool _showNotes = true; - String? _selectedTag; - List<_SearchResult> _results = []; - bool _isSearching = false; + bool _hasSearched = false; + + Timer? _debounce; @override void initState() { super.initState(); - // 自动聚焦,弹出键盘 WidgetsBinding.instance.addPostFrameCallback((_) { _focusNode.requestFocus(); }); @@ -43,44 +41,52 @@ class _SearchPageState extends State { void dispose() { _searchController.dispose(); _focusNode.dispose(); + _debounce?.cancel(); super.dispose(); } + void _scheduleSearch() { + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 250), _performSearch); + } + void _performSearch() { final keyword = _searchController.text.trim(); - if (keyword.isEmpty && _selectedTag == null) { - setState(() => _results = []); + if (keyword.isEmpty) { + setState(() { + _results = []; + _hasSearched = false; + }); return; } - setState(() => _isSearching = true); - final provider = context.read(); final lowerKeyword = keyword.toLowerCase(); final results = <_SearchResult>[]; - // 影视 if (_showMovies) { for (final movie in provider.movies.where((m) => !m.isDeleted)) { - if (_matchMovie(movie, lowerKeyword)) { + if (movie.title.toLowerCase().contains(lowerKeyword) || + movie.alternateTitles.any((t) => t.toLowerCase().contains(lowerKeyword)) || + (movie.summary?.toLowerCase().contains(lowerKeyword) ?? false)) { results.add(_SearchResult(type: 'movie', data: movie)); } } } - // 书籍 if (_showBooks) { for (final book in provider.books.where((b) => !b.isDeleted)) { - if (_matchBook(book, lowerKeyword)) { + if (book.title.toLowerCase().contains(lowerKeyword) || + book.alternateTitles.any((t) => t.toLowerCase().contains(lowerKeyword)) || + (book.summary?.toLowerCase().contains(lowerKeyword) ?? false)) { results.add(_SearchResult(type: 'book', data: book)); } } } - // 笔记 if (_showNotes) { for (final note in provider.notes.where((n) => !n.isDeleted)) { - if (_matchNote(note, lowerKeyword)) { + if (note.content.toLowerCase().contains(lowerKeyword)) { results.add(_SearchResult(type: 'note', data: note)); } } @@ -88,30 +94,10 @@ class _SearchPageState extends State { setState(() { _results = results; - _isSearching = false; + _hasSearched = true; }); } - bool _matchMovie(Movie movie, String lowerKeyword) { - if (lowerKeyword.isEmpty) return true; - return movie.title.toLowerCase().contains(lowerKeyword) || - movie.alternateTitles.any((t) => t.toLowerCase().contains(lowerKeyword)) || - (movie.summary?.toLowerCase().contains(lowerKeyword) ?? false); - } - - bool _matchBook(Book book, String lowerKeyword) { - if (lowerKeyword.isEmpty) return true; - return book.title.toLowerCase().contains(lowerKeyword) || - book.alternateTitles.any((t) => t.toLowerCase().contains(lowerKeyword)) || - (book.summary?.toLowerCase().contains(lowerKeyword) ?? false); - } - - bool _matchNote(Note note, String lowerKeyword) { - if (_selectedTag != null && !note.tags.contains(_selectedTag)) return false; - if (lowerKeyword.isEmpty) return _selectedTag != null; - return note.content.toLowerCase().contains(lowerKeyword); - } - @override Widget build(BuildContext context) { return Scaffold( @@ -122,21 +108,12 @@ class _SearchPageState extends State { ), body: Column( children: [ - // 搜索输入框 _buildSearchBar(), - - // 类型筛选 & 标签筛选 _buildFilterRow(), - - // 结果 Expanded( - child: _isSearching - ? const Center(child: CircularProgressIndicator(color: Color(0xFF1A1A1A))) - : _results.isEmpty && _searchController.text.isEmpty && _selectedTag == null - ? _buildInitialState() - : _results.isEmpty - ? _buildEmptyState() - : _buildResultList(), + child: _hasSearched + ? _results.isEmpty ? _buildEmptyState() : _buildResultList() + : _buildInitialState(), ), ], ), @@ -145,167 +122,108 @@ class _SearchPageState extends State { Widget _buildSearchBar() { return Container( - padding: const EdgeInsets.fromLTRB(20, 12, 20, 8), + padding: const EdgeInsets.fromLTRB(20, 12, 20, 4), child: TextField( controller: _searchController, focusNode: _focusNode, + style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A)), decoration: InputDecoration( - hintText: '搜索影视、书籍、笔记...', - hintStyle: const TextStyle(color: Color(0xFF999999), fontSize: 14), - prefixIcon: const Icon(Icons.search, color: Color(0xFF666666), size: 20), + hintText: '搜索标题、别名、内容...', + hintStyle: const TextStyle(color: Color(0xFFB0B0B0), fontSize: 15), + prefixIcon: const Padding( + padding: EdgeInsets.only(left: 12, right: 8), + child: Icon(Icons.search, color: Color(0xFF1A1A1A), size: 22), + ), + prefixIconConstraints: const BoxConstraints(minWidth: 42, minHeight: 42), suffixIcon: _searchController.text.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear, color: Color(0xFF999999), size: 20), - onPressed: () { + ? GestureDetector( + onTap: () { _searchController.clear(); - _performSearch(); + _scheduleSearch(); + _focusNode.requestFocus(); }, + child: Container( + margin: const EdgeInsets.only(right: 4), + width: 28, + height: 28, + decoration: BoxDecoration( + color: const Color(0xFFE5E5E5), + borderRadius: BorderRadius.circular(14), + ), + child: const Icon(Icons.close, color: Color(0xFF666666), size: 16), + ), ) : null, filled: true, - fillColor: const Color(0xFFFAFAFA), + fillColor: const Color(0xFFF8F8F8), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: Color(0xFFE8E8E8), width: 0.5), + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, ), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: Color(0xFFE8E8E8), width: 0.5), + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: Color(0xFF1A1A1A), width: 1), + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: Color(0xFF1A1A1A), width: 1.5), ), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), ), - onSubmitted: (_) => _performSearch(), - onChanged: (_) { + onSubmitted: (_) { + _debounce?.cancel(); _performSearch(); }, + onChanged: (_) { + _debounce?.cancel(); + setState(() {}); + _scheduleSearch(); + }, ), ); } Widget _buildFilterRow() { - return Consumer( - builder: (context, provider, child) { - // 收集所有笔记标签 - final allTags = {}; - for (final note in provider.notes.where((n) => !n.isDeleted)) { - allTags.addAll(note.tags); - } - final tags = allTags.toList()..sort(); - - return Padding( - padding: const EdgeInsets.fromLTRB(20, 4, 20, 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - // 类型筛选行 - Row( - children: [ - const Text('类型', style: TextStyle(fontSize: 12, color: Color(0xFFBBBBBB))), - const SizedBox(width: 10), - _buildTypeChip('影视', _showMovies, (v) { - setState(() { _showMovies = v; _performSearch(); }); - }), - const SizedBox(width: 8), - _buildTypeChip('书籍', _showBooks, (v) { - setState(() { _showBooks = v; _performSearch(); }); - }), - const SizedBox(width: 8), - _buildTypeChip('笔记', _showNotes, (v) { - setState(() { _showNotes = v; _performSearch(); }); - }), - ], - ), - - // 笔记标签筛选 - if (tags.isNotEmpty && _showNotes) ...[ - const SizedBox(height: 10), - SizedBox( - height: 32, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: tags.length + (_selectedTag != null ? 1 : 0), - separatorBuilder: (_, __) => const SizedBox(width: 8), - itemBuilder: (context, index) { - // 第一个始终是"全部标签"清除按钮 - if (_selectedTag != null && index == 0) { - return _buildTagChip('全部标签', true, () { - setState(() { _selectedTag = null; _performSearch(); }); - }); - } - final tagIndex = _selectedTag != null ? index - 1 : index; - final tag = tags[tagIndex]; - final isSelected = _selectedTag == tag; - return _buildTagChip(tag, isSelected, () { - setState(() { - _selectedTag = isSelected ? null : tag; - _performSearch(); - }); - }); - }, - ), - ), - ], - ], - ), - ); - }, - ); - } - - Widget _buildTypeChip(String label, bool selected, ValueChanged onChanged) { - return GestureDetector( - onTap: () => onChanged(!selected), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), - decoration: BoxDecoration( - color: selected ? const Color(0xFF1A1A1A) : const Color(0xFFF5F5F5), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: selected ? const Color(0xFF1A1A1A) : const Color(0xFFE8E8E8), - width: 0.5, - ), - ), - child: Text( - label, - style: TextStyle( - fontSize: 12, - fontWeight: selected ? FontWeight.w600 : FontWeight.w500, - color: selected ? Colors.white : const Color(0xFF888888), - ), - ), + return Padding( + padding: const EdgeInsets.fromLTRB(20, 6, 20, 12), + child: Row( + children: [ + _buildTypeChip('影视', Icons.movie_outlined, _showMovies, (v) { + setState(() { _showMovies = v; _performSearch(); }); + }), + const SizedBox(width: 8), + _buildTypeChip('书籍', Icons.menu_book_outlined, _showBooks, (v) { + setState(() { _showBooks = v; _performSearch(); }); + }), + const SizedBox(width: 8), + _buildTypeChip('笔记', Icons.note_outlined, _showNotes, (v) { + setState(() { _showNotes = v; _performSearch(); }); + }), + ], ), ); } - Widget _buildTagChip(String label, bool selected, VoidCallback onTap) { + Widget _buildTypeChip(String label, IconData icon, bool selected, ValueChanged onChanged) { return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + onTap: () => onChanged(!selected), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), decoration: BoxDecoration( color: selected ? const Color(0xFF1A1A1A) : const Color(0xFFF5F5F5), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: selected ? const Color(0xFF1A1A1A) : const Color(0xFFE8E8E8), - width: 0.5, - ), + borderRadius: BorderRadius.circular(8), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - if (selected) - const Icon(Icons.close, size: 12, color: Colors.white70), - if (selected) const SizedBox(width: 4), + Icon(icon, size: 14, color: selected ? Colors.white : const Color(0xFF888888)), + const SizedBox(width: 5), Text( label, style: TextStyle( - fontSize: 12, - fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + fontSize: 13, + fontWeight: FontWeight.w500, color: selected ? Colors.white : const Color(0xFF888888), ), ), @@ -321,17 +239,18 @@ class _SearchPageState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ Container( - width: 80, height: 80, + width: 88, + height: 88, decoration: BoxDecoration( - color: const Color(0xFFF5F5F5), - borderRadius: BorderRadius.circular(20), + color: const Color(0xFFF8F8F8), + borderRadius: BorderRadius.circular(24), ), - child: const Icon(Icons.search, size: 40, color: Color(0xFFCCCCCC)), + child: const Icon(Icons.search_rounded, size: 44, color: Color(0xFFD0D0D0)), ), - const SizedBox(height: 20), - const Text('输入关键词搜索影视、书籍、笔记', style: TextStyle(fontSize: 14, color: Color(0xFF999999))), - const SizedBox(height: 4), - const Text('可同时筛选多个类型', style: TextStyle(fontSize: 12, color: Color(0xFFCCCCCC))), + const SizedBox(height: 24), + const Text('输入关键词搜索', style: TextStyle(fontSize: 15, color: Color(0xFF999999))), + const SizedBox(height: 6), + const Text('可同时筛选影视、书籍、笔记', style: TextStyle(fontSize: 13, color: Color(0xFFCCCCCC))), ], ), ); @@ -343,17 +262,18 @@ class _SearchPageState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ Container( - width: 80, height: 80, + width: 88, + height: 88, decoration: BoxDecoration( - color: const Color(0xFFF5F5F5), - borderRadius: BorderRadius.circular(20), + color: const Color(0xFFF8F8F8), + borderRadius: BorderRadius.circular(24), ), - child: const Icon(Icons.search_off, size: 40, color: Color(0xFFCCCCCC)), + child: const Icon(Icons.search_off_rounded, size: 44, color: Color(0xFFD0D0D0)), ), - const SizedBox(height: 20), + const SizedBox(height: 24), const Text('未找到相关内容', style: TextStyle(fontSize: 15, color: Color(0xFF999999))), - const SizedBox(height: 8), - const Text('尝试更换关键词或筛选条件', style: TextStyle(fontSize: 13, color: Color(0xFFCCCCCC))), + const SizedBox(height: 6), + const Text('换个关键词试试', style: TextStyle(fontSize: 13, color: Color(0xFFCCCCCC))), ], ), ); @@ -379,206 +299,210 @@ class _SearchPageState extends State { ); } - Widget _buildItemWrapper({ - required Widget child, - required String typeLabel, - required IconData typeIcon, - required Color typeColor, - required VoidCallback onTap, - }) { + // ─── 影视结果项 ────────────────────────────────────────────────────── + + Widget _buildMovieItem(Movie movie) { return GestureDetector( - onTap: onTap, + onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => MovieDetailPage(movie: movie))), child: Container( - margin: const EdgeInsets.only(bottom: 10), + margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: const Color(0xFFFAFAFA), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5), + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + _buildPosterThumb(movie.posterPath, Icons.movie_outlined), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _typeBadge('影视', const Color(0xFF4A90D9)), + const Spacer(), + _statusBadge(movie.status), + ], + ), + const SizedBox(height: 8), + Text(movie.title, maxLines: 1, overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), + if (movie.alternateTitles.isNotEmpty) ...[ + const SizedBox(height: 3), + Text(movie.alternateTitles.take(2).join('、'), maxLines: 1, overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12, color: Color(0xFFAAAAAA))), + ], + ], + ), + ), + const SizedBox(width: 8), + const Icon(Icons.chevron_right, color: Color(0xFFD0D0D0), size: 20), + ], + ), + ), + ); + } + + // ─── 书籍结果项 ────────────────────────────────────────────────────── + + Widget _buildBookItem(Book book) { + return GestureDetector( + onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => BookDetailPage(book: book))), + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFFAFAFA), + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + _buildPosterThumb(book.coverPath, Icons.menu_book_outlined), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _typeBadge('书籍', const Color(0xFF7E57C2)), + const Spacer(), + _bookStatusBadge(book.status), + ], + ), + const SizedBox(height: 8), + Text(book.title, maxLines: 1, overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), + if (book.authors.isNotEmpty) ...[ + const SizedBox(height: 3), + Text(book.authors.take(2).join('、'), maxLines: 1, overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12, color: Color(0xFFAAAAAA))), + ], + ], + ), + ), + const SizedBox(width: 8), + const Icon(Icons.chevron_right, color: Color(0xFFD0D0D0), size: 20), + ], + ), + ), + ); + } + + // ─── 笔记结果项 ────────────────────────────────────────────────────── + + Widget _buildNoteItem(Note note) { + return GestureDetector( + onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => NoteDetailPage(note: note))), + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFFAFAFA), + borderRadius: BorderRadius.circular(14), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 类型标签 Row( children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), - decoration: BoxDecoration( - color: typeColor.withOpacity(0.1), - borderRadius: BorderRadius.circular(4), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(typeIcon, size: 10, color: typeColor), - const SizedBox(width: 3), - Text(typeLabel, style: TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: typeColor)), - ], - ), - ), + _typeBadge('笔记', const Color(0xFF66BB6A)), + const Spacer(), + const Icon(Icons.chevron_right, color: Color(0xFFD0D0D0), size: 20), ], ), const SizedBox(height: 10), - child, + Text( + note.summary.trim().isEmpty ? '(无内容)' : note.summary.trim(), + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14, color: Color(0xFF333333), height: 1.6), + ), + if (note.tags.isNotEmpty) ...[ + const SizedBox(height: 10), + Wrap( + spacing: 6, + runSpacing: 6, + children: note.tags.map((tag) => Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(6), + ), + child: Text(tag, style: const TextStyle(fontSize: 11, color: Color(0xFF888888))), + )).toList(), + ), + ], ], ), ), ); } - Widget _buildMovieItem(Movie movie) { - return _buildItemWrapper( - typeLabel: '影视', - typeIcon: Icons.movie_outlined, - typeColor: const Color(0xFF4A90D9), - onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => MovieDetailPage(movie: movie))), - child: Row( - children: [ - Container( - width: 52, height: 70, - decoration: BoxDecoration( - color: const Color(0xFFF5F5F5), - borderRadius: BorderRadius.circular(6), - ), - clipBehavior: Clip.antiAlias, - child: movie.posterPath != null && movie.posterPath!.isNotEmpty - ? Image.file(File(movie.posterPath!), fit: BoxFit.cover, - errorBuilder: (_, __, ___) => const Icon(Icons.movie, size: 22, color: Color(0xFFCCCCCC))) - : const Icon(Icons.movie, size: 22, color: Color(0xFFCCCCCC)), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(movie.title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), - if (movie.alternateTitles.isNotEmpty) ...[ - const SizedBox(height: 4), - Text(movie.alternateTitles.take(2).join(' / '), maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 12, color: Color(0xFF999999))), - ], - const SizedBox(height: 6), - _statusTag(movie.status), - ], - ), - ), - ], + // ─── 通用组件 ──────────────────────────────────────────────────────── + + Widget _buildPosterThumb(String? path, IconData fallback) { + return Container( + width: 48, + height: 64, + decoration: BoxDecoration( + color: const Color(0xFFF0F0F0), + borderRadius: BorderRadius.circular(8), ), + clipBehavior: Clip.antiAlias, + child: path != null && path.isNotEmpty + ? Image.file(File(path), fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Icon(fallback, size: 22, color: const Color(0xFFCCCCCC))) + : Icon(fallback, size: 22, color: const Color(0xFFCCCCCC)), ); } - Widget _buildBookItem(Book book) { - return _buildItemWrapper( - typeLabel: '书籍', - typeIcon: Icons.menu_book_outlined, - typeColor: const Color(0xFF7E57C2), - onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => BookDetailPage(book: book))), - child: Row( - children: [ - Container( - width: 52, height: 70, - decoration: BoxDecoration( - color: const Color(0xFFF5F5F5), - borderRadius: BorderRadius.circular(6), - ), - clipBehavior: Clip.antiAlias, - child: book.coverPath != null && book.coverPath!.isNotEmpty - ? Image.file(File(book.coverPath!), fit: BoxFit.cover, - errorBuilder: (_, __, ___) => const Icon(Icons.book, size: 22, color: Color(0xFFCCCCCC))) - : const Icon(Icons.book, size: 22, color: Color(0xFFCCCCCC)), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(book.title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), - if (book.authors.isNotEmpty) ...[ - const SizedBox(height: 4), - Text(book.authors.take(2).join(' / '), maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 12, color: Color(0xFF999999))), - ], - const SizedBox(height: 6), - _bookStatusTag(book.status), - ], - ), - ), - ], + Widget _typeBadge(String label, Color color) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(5), ), + child: Text(label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: color)), ); } - Widget _buildNoteItem(Note note) { - return _buildItemWrapper( - typeLabel: '笔记', - typeIcon: Icons.note_outlined, - typeColor: const Color(0xFF66BB6A), - onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => NoteDetailPage(note: note))), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - note.summary.trim(), - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 14, color: Color(0xFF333333), height: 1.7), - ), - if (note.tags.isNotEmpty) ...[ - const SizedBox(height: 10), - Wrap( - spacing: 6, - runSpacing: 6, - children: note.tags.map((tag) => Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(4), - border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5), - ), - child: Text(tag, style: const TextStyle(fontSize: 10, color: Color(0xFF999999))), - )).toList(), - ), - ], - ], - ), - ); - } - - Widget _statusTag(String status) { + Widget _statusBadge(String status) { final (label, bg, fg) = switch (status) { 'watched' => ('已看', const Color(0xFF1A1A1A), Colors.white), 'watching' => ('在看', const Color(0xFFF0F0F0), const Color(0xFF666666)), 'want_to_watch' => ('想看', const Color(0xFFF5F5F5), const Color(0xFF999999)), - _ => ('未标记', const Color(0xFFF5F5F5), const Color(0xFFBBBBBB)), + _ => ('', const Color(0xFFF5F5F5), const Color(0xFFBBBBBB)), }; + if (label.isEmpty) return const SizedBox.shrink(); return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(4)), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(5)), child: Text(label, style: TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: fg)), ); } - Widget _bookStatusTag(String status) { + Widget _bookStatusBadge(String status) { final (label, bg, fg) = switch (status) { 'read' => ('已读', const Color(0xFF1A1A1A), Colors.white), 'reading' => ('在读', const Color(0xFFF0F0F0), const Color(0xFF666666)), 'want_to_read' => ('想读', const Color(0xFFF5F5F5), const Color(0xFF999999)), - _ => ('未标记', const Color(0xFFF5F5F5), const Color(0xFFBBBBBB)), + _ => ('', const Color(0xFFF5F5F5), const Color(0xFFBBBBBB)), }; + if (label.isEmpty) return const SizedBox.shrink(); return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(4)), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(5)), child: Text(label, style: TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: fg)), ); } } class _SearchResult { - final String type; // movie, book, note + final String type; final dynamic data; - _SearchResult({required this.type, required this.data}); } diff --git a/lib/pages/tag_management_page.dart b/lib/pages/tag_management_page.dart index b7e2e14..265b907 100644 --- a/lib/pages/tag_management_page.dart +++ b/lib/pages/tag_management_page.dart @@ -120,55 +120,83 @@ class _TagManagementPageState extends State { ); } - /// 胶囊式 Tab 选择器 + /// 胶囊式 Tab 选择器(水滴滑动动画) Widget _buildTabSelector() { return Container( - margin: const EdgeInsets.symmetric(horizontal: 20), + margin: const EdgeInsets.symmetric(horizontal: 24), padding: const EdgeInsets.all(4), decoration: BoxDecoration( - color: const Color(0xFFF2F2F2), - borderRadius: BorderRadius.circular(22), + color: const Color(0xFFF0F0F0), + borderRadius: BorderRadius.circular(24), ), - child: Row( - children: List.generate(3, (i) { - final selected = _currentIndex == i; - return Expanded( - child: GestureDetector( - onTap: () { - setState(() => _currentIndex = i); - _loadTags(_tabTypes[i]); - }, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(vertical: 9), - decoration: BoxDecoration( - color: selected ? Colors.white : Colors.transparent, - borderRadius: BorderRadius.circular(20), - boxShadow: selected - ? [ + child: LayoutBuilder( + builder: (context, constraints) { + final tabWidth = constraints.maxWidth / 3; + return SizedBox( + height: 42, + child: Stack( + children: [ + // 水滴指示器 + AnimatedPositioned( + duration: const Duration(milliseconds: 450), + curve: Curves.elasticOut, + left: _currentIndex * tabWidth, + top: 0, + bottom: 0, + width: tabWidth, + child: Padding( + padding: const EdgeInsets.all(3), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(22), + boxShadow: [ BoxShadow( - color: Colors.black.withValues(alpha: 0.06), - blurRadius: 6, + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 8, offset: const Offset(0, 2), ), - ] - : null, - ), - child: Text( - _typeLabels[i], - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - fontWeight: selected ? FontWeight.w600 : FontWeight.normal, - color: selected - ? const Color(0xFF1A1A1A) - : const Color(0xFF888888), + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 2, + offset: const Offset(0, 1), + ), + ], + ), + ), ), ), - ), + // 标签文字 + Row( + children: List.generate(3, (i) { + final selected = _currentIndex == i; + return Expanded( + child: GestureDetector( + onTap: () { + setState(() => _currentIndex = i); + _loadTags(_tabTypes[i]); + }, + child: Center( + child: Text( + _typeLabels[i], + style: TextStyle( + fontSize: 14, + fontWeight: + selected ? FontWeight.w600 : FontWeight.w500, + color: selected + ? const Color(0xFF1A1A1A) + : const Color(0xFF999999), + ), + ), + ), + ), + ); + }), + ), + ], ), ); - }), + }, ), ); } diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index eed20aa..0006c45 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -10,6 +10,7 @@ import '../utils/book/book_excerpt_dao.dart'; import '../utils/tag/tag_dao.dart'; import '../utils/database_helper.dart'; import '../utils/image_path_helper.dart'; +import '../utils/user_prefs.dart'; /// 应用全局状态管理 class AppProvider extends ChangeNotifier { @@ -48,9 +49,39 @@ class AppProvider extends ChangeNotifier { // 初始化数据库 Future initDatabase() async { - await loadMovies(); - await loadBooks(); - await loadNotes(); + final results = await Future.wait([ + _movieDao.getAllMovies(), + _bookDao.getAllBooks(), + _noteDao.getAllNotes(), + ]); + _movies = results[0] as List; + _books = results[1] as List; + _notes = results[2] as List; + notifyListeners(); + } + + // 从用户偏好恢复默认启动标签 + void initMainTabIndex() { + final userPrefs = UserPrefs(); + final defaultIndex = userPrefs.defaultMainTabIndex; + // 确保选中的标签是启用的 + final showMovie = userPrefs.showMovieTab; + final showBook = userPrefs.showBookTab; + final showNote = userPrefs.showNoteTab; + final enabled = [showMovie, showBook, showNote]; + if (enabled[defaultIndex]) { + _mainTabIndex = defaultIndex; + } else { + // 回退到第一个启用的标签 + if (showMovie) { + _mainTabIndex = 0; + } else if (showBook) { + _mainTabIndex = 1; + } else { + _mainTabIndex = 2; + } + } + notifyListeners(); } // 加载影视数据 diff --git a/lib/utils/sync/backup_service.dart b/lib/utils/sync/backup_service.dart index c43c53f..8932146 100644 --- a/lib/utils/sync/backup_service.dart +++ b/lib/utils/sync/backup_service.dart @@ -482,11 +482,15 @@ class BackupService { if (data.containsKey('tags')) { final tags = data['tags'] as List; for (final tag in tags) { - await txn.insert('tags', _convertToDbMap(tag)); + final map = _convertToDbMap(tag); + await txn.rawInsert( + 'INSERT OR IGNORE INTO tags (id, name, type, created_at) VALUES (?, ?, ?, ?)', + [map['id'], map['name'], map['type'], map['created_at']], + ); } } }); - + // 恢复用户个人信息 if (backupData.containsKey('userInfo')) { final userInfo = backupData['userInfo'] as Map; @@ -644,7 +648,11 @@ class BackupService { if (data.containsKey('tags')) { final tags = data['tags'] as List; for (final tag in tags) { - await txn.insert('tags', _convertToDbMap(tag)); + final map = _convertToDbMap(tag); + await txn.rawInsert( + 'INSERT OR IGNORE INTO tags (id, name, type, created_at) VALUES (?, ?, ?, ?)', + [map['id'], map['name'], map['type'], map['created_at']], + ); } } }); diff --git a/lib/utils/user_prefs.dart b/lib/utils/user_prefs.dart index 2ca5ed7..3af85e3 100644 --- a/lib/utils/user_prefs.dart +++ b/lib/utils/user_prefs.dart @@ -64,6 +64,14 @@ class UserPrefs { bool get showNoteTab => prefs.getBool('showNoteTab') ?? true; Future setShowNoteTab(bool value) => prefs.setBool('showNoteTab', value); + /// 默认启动标签 (0: 影视, 1: 阅读, 2: 笔记) + int get defaultMainTabIndex => prefs.getInt('defaultMainTabIndex') ?? 0; + Future setDefaultMainTabIndex(int value) => prefs.setInt('defaultMainTabIndex', value); + + /// 笔记布局样式 (0: 列表, 1: 瀑布流) + int get noteLayoutStyle => prefs.getInt('noteLayoutStyle') ?? 0; + Future setNoteLayoutStyle(int value) => prefs.setInt('noteLayoutStyle', value); + // ========== 应用图标设置 ========== /// Markdown 阅读器最近选择的目录 diff --git a/lib/widgets/book_status_bar.dart b/lib/widgets/book_status_bar.dart index 34c02a9..c469e08 100644 --- a/lib/widgets/book_status_bar.dart +++ b/lib/widgets/book_status_bar.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/app_provider.dart'; -/// 阅读状态选择栏 - 现代胶囊式设计 +/// 阅读状态选择栏 - 水滴滑动动画 class BookStatusBar extends StatelessWidget { const BookStatusBar({super.key}); @@ -24,27 +24,63 @@ class BookStatusBar extends StatelessWidget { color: const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(24), ), - child: Row( - children: [ - _buildStatusItem( - label: '已读', - icon: Icons.check_circle_outline, - isSelected: provider.bookStatusIndex == 0, - onTap: () => provider.setBookStatusIndex(0), - ), - _buildStatusItem( - label: '在读', - icon: Icons.menu_book_outlined, - isSelected: provider.bookStatusIndex == 1, - onTap: () => provider.setBookStatusIndex(1), - ), - _buildStatusItem( - label: '想读', - icon: Icons.bookmark_outline, - isSelected: provider.bookStatusIndex == 2, - onTap: () => provider.setBookStatusIndex(2), - ), - ], + child: LayoutBuilder( + builder: (context, constraints) { + final tabWidth = constraints.maxWidth / 3; + return SizedBox( + height: 40, + child: Stack( + children: [ + AnimatedPositioned( + duration: const Duration(milliseconds: 800), + curve: Curves.elasticOut, + left: provider.bookStatusIndex * tabWidth, + top: 0, + bottom: 0, + width: tabWidth, + child: Padding( + padding: const EdgeInsets.all(3), + child: Container( + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _buildTab( + label: '已读', + icon: Icons.check_circle_outline, + isSelected: provider.bookStatusIndex == 0, + onTap: () => provider.setBookStatusIndex(0), + ), + _buildTab( + label: '在读', + icon: Icons.menu_book_outlined, + isSelected: provider.bookStatusIndex == 1, + onTap: () => provider.setBookStatusIndex(1), + ), + _buildTab( + label: '想读', + icon: Icons.bookmark_outlined, + isSelected: provider.bookStatusIndex == 2, + onTap: () => provider.setBookStatusIndex(2), + ), + ], + ), + ], + ), + ); + }, ), ), ); @@ -52,8 +88,7 @@ class BookStatusBar extends StatelessWidget { ); } - /// 构建状态项 - Widget _buildStatusItem({ + Widget _buildTab({ required String label, required IconData icon, required bool isSelected, @@ -61,26 +96,14 @@ class BookStatusBar extends StatelessWidget { }) { return Expanded( child: GestureDetector( + behavior: HitTestBehavior.opaque, onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - padding: const EdgeInsets.symmetric(vertical: 10), - decoration: BoxDecoration( - color: isSelected ? const Color(0xFF1A1A1A) : Colors.transparent, - borderRadius: BorderRadius.circular(20), - boxShadow: isSelected - ? [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ] - : null, - ), + child: Container( + height: double.infinity, + alignment: Alignment.center, child: Row( mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, children: [ Icon( icon, diff --git a/lib/widgets/custom_drawer.dart b/lib/widgets/custom_drawer.dart index b3f124a..ac0d2cc 100644 --- a/lib/widgets/custom_drawer.dart +++ b/lib/widgets/custom_drawer.dart @@ -260,48 +260,6 @@ class _CustomDrawerState extends State { ), ), - // 分隔线 - const Divider( - height: 0.5, thickness: 0.5, color: Color(0xFFEEEEEE)), - - // Markdown 阅读 - InkWell( - onTap: () { - Navigator.pop(context); - Navigator.push( - context, - MaterialPageRoute(builder: (_) => const MdReaderTabPage()), - ); - }, - borderRadius: BorderRadius.zero, - child: const Padding( - padding: EdgeInsets.symmetric(vertical: 13, horizontal: 16), - child: Row( - children: [ - Icon(Icons.description_outlined, - size: 18, color: Color(0xFF666666)), - SizedBox(width: 10), - Expanded( - child: Text( - 'MD阅读', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Color(0xFF1A1A1A)), - ), - ), - Text( - '浏览本地 md 文件', - style: TextStyle(fontSize: 11, color: Color(0xFFBBBBBB)), - ), - SizedBox(width: 6), - Icon(Icons.chevron_right, - size: 16, color: Color(0xFFCCCCCC)), - ], - ), - ), - ), - // 分隔线 const Divider( height: 0.5, thickness: 0.5, color: Color(0xFFEEEEEE)), @@ -315,8 +273,7 @@ class _CustomDrawerState extends State { MaterialPageRoute(builder: (_) => const TagManagementPage()), ); }, - borderRadius: - const BorderRadius.vertical(bottom: Radius.circular(10)), + borderRadius: BorderRadius.zero, child: const Padding( padding: EdgeInsets.symmetric(vertical: 13, horizontal: 16), child: Row( @@ -344,6 +301,49 @@ class _CustomDrawerState extends State { ), ), ), + + // 分隔线 + const Divider( + height: 0.5, thickness: 0.5, color: Color(0xFFEEEEEE)), + + // Markdown 阅读 + InkWell( + onTap: () { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const MdReaderTabPage()), + ); + }, + borderRadius: + const BorderRadius.vertical(bottom: Radius.circular(10)), + child: const Padding( + padding: EdgeInsets.symmetric(vertical: 13, horizontal: 16), + child: Row( + children: [ + Icon(Icons.description_outlined, + size: 18, color: Color(0xFF666666)), + SizedBox(width: 10), + Expanded( + child: Text( + 'MD阅读', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF1A1A1A)), + ), + ), + Text( + '浏览本地 md 文件', + style: TextStyle(fontSize: 11, color: Color(0xFFBBBBBB)), + ), + SizedBox(width: 6), + Icon(Icons.chevron_right, + size: 16, color: Color(0xFFCCCCCC)), + ], + ), + ), + ), ], ), ), diff --git a/lib/widgets/movie_status_bar.dart b/lib/widgets/movie_status_bar.dart index 7777612..7b7b72b 100644 --- a/lib/widgets/movie_status_bar.dart +++ b/lib/widgets/movie_status_bar.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/app_provider.dart'; -/// 观影状态选择栏 - 现代胶囊式设计 +/// 观影状态选择栏 - 水滴滑动动画 class MovieStatusBar extends StatelessWidget { const MovieStatusBar({super.key}); @@ -24,27 +24,63 @@ class MovieStatusBar extends StatelessWidget { color: const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(24), ), - child: Row( - children: [ - _buildStatusItem( - label: '已看', - icon: Icons.check_circle_outline, - isSelected: provider.movieStatusIndex == 0, - onTap: () => provider.setMovieStatusIndex(0), - ), - _buildStatusItem( - label: '在看', - icon: Icons.play_circle_outline, - isSelected: provider.movieStatusIndex == 1, - onTap: () => provider.setMovieStatusIndex(1), - ), - _buildStatusItem( - label: '想看', - icon: Icons.bookmark_outline, - isSelected: provider.movieStatusIndex == 2, - onTap: () => provider.setMovieStatusIndex(2), - ), - ], + child: LayoutBuilder( + builder: (context, constraints) { + final tabWidth = constraints.maxWidth / 3; + return SizedBox( + height: 40, + child: Stack( + children: [ + AnimatedPositioned( + duration: const Duration(milliseconds: 800), + curve: Curves.elasticOut, + left: provider.movieStatusIndex * tabWidth, + top: 0, + bottom: 0, + width: tabWidth, + child: Padding( + padding: const EdgeInsets.all(3), + child: Container( + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _buildTab( + label: '已看', + icon: Icons.check_circle_outline, + isSelected: provider.movieStatusIndex == 0, + onTap: () => provider.setMovieStatusIndex(0), + ), + _buildTab( + label: '在看', + icon: Icons.play_circle_outline, + isSelected: provider.movieStatusIndex == 1, + onTap: () => provider.setMovieStatusIndex(1), + ), + _buildTab( + label: '想看', + icon: Icons.bookmark_outline, + isSelected: provider.movieStatusIndex == 2, + onTap: () => provider.setMovieStatusIndex(2), + ), + ], + ), + ], + ), + ); + }, ), ), ); @@ -52,8 +88,7 @@ class MovieStatusBar extends StatelessWidget { ); } - /// 构建状态项 - Widget _buildStatusItem({ + Widget _buildTab({ required String label, required IconData icon, required bool isSelected, @@ -61,26 +96,14 @@ class MovieStatusBar extends StatelessWidget { }) { return Expanded( child: GestureDetector( + behavior: HitTestBehavior.opaque, onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - padding: const EdgeInsets.symmetric(vertical: 10), - decoration: BoxDecoration( - color: isSelected ? const Color(0xFF1A1A1A) : Colors.transparent, - borderRadius: BorderRadius.circular(20), - boxShadow: isSelected - ? [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ] - : null, - ), + child: Container( + height: double.infinity, + alignment: Alignment.center, child: Row( mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, children: [ Icon( icon, diff --git a/server/stats.db b/server/stats.db index cf1335d..f72dfe5 100644 Binary files a/server/stats.db and b/server/stats.db differ