diff --git a/lib/pages/book/book_detail_page.dart b/lib/pages/book/book_detail_page.dart index 99bf901..5360fbd 100644 --- a/lib/pages/book/book_detail_page.dart +++ b/lib/pages/book/book_detail_page.dart @@ -28,13 +28,14 @@ class _BookDetailPageState extends State { late int _detailStyle; final ValueNotifier _coverOffset = ValueNotifier(0.0); double _coverDragStartOffset = 0.0; - bool _draggingCover = false; + final ValueNotifier _draggingCover = ValueNotifier(false); final GlobalKey _coverImageKey = GlobalKey(); double _coverImageHeight = 0.0; @override void dispose() { _coverOffset.dispose(); + _draggingCover.dispose(); super.dispose(); } @@ -42,7 +43,7 @@ class _BookDetailPageState extends State { void initState() { super.initState(); _detailStyle = UserPrefs().detailPageStyle; - _coverOffset.value = widget.book.coverOffset; + _coverOffset.value = UserPrefs().getCoverOffset(widget.book.id); } @override @@ -351,7 +352,7 @@ class _BookDetailPageState extends State { final box = ctx.findRenderObject() as RenderBox?; if (box != null) _coverImageHeight = box.size.height; } - setState(() => _draggingCover = true); + _draggingCover.value = true; _coverDragStartOffset = _coverOffset.value; } : null, onLongPressMoveUpdate: hasCover ? (d) { @@ -361,8 +362,10 @@ class _BookDetailPageState extends State { _coverOffset.value = (raw.clamp(minOffset, 0.0) as double); } : null, onLongPressEnd: hasCover ? (_) { - setState(() => _draggingCover = false); - context.read().updateBook(book.copyWith(coverOffset: _coverOffset.value)); + _draggingCover.value = false; + final offset = _coverOffset.value; + UserPrefs().setCoverOffset(widget.book.id, offset); + context.read().updateBookCoverOffset(widget.book.id, offset); } : null, child: ValueListenableBuilder( valueListenable: _coverOffset, @@ -389,44 +392,54 @@ class _BookDetailPageState extends State { ) else _buildCoverPlaceholder(), - if (!_draggingCover) - Positioned( - left: 0, right: 0, bottom: 0, - child: IgnorePointer( - child: Container( - height: 60, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - colors.surface.withValues(alpha: 0), - colors.surface, - ], + // 底部渐变 + 拖动遮罩 + ValueListenableBuilder( + valueListenable: _draggingCover, + builder: (context, dragging, _) { + return Stack( + children: [ + if (!dragging) + Positioned( + left: 0, right: 0, bottom: 0, + child: IgnorePointer( + child: Container( + height: 60, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + colors.surface.withValues(alpha: 0), + colors.surface, + ], + ), + ), + ), + ), ), - ), - ), - ), - ), - if (_draggingCover) ...[ - Positioned.fill( - child: Container(color: Colors.black.withValues(alpha: 0.3)), - ), - Positioned( - left: 0, right: 0, bottom: 20, - child: Center( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.6), - borderRadius: BorderRadius.circular(20), - ), - child: const Text('上下滑动调整图片位置', - style: TextStyle(fontSize: 13, color: Colors.white70)), - ), - ), - ), - ], + if (dragging) ...[ + Positioned.fill( + child: Container(color: Colors.black.withValues(alpha: 0.3)), + ), + Positioned( + left: 0, right: 0, bottom: 20, + child: Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(20), + ), + child: const Text('上下滑动调整图片位置', + style: TextStyle(fontSize: 13, color: Colors.white70)), + ), + ), + ), + ], + ], + ); + }, + ), ], ); }, diff --git a/lib/pages/book/book_form_page.dart b/lib/pages/book/book_form_page.dart index 794eb96..e06d0fb 100644 --- a/lib/pages/book/book_form_page.dart +++ b/lib/pages/book/book_form_page.dart @@ -135,10 +135,17 @@ class _BookFormPageState extends State { final colors = Theme.of(context).colorScheme; final isEdit = widget.book != null; - return Scaffold( - backgroundColor: colors.surface, - appBar: AppBar( - title: Text(isEdit ? '编辑书籍' : '添加书籍'), + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + final shouldPop = await _confirmLeave(); + if (shouldPop && context.mounted) Navigator.pop(context); + }, + child: Scaffold( + backgroundColor: colors.surface, + appBar: AppBar( + title: Text(isEdit ? '编辑书籍' : '添加书籍'), actions: [ // 保存按钮 _buildActionButton( @@ -325,6 +332,7 @@ class _BookFormPageState extends State { ], ), ), + ), ); } @@ -1264,6 +1272,56 @@ class _BookFormPageState extends State { ); } + /// 检查表单是否有内容 + bool _hasContent() { + if (widget.book != null) return true; + if (_titleController.text.trim().isNotEmpty) return true; + if (_summaryController.text.trim().isNotEmpty) return true; + if (_ratingController.text.trim().isNotEmpty) return true; + if (_isbnController.text.trim().isNotEmpty) return true; + if (_publisherController.text.trim().isNotEmpty) return true; + if (_coverPath != null) return true; + if (_authors.isNotEmpty || _alternateTitles.isNotEmpty || _genres.isNotEmpty) return true; + if (_publishDate != null) return true; + return false; + } + + /// 离开确认 + Future _confirmLeave() async { + if (!_hasContent()) return true; + final colors = Theme.of(context).colorScheme; + final result = await 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: Text('当前内容未保存,确定要离开吗?', + style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))), + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + style: ElevatedButton.styleFrom( + backgroundColor: colors.error, + foregroundColor: colors.onError, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + child: const Text('离开'), + ), + ], + actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + ); + return result ?? false; + } + /// 保存书籍 Future _saveBook() async { if (!_formKey.currentState!.validate()) { diff --git a/lib/pages/book/book_tab_page.dart b/lib/pages/book/book_tab_page.dart index 1410f49..72c2be3 100644 --- a/lib/pages/book/book_tab_page.dart +++ b/lib/pages/book/book_tab_page.dart @@ -90,10 +90,10 @@ class _BookTabPageState extends State { _lastStatusIndex = statusIdx; _initialized = true; final status = _statusMap[statusIdx] ?? 'read'; - setState(() { _isLoading = true; _items.clear(); _offset = 0; _hasMore = true; }); + setState(() { _isLoading = true; _offset = 0; _hasMore = true; }); final list = await provider.loadBooksPaged(status: status, offset: 0); if (!mounted) return; - setState(() { _items.addAll(list); _offset = list.length; _hasMore = list.length >= 20; _isLoading = false; }); + setState(() { _items.clear(); _items.addAll(list); _offset = list.length; _hasMore = list.length >= 20; _isLoading = false; }); } Future _loadMore() async { diff --git a/lib/pages/movies/movie_detail_page.dart b/lib/pages/movies/movie_detail_page.dart index 3a40c38..a9728e5 100644 --- a/lib/pages/movies/movie_detail_page.dart +++ b/lib/pages/movies/movie_detail_page.dart @@ -32,7 +32,7 @@ class _MovieDetailPageState extends State { late int _detailStyle; final ValueNotifier _posterOffset = ValueNotifier(0.0); double _posterDragStartOffset = 0.0; - bool _draggingPoster = false; + final ValueNotifier _draggingPoster = ValueNotifier(false); final GlobalKey _posterImageKey = GlobalKey(); double _posterImageHeight = 0.0; @@ -41,7 +41,14 @@ class _MovieDetailPageState extends State { super.initState(); _showExactDate = UserPrefs().showExactReleaseDate; _detailStyle = UserPrefs().detailPageStyle; - _posterOffset.value = widget.movie.coverOffset; + _posterOffset.value = UserPrefs().getCoverOffset(widget.movie.id); + } + + @override + void dispose() { + _posterOffset.dispose(); + _draggingPoster.dispose(); + super.dispose(); } void _toggleDateDisplay() { @@ -403,7 +410,7 @@ class _MovieDetailPageState extends State { final box = ctx.findRenderObject() as RenderBox?; if (box != null) _posterImageHeight = box.size.height; } - setState(() => _draggingPoster = true); + _draggingPoster.value = true; _posterDragStartOffset = _posterOffset.value; } : null, onLongPressMoveUpdate: hasPoster ? (d) { @@ -414,8 +421,10 @@ class _MovieDetailPageState extends State { _posterOffset.value = (raw.clamp(minOffset, 0.0) as double); } : null, onLongPressEnd: hasPoster ? (_) { - setState(() => _draggingPoster = false); - context.read().updateMovie(movie.copyWith(coverOffset: _posterOffset.value)); + _draggingPoster.value = false; + final offset = _posterOffset.value; + UserPrefs().setCoverOffset(widget.movie.id, offset); + context.read().updateMovieCoverOffset(widget.movie.id, offset); } : null, child: ValueListenableBuilder( valueListenable: _posterOffset, @@ -442,46 +451,54 @@ class _MovieDetailPageState extends State { ) else _buildPosterPlaceholder(), - // 底部白色渐变过渡(拖动时隐藏) - if (!_draggingPoster) - Positioned( - left: 0, right: 0, bottom: 0, - child: IgnorePointer( - child: Container( - height: 60, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - colors.surface.withValues(alpha: 0), - colors.surface, - ], + // 底部渐变 + 拖动遮罩 + ValueListenableBuilder( + valueListenable: _draggingPoster, + builder: (context, dragging, _) { + return Stack( + children: [ + if (!dragging) + Positioned( + left: 0, right: 0, bottom: 0, + child: IgnorePointer( + child: Container( + height: 60, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + colors.surface.withValues(alpha: 0), + colors.surface, + ], + ), + ), + ), + ), ), - ), - ), - ), - ), - // 长按拖动时的遮罩 + 提示 - if (_draggingPoster) ...[ - Positioned.fill( - child: Container(color: Colors.black.withValues(alpha: 0.3)), - ), - Positioned( - left: 0, right: 0, bottom: 20, - child: Center( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.6), - borderRadius: BorderRadius.circular(20), - ), - child: const Text('上下滑动调整图片位置', - style: TextStyle(fontSize: 13, color: Colors.white70)), - ), - ), - ), - ], + if (dragging) ...[ + Positioned.fill( + child: Container(color: Colors.black.withValues(alpha: 0.3)), + ), + Positioned( + left: 0, right: 0, bottom: 20, + child: Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(20), + ), + child: const Text('上下滑动调整图片位置', + style: TextStyle(fontSize: 13, color: Colors.white70)), + ), + ), + ), + ], + ], + ); + }, + ), ], ); }, diff --git a/lib/pages/movies/movie_form_page.dart b/lib/pages/movies/movie_form_page.dart index dffbbf9..6200936 100644 --- a/lib/pages/movies/movie_form_page.dart +++ b/lib/pages/movies/movie_form_page.dart @@ -385,10 +385,17 @@ class _MovieFormPageState extends State { final colors = Theme.of(context).colorScheme; final isEdit = widget.movie != null; - return Scaffold( - backgroundColor: colors.surface, - appBar: AppBar( - title: Text(isEdit ? '编辑影视' : '添加影视'), + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + final shouldPop = await _confirmLeave(); + if (shouldPop && context.mounted) Navigator.pop(context); + }, + child: Scaffold( + backgroundColor: colors.surface, + appBar: AppBar( + title: Text(isEdit ? '编辑影视' : '添加影视'), actions: [ // 快捷添加按钮(仅添加模式显示) if (!isEdit) @@ -598,10 +605,9 @@ class _MovieFormPageState extends State { ], ), ), + ), ); } - - /// 构建信息卡片(用于网格布局) Widget _buildInfoCard({ required String label, required String value, @@ -1800,6 +1806,55 @@ class _MovieFormPageState extends State { } } + /// 检查表单是否有内容 + bool _hasContent() { + if (widget.movie != null) return true; // 编辑模式始终需要确认 + if (_titleController.text.trim().isNotEmpty) return true; + if (_summaryController.text.trim().isNotEmpty) return true; + if (_ratingController.text.trim().isNotEmpty) return true; + if (_posterPath != null) return true; + if (_directors.isNotEmpty || _writers.isNotEmpty || _actors.isNotEmpty) return true; + if (_genres.isNotEmpty || _alternateTitles.isNotEmpty) return true; + if (_releaseDate != null || _watchDate != null) return true; + return false; + } + + /// 离开确认 + Future _confirmLeave() async { + if (!_hasContent()) return true; + final colors = Theme.of(context).colorScheme; + final result = await 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: Text('当前内容未保存,确定要离开吗?', + style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))), + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + style: ElevatedButton.styleFrom( + backgroundColor: colors.error, + foregroundColor: colors.onError, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + child: const Text('离开'), + ), + ], + actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + ); + return result ?? false; + } + /// 保存影视 Future _saveMovie() async { if (!_formKey.currentState!.validate()) { diff --git a/lib/pages/movies/movie_tab_page.dart b/lib/pages/movies/movie_tab_page.dart index 5662da0..47529d4 100644 --- a/lib/pages/movies/movie_tab_page.dart +++ b/lib/pages/movies/movie_tab_page.dart @@ -92,10 +92,11 @@ class _MovieTabPageState extends State { _lastStatusIndex = statusIdx; _initialized = true; final status = _statusMap[statusIdx] ?? 'watched'; - setState(() { _isLoading = true; _items.clear(); _offset = 0; _hasMore = true; }); + setState(() { _isLoading = true; _offset = 0; _hasMore = true; }); final list = await provider.loadMoviesPaged(status: status, offset: 0); if (!mounted) return; setState(() { + _items.clear(); _items.addAll(list); _offset = list.length; _hasMore = list.length >= 20; diff --git a/lib/pages/note/note_detail_page.dart b/lib/pages/note/note_detail_page.dart index 26021cf..b5dbfad 100644 --- a/lib/pages/note/note_detail_page.dart +++ b/lib/pages/note/note_detail_page.dart @@ -1,4 +1,3 @@ -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:provider/provider.dart'; @@ -6,7 +5,6 @@ import '../../providers/app_provider.dart'; import '../../widgets/fade_in_local_image.dart'; import '../../models/data_models.dart'; import 'note_share_page.dart'; -import '../../widgets/fade_in_local_image.dart'; /// 笔记详情页 class NoteDetailPage extends StatefulWidget { diff --git a/lib/pages/note/note_form_page.dart b/lib/pages/note/note_form_page.dart index e96c1d3..4f7c379 100644 --- a/lib/pages/note/note_form_page.dart +++ b/lib/pages/note/note_form_page.dart @@ -30,7 +30,6 @@ class _NoteFormPageState extends State { final ImagePicker _picker = ImagePicker(); String? _tempNoteId; // 新建模式时使用的临时笔记ID String _editorMode = 'edit'; // 'edit' | 'preview' - int _charCount = 0; static const _weekdays = ['一', '二', '三', '四', '五', '六', '日']; @@ -41,10 +40,6 @@ class _NoteFormPageState extends State { _titleController = TextEditingController(text: note?.title ?? ''); final text = note?.content ?? ''; _contentController = TextEditingController(text: text); - _charCount = text.length; - _contentController.addListener(() { - setState(() => _charCount = _contentController.text.length); - }); _createdAt = note?.createdAt ?? DateTime.now(); _tags = note != null ? List.from(note.tags) : []; _images = note != null ? List.from(note.images) : []; @@ -61,123 +56,109 @@ class _NoteFormPageState extends State { @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; - return Scaffold( - backgroundColor: colors.surface, - resizeToAvoidBottomInset: true, - appBar: AppBar( - title: GestureDetector( - onLongPress: _showTitleDialog, - child: _buildAppBarTitle(), - ), - actions: [ - Padding( - padding: const EdgeInsets.only(right: 12), - child: TextButton( - onPressed: _saveNote, - style: TextButton.styleFrom( - backgroundColor: colors.primary, - foregroundColor: colors.onPrimary, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - minimumSize: Size.zero, - ), - child: const Text('保存', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), - ), - ), - ], - ), - body: Stack( - children: [ - // 主内容区域 — 图片网格固定在内容下方 - Padding( - padding: const EdgeInsets.only(bottom: 56), - child: Column( - children: [ - // 顶部信息栏 - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(color: colors.outline, width: 0.5), + final topPadding = MediaQuery.of(context).padding.top; + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + final shouldPop = await _confirmLeave(); + if (shouldPop && context.mounted) Navigator.pop(context); + }, + child: Scaffold( + backgroundColor: colors.surface, + resizeToAvoidBottomInset: false, + body: LayoutBuilder( + builder: (context, constraints) { + final keyboardH = MediaQuery.of(context).viewInsets.bottom; + final contentH = constraints.maxHeight * 0.4; + return Stack( + children: [ + Column( + children: [ + // 顶部区域 — 固定不动 + _buildHeader(colors, topPadding), + + // 可滚动内容 + Expanded( + child: CustomScrollView( + slivers: [ + // 标题行(点击编辑) + SliverToBoxAdapter(child: _buildTitleInput(colors)), + + // 编辑区域 — 固定高度 + SliverToBoxAdapter( + child: SizedBox(height: contentH, child: _buildContentArea()), + ), + + // 图片 + 标签 + 字数 — 随内容撑开 + SliverToBoxAdapter(child: _buildImageGrid()), + SliverToBoxAdapter(child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: _buildTagChips(), + )), + SliverToBoxAdapter(child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Text( + '一共${_contentController.text.length}字', + style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.3)), + ), + )), + + // 与工具栏的间距 + const SliverToBoxAdapter(child: SizedBox(height: 80)), + ], ), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - // 大日号 - Text( - '${_createdAt.day}', - style: TextStyle( - fontSize: 30, - fontWeight: FontWeight.w200, - color: colors.onSurface.withValues(alpha: 0.75), - height: 1.0, - ), - ), - const SizedBox(width: 8), - // 右边:年月 + 时分 纵向排列 - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '${_createdAt.year}/${_createdAt.month.toString().padLeft(2, '0')} 周${_weekdays[_createdAt.weekday - 1]}', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w500, - color: colors.onSurface.withValues(alpha: 0.6), - ), - ), - const SizedBox(height: 1), - Text( - '${_createdAt.hour.toString().padLeft(2, '0')}:${_createdAt.minute.toString().padLeft(2, '0')}', - style: TextStyle( - fontSize: 11, - color: colors.onSurface.withValues(alpha: 0.4), - ), - ), - ], - ), - const Spacer(), - // 字数统计 - Text( - '$_charCount 字', - style: TextStyle( - fontSize: 11, - color: colors.onSurface.withValues(alpha: 0.35), - ), - ), - ], - ), - ], - ), - ), + ], + ), - // 标签行 - _buildTagRow(), + // 底部浮动工具栏 — 跟随键盘上移 + Positioned( + left: 0, + right: 0, + bottom: keyboardH, + child: _buildFloatingToolbar(), + ), + ], + ); + }, + ), + ), + ); + } - // 编辑 / 预览区域 - Expanded( - child: _buildContentArea(), - ), - - // 图片区域 - _buildImageRow(), - ], + /// 顶部区域:返回 / 年月日 周几 / 保存按钮 + Widget _buildHeader(ColorScheme colors, double topPadding) { + return Container( + padding: EdgeInsets.fromLTRB(4, topPadding + 4, 12, 10), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: colors.outlineVariant, width: 0.5)), + ), + child: Row( + children: [ + IconButton( + onPressed: () async { + final shouldPop = await _confirmLeave(); + if (shouldPop && context.mounted) Navigator.pop(context); + }, + icon: Icon(Icons.arrow_back_ios_new, size: 20, color: colors.onSurface.withValues(alpha: 0.7)), + ), + Expanded( + child: Text( + '${_createdAt.year}年${_createdAt.month}月${_createdAt.day}日 周${_weekdays[_createdAt.weekday - 1]}', + style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: colors.onSurface), ), ), - - // 底部浮动工具栏 — 独立于主内容,键盘弹起时单独上移 - Positioned( - left: 0, - right: 0, - bottom: 0, - child: _buildFloatingToolbar(), + GestureDetector( + onTap: _saveNote, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + decoration: BoxDecoration( + color: colors.primary, + borderRadius: BorderRadius.circular(20), + ), + child: Text('保存', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onPrimary)), + ), ), ], ), @@ -389,17 +370,17 @@ class _NoteFormPageState extends State { strutStyle: const StrutStyle( forceStrutHeight: true, height: 1.6, - fontSize: 16, + fontSize: 14, ), style: TextStyle( - fontSize: 16, + fontSize: 14, color: colors.onSurface, height: 1.6, ), decoration: InputDecoration( hintText: '使用 Markdown 格式书写...', hintStyle: TextStyle( - fontSize: 16, + fontSize: 14, color: colors.onSurface.withValues(alpha: 0.25), height: 1.6, ), @@ -480,24 +461,15 @@ class _NoteFormPageState extends State { )); } - /// 构建标签选择器 - /// 构建标签横向滚动行 - Widget _buildTagRow() { - return Container( - height: 28, - margin: const EdgeInsets.only(top: 6, bottom: 2), - child: ListView.separated( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 16), - itemCount: _tags.length + 1, - separatorBuilder: (_, __) => const SizedBox(width: 6), - itemBuilder: (context, index) { - if (index < _tags.length) { - return _buildTagChip(index); - } - return _buildAddTagButton(); - }, - ), + /// 标签 chips 行(右侧展示) + Widget _buildTagChips() { + return Wrap( + spacing: 6, + runSpacing: 4, + children: [ + for (int i = 0; i < _tags.length; i++) _buildTagChip(i), + _buildAddTagButton(), + ], ); } @@ -726,96 +698,72 @@ class _NoteFormPageState extends State { } } - Widget _buildAppBarTitle() { - final colors = Theme.of(context).colorScheme; - final base = _isEditing ? '编辑笔记' : '新建笔记'; - final t = _titleController.text.trim(); - if (t.isEmpty) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(base), - const SizedBox(width: 6), - Icon(Icons.edit, size: 14, color: colors.onSurface.withValues(alpha: 0.4)), - ], - ); - } - final display = t.length > 4 ? '${t.substring(0, 4)}…' : t; - return Text('$base($display)'); - } - - /// 长按标题弹出标题编辑窗口 - void _showTitleDialog() { - final controller = TextEditingController(text: _titleController.text); - showDialog( - context: context, - builder: (context) { - final colors = Theme.of(context).colorScheme; - return AlertDialog( - backgroundColor: colors.surface, - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - title: const Text( - '编辑标题', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + /// 标题输入行 + Widget _buildTitleInput(ColorScheme colors) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: TextField( + controller: _titleController, + maxLines: 1, + style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: colors.onSurface), + decoration: InputDecoration( + hintText: '添加标题', + hintStyle: TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: colors.onSurface.withValues(alpha: 0.2)), + border: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(vertical: 8), + isDense: true, ), - content: TextField( - controller: controller, - autofocus: true, - style: TextStyle(fontSize: 16, color: colors.onSurface), - decoration: InputDecoration( - hintText: '输入标题...', - hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.25)), - filled: true, - fillColor: colors.surfaceContainerHigh, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: colors.outline, width: 0.5), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: colors.outline, width: 0.5), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: colors.primary, width: 1), - ), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - ), - onSubmitted: (value) { - setState(() => _titleController.text = value.trim()); - Navigator.pop(context); - }, - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - style: TextButton.styleFrom( - foregroundColor: colors.onSurface.withValues(alpha: 0.6), - ), - child: const Text('取消'), - ), - ElevatedButton( - onPressed: () { - setState(() => _titleController.text = controller.text.trim()); - Navigator.pop(context); - }, - style: ElevatedButton.styleFrom( - backgroundColor: colors.primary, - foregroundColor: colors.onPrimary, - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - ), - child: const Text('确定'), - ), - ], - actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - ); - }, + onChanged: (_) => setState(() {}), + ), ); } - /// 保存笔记 + /// 检查表单是否有内容 + bool _hasContent() { + if (_isEditing) return true; + if (_titleController.text.trim().isNotEmpty) return true; + if (_contentController.text.trim().isNotEmpty) return true; + if (_images.isNotEmpty) return true; + if (_tags.isNotEmpty) return true; + return false; + } + + /// 离开确认 + Future _confirmLeave() async { + if (!_hasContent()) return true; + final colors = Theme.of(context).colorScheme; + final result = await 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: Text('当前内容未保存,确定要离开吗?', + style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))), + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + style: ElevatedButton.styleFrom( + backgroundColor: colors.error, + foregroundColor: colors.onError, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + child: const Text('离开'), + ), + ], + actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + ); + return result ?? false; + } Future _saveNote() async { final content = _contentController.text.trim(); final title = _titleController.text.trim(); @@ -956,48 +904,41 @@ class _NoteFormPageState extends State { } } - /// 构建图片横向滚动行 - Widget _buildImageRow() { - final colors = Theme.of(context).colorScheme; - if (_images.isEmpty) { - return Container( - height: 80, - decoration: BoxDecoration( - color: colors.surface, - ), - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - children: [ - _buildAddImageButton(), - ], - ), - ); - } - + /// 构建图片网格(一行三个) + Widget _buildImageGrid() { return Container( - height: 88, - decoration: BoxDecoration( - color: colors.surface, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.02), - blurRadius: 4, - offset: const Offset(0, -1), - ), + width: double.infinity, + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Wrap( + spacing: 10, + runSpacing: 10, + children: [ + for (int i = 0; i < _images.length; i++) _buildGridImageItem(i), + _buildAddImageButton(), ], ), - child: ListView.separated( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - itemCount: _images.length + 1, - separatorBuilder: (_, __) => const SizedBox(width: 10), - itemBuilder: (context, index) { - if (index < _images.length) { - return _buildImageItem(index); - } - return _buildAddImageButton(); - }, + ); + } + + Widget _buildGridImageItem(int index) { + final colors = Theme.of(context).colorScheme; + final size = (MediaQuery.of(context).size.width - 16 * 2 - 10 * 2) / 3; + return InkWell( + onTap: () => _showImagePreview(index), + onLongPress: () => _showDeleteImageDialog(index), + borderRadius: BorderRadius.circular(10), + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all(color: colors.outline, width: 0.5), + ), + clipBehavior: Clip.antiAlias, + child: FadeInLocalImage( + path: _images[index], + fit: BoxFit.cover, + ), ), ); } @@ -1005,49 +946,27 @@ class _NoteFormPageState extends State { /// 添加图片按钮 Widget _buildAddImageButton() { final colors = Theme.of(context).colorScheme; + final size = (MediaQuery.of(context).size.width - 16 * 2 - 10 * 2) / 3; return InkWell( onTap: _pickImage, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(10), child: Container( - width: 64, - height: 64, + width: size, + height: size, decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(10), color: colors.surfaceContainerHigh, border: Border.all(color: colors.outline, width: 0.5), ), child: Icon( Icons.add_photo_alternate_outlined, - size: 24, + size: 28, color: colors.onSurface.withValues(alpha: 0.3), ), ), ); } - /// 构建图片项 - Widget _buildImageItem(int index) { - final colors = Theme.of(context).colorScheme; - return InkWell( - onTap: () => _showImagePreview(index), - onLongPress: () => _showDeleteImageDialog(index), - borderRadius: BorderRadius.circular(12), - child: Container( - width: 64, - height: 64, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all(color: colors.outline, width: 0.5), - ), - clipBehavior: Clip.antiAlias, - child: FadeInLocalImage( - path: _images[index], - fit: BoxFit.cover, - ), - ), - ); - } - /// 显示图片预览 void _showImagePreview(int index) { showDialog( diff --git a/lib/pages/note/note_tab_page.dart b/lib/pages/note/note_tab_page.dart index cf8b191..c9dd036 100644 --- a/lib/pages/note/note_tab_page.dart +++ b/lib/pages/note/note_tab_page.dart @@ -81,10 +81,10 @@ class _NoteTabPageState extends State { Future _loadFirst() async { _initialized = true; - setState(() { _isLoading = true; _items.clear(); _offset = 0; _hasMore = true; }); + setState(() { _isLoading = true; _offset = 0; _hasMore = true; }); final list = await context.read().loadNotesPaged(offset: 0); if (!mounted) return; - setState(() { _items.addAll(list); _offset = list.length; _hasMore = list.length >= 20; _isLoading = false; }); + setState(() { _items.clear(); _items.addAll(list); _offset = list.length; _hasMore = list.length >= 20; _isLoading = false; }); } Future _loadMore() async { @@ -118,6 +118,7 @@ class _NoteTabPageState extends State { Widget _buildSkeleton() { switch (_layoutStyle) { case 1: return _buildWaterfallSkeleton(); + case 2: return const NoteSkeletonTimeline(); default: return const NoteSkeletonList(); } } diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 6ccdeec..40c1417 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -322,6 +322,26 @@ class AppProvider extends ChangeNotifier { await loadMovies(); } + /// 仅更新封面偏移量(不触发全量刷新) + Future updateMovieCoverOffset(String movieId, double offset) async { + await _movieDao.updateCoverOffset(movieId, offset); + final idx = _movies.indexWhere((m) => m.id == movieId); + if (idx != -1) { + _movies[idx] = _movies[idx].copyWith(coverOffset: offset); + notifyListeners(); + } + } + + /// 仅更新封面偏移量(不触发全量刷新) + Future updateBookCoverOffset(String bookId, double offset) async { + await _bookDao.updateCoverOffset(bookId, offset); + final idx = _books.indexWhere((b) => b.id == bookId); + if (idx != -1) { + _books[idx] = _books[idx].copyWith(coverOffset: offset); + notifyListeners(); + } + } + Future removeMovie(String id) async { if (_useRemote) { await ServerDataService.instance.deleteMovie(id); diff --git a/lib/utils/book/book_dao.dart b/lib/utils/book/book_dao.dart index 3d963c4..8f9ca06 100644 --- a/lib/utils/book/book_dao.dart +++ b/lib/utils/book/book_dao.dart @@ -82,6 +82,12 @@ class BookDao { ); }); + // 仅更新封面偏移量(不触发全量刷新) + Future updateCoverOffset(String bookId, double offset) => _wrap('updateCoverOffset', () async { + final db = await _dbHelper.database; + await db.update('books', {'cover_offset': offset}, where: 'id = ?', whereArgs: [bookId]); + }); + // 软删除书籍记录(移入回收站) Future deleteBook(String id) => _wrap('deleteBook', () async { final db = await _dbHelper.database; diff --git a/lib/utils/database_helper.dart b/lib/utils/database_helper.dart index 97de5f7..21adfd0 100644 --- a/lib/utils/database_helper.dart +++ b/lib/utils/database_helper.dart @@ -57,7 +57,7 @@ class DatabaseHelper { return await openDatabase( path, - version: 15, + version: 16, onCreate: _createDB, onUpgrade: _onUpgrade, ); @@ -119,8 +119,26 @@ class DatabaseHelper { await _createReaderBooksTable(db); } if (oldVersion < 15) { - await db.execute('ALTER TABLE movies ADD COLUMN cover_offset REAL DEFAULT 0'); - await db.execute('ALTER TABLE books ADD COLUMN cover_offset REAL DEFAULT 0'); + // 安全添加 cover_offset 列(防止列已存在时报错) + final movieCols = await db.rawQuery('PRAGMA table_info(movies)'); + if (!movieCols.any((col) => col['name'] == 'cover_offset')) { + await db.execute('ALTER TABLE movies ADD COLUMN cover_offset REAL DEFAULT 0'); + } + final bookCols = await db.rawQuery('PRAGMA table_info(books)'); + if (!bookCols.any((col) => col['name'] == 'cover_offset')) { + await db.execute('ALTER TABLE books ADD COLUMN cover_offset REAL DEFAULT 0'); + } + } + // v16: 确保 cover_offset 列存在(v15 的数据库可能缺少此列) + if (oldVersion < 16) { + final movieCols = await db.rawQuery('PRAGMA table_info(movies)'); + if (!movieCols.any((col) => col['name'] == 'cover_offset')) { + await db.execute('ALTER TABLE movies ADD COLUMN cover_offset REAL DEFAULT 0'); + } + final bookCols = await db.rawQuery('PRAGMA table_info(books)'); + if (!bookCols.any((col) => col['name'] == 'cover_offset')) { + await db.execute('ALTER TABLE books ADD COLUMN cover_offset REAL DEFAULT 0'); + } } } diff --git a/lib/utils/movie/movie_dao.dart b/lib/utils/movie/movie_dao.dart index bc78e54..e69bc1e 100644 --- a/lib/utils/movie/movie_dao.dart +++ b/lib/utils/movie/movie_dao.dart @@ -139,6 +139,12 @@ class MovieDao { ); }); + // 仅更新封面偏移量(不触发全量刷新) + Future updateCoverOffset(String movieId, double offset) => _wrap('updateCoverOffset', () async { + final db = await _dbHelper.database; + await db.update('movies', {'cover_offset': offset}, where: 'id = ?', whereArgs: [movieId]); + }); + // 删除影视记录(软删除) Future deleteMovie(String id) => _wrap('deleteMovie', () async { final db = await _dbHelper.database; diff --git a/lib/utils/user_prefs.dart b/lib/utils/user_prefs.dart index a4a9362..7b5409f 100644 --- a/lib/utils/user_prefs.dart +++ b/lib/utils/user_prefs.dart @@ -61,6 +61,12 @@ class UserPrefs { int get detailPageStyle => prefs.getInt('detailPageStyle') ?? 0; Future setDetailPageStyle(int value) => prefs.setInt('detailPageStyle', value); + // ========== 封面位置 ========== + + /// 获取封面偏移量(-1.0 到 1.0,0 = 居中) + double getCoverOffset(String itemId) => prefs.getDouble('coverOffset_$itemId') ?? 0.0; + Future setCoverOffset(String itemId, double value) => prefs.setDouble('coverOffset_$itemId', value); + // ========== 主界面显示设置 ========== /// 是否启用底部导航栏滚动隐藏(默认开启) diff --git a/lib/widgets/shimmer_skeleton.dart b/lib/widgets/shimmer_skeleton.dart index 91f28ed..87e0c2a 100644 --- a/lib/widgets/shimmer_skeleton.dart +++ b/lib/widgets/shimmer_skeleton.dart @@ -168,3 +168,61 @@ class NoteSkeletonList extends StatelessWidget { ); } } + +/// 笔记时间线骨架屏 +class NoteSkeletonTimeline extends StatelessWidget { + const NoteSkeletonTimeline({super.key}); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return ListView.builder( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 100), + itemCount: 5, + itemBuilder: (_, __) => IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: 40, + child: Column( + children: [ + Container( + width: 10, height: 10, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + shape: BoxShape.circle, + ), + ), + Expanded(child: Container(width: 1, color: colors.outline)), + ], + ), + ), + Expanded( + child: Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + ), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShimmerSkeleton(width: 80, height: 11), + SizedBox(height: 8), + ShimmerSkeleton(width: 150, height: 15), + SizedBox(height: 6), + ShimmerSkeleton(width: double.infinity, height: 12), + SizedBox(height: 4), + ShimmerSkeleton(width: 200, height: 12), + ], + ), + ), + ), + ], + ), + ), + ); + } +}