This commit is contained in:
DelLevin-Home
2026-06-20 05:01:59 +08:00
parent 3f186c7521
commit c7f74c61f5
15 changed files with 572 additions and 396 deletions

View File

@@ -28,13 +28,14 @@ class _BookDetailPageState extends State<BookDetailPage> {
late int _detailStyle; late int _detailStyle;
final ValueNotifier<double> _coverOffset = ValueNotifier(0.0); final ValueNotifier<double> _coverOffset = ValueNotifier(0.0);
double _coverDragStartOffset = 0.0; double _coverDragStartOffset = 0.0;
bool _draggingCover = false; final ValueNotifier<bool> _draggingCover = ValueNotifier(false);
final GlobalKey _coverImageKey = GlobalKey(); final GlobalKey _coverImageKey = GlobalKey();
double _coverImageHeight = 0.0; double _coverImageHeight = 0.0;
@override @override
void dispose() { void dispose() {
_coverOffset.dispose(); _coverOffset.dispose();
_draggingCover.dispose();
super.dispose(); super.dispose();
} }
@@ -42,7 +43,7 @@ class _BookDetailPageState extends State<BookDetailPage> {
void initState() { void initState() {
super.initState(); super.initState();
_detailStyle = UserPrefs().detailPageStyle; _detailStyle = UserPrefs().detailPageStyle;
_coverOffset.value = widget.book.coverOffset; _coverOffset.value = UserPrefs().getCoverOffset(widget.book.id);
} }
@override @override
@@ -351,7 +352,7 @@ class _BookDetailPageState extends State<BookDetailPage> {
final box = ctx.findRenderObject() as RenderBox?; final box = ctx.findRenderObject() as RenderBox?;
if (box != null) _coverImageHeight = box.size.height; if (box != null) _coverImageHeight = box.size.height;
} }
setState(() => _draggingCover = true); _draggingCover.value = true;
_coverDragStartOffset = _coverOffset.value; _coverDragStartOffset = _coverOffset.value;
} : null, } : null,
onLongPressMoveUpdate: hasCover ? (d) { onLongPressMoveUpdate: hasCover ? (d) {
@@ -361,8 +362,10 @@ class _BookDetailPageState extends State<BookDetailPage> {
_coverOffset.value = (raw.clamp(minOffset, 0.0) as double); _coverOffset.value = (raw.clamp(minOffset, 0.0) as double);
} : null, } : null,
onLongPressEnd: hasCover ? (_) { onLongPressEnd: hasCover ? (_) {
setState(() => _draggingCover = false); _draggingCover.value = false;
context.read<AppProvider>().updateBook(book.copyWith(coverOffset: _coverOffset.value)); final offset = _coverOffset.value;
UserPrefs().setCoverOffset(widget.book.id, offset);
context.read<AppProvider>().updateBookCoverOffset(widget.book.id, offset);
} : null, } : null,
child: ValueListenableBuilder<double>( child: ValueListenableBuilder<double>(
valueListenable: _coverOffset, valueListenable: _coverOffset,
@@ -389,44 +392,54 @@ class _BookDetailPageState extends State<BookDetailPage> {
) )
else else
_buildCoverPlaceholder(), _buildCoverPlaceholder(),
if (!_draggingCover) // 底部渐变 + 拖动遮罩
Positioned( ValueListenableBuilder<bool>(
left: 0, right: 0, bottom: 0, valueListenable: _draggingCover,
child: IgnorePointer( builder: (context, dragging, _) {
child: Container( return Stack(
height: 60, children: [
decoration: BoxDecoration( if (!dragging)
gradient: LinearGradient( Positioned(
begin: Alignment.topCenter, left: 0, right: 0, bottom: 0,
end: Alignment.bottomCenter, child: IgnorePointer(
colors: [ child: Container(
colors.surface.withValues(alpha: 0), height: 60,
colors.surface, decoration: BoxDecoration(
], gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colors.surface.withValues(alpha: 0),
colors.surface,
],
),
),
),
),
), ),
), if (dragging) ...[
), Positioned.fill(
), child: Container(color: Colors.black.withValues(alpha: 0.3)),
), ),
if (_draggingCover) ...[ Positioned(
Positioned.fill( left: 0, right: 0, bottom: 20,
child: Container(color: Colors.black.withValues(alpha: 0.3)), child: Center(
), child: Container(
Positioned( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
left: 0, right: 0, bottom: 20, decoration: BoxDecoration(
child: Center( color: Colors.black.withValues(alpha: 0.6),
child: Container( borderRadius: BorderRadius.circular(20),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), ),
decoration: BoxDecoration( child: const Text('上下滑动调整图片位置',
color: Colors.black.withValues(alpha: 0.6), style: TextStyle(fontSize: 13, color: Colors.white70)),
borderRadius: BorderRadius.circular(20), ),
), ),
child: const Text('上下滑动调整图片位置', ),
style: TextStyle(fontSize: 13, color: Colors.white70)), ],
), ],
), );
), },
], ),
], ],
); );
}, },

View File

@@ -135,10 +135,17 @@ class _BookFormPageState extends State<BookFormPage> {
final colors = Theme.of(context).colorScheme; final colors = Theme.of(context).colorScheme;
final isEdit = widget.book != null; final isEdit = widget.book != null;
return Scaffold( return PopScope(
backgroundColor: colors.surface, canPop: false,
appBar: AppBar( onPopInvokedWithResult: (didPop, result) async {
title: Text(isEdit ? '编辑书籍' : '添加书籍'), 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: [ actions: [
// 保存按钮 // 保存按钮
_buildActionButton( _buildActionButton(
@@ -325,6 +332,7 @@ class _BookFormPageState extends State<BookFormPage> {
], ],
), ),
), ),
),
); );
} }
@@ -1264,6 +1272,56 @@ class _BookFormPageState extends State<BookFormPage> {
); );
} }
/// 检查表单是否有内容
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<bool> _confirmLeave() async {
if (!_hasContent()) return true;
final colors = Theme.of(context).colorScheme;
final result = await showDialog<bool>(
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<void> _saveBook() async { Future<void> _saveBook() async {
if (!_formKey.currentState!.validate()) { if (!_formKey.currentState!.validate()) {

View File

@@ -90,10 +90,10 @@ class _BookTabPageState extends State<BookTabPage> {
_lastStatusIndex = statusIdx; _lastStatusIndex = statusIdx;
_initialized = true; _initialized = true;
final status = _statusMap[statusIdx] ?? 'read'; 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); final list = await provider.loadBooksPaged(status: status, offset: 0);
if (!mounted) return; 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<void> _loadMore() async { Future<void> _loadMore() async {

View File

@@ -32,7 +32,7 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
late int _detailStyle; late int _detailStyle;
final ValueNotifier<double> _posterOffset = ValueNotifier(0.0); final ValueNotifier<double> _posterOffset = ValueNotifier(0.0);
double _posterDragStartOffset = 0.0; double _posterDragStartOffset = 0.0;
bool _draggingPoster = false; final ValueNotifier<bool> _draggingPoster = ValueNotifier(false);
final GlobalKey _posterImageKey = GlobalKey(); final GlobalKey _posterImageKey = GlobalKey();
double _posterImageHeight = 0.0; double _posterImageHeight = 0.0;
@@ -41,7 +41,14 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
super.initState(); super.initState();
_showExactDate = UserPrefs().showExactReleaseDate; _showExactDate = UserPrefs().showExactReleaseDate;
_detailStyle = UserPrefs().detailPageStyle; _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() { void _toggleDateDisplay() {
@@ -403,7 +410,7 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
final box = ctx.findRenderObject() as RenderBox?; final box = ctx.findRenderObject() as RenderBox?;
if (box != null) _posterImageHeight = box.size.height; if (box != null) _posterImageHeight = box.size.height;
} }
setState(() => _draggingPoster = true); _draggingPoster.value = true;
_posterDragStartOffset = _posterOffset.value; _posterDragStartOffset = _posterOffset.value;
} : null, } : null,
onLongPressMoveUpdate: hasPoster ? (d) { onLongPressMoveUpdate: hasPoster ? (d) {
@@ -414,8 +421,10 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
_posterOffset.value = (raw.clamp(minOffset, 0.0) as double); _posterOffset.value = (raw.clamp(minOffset, 0.0) as double);
} : null, } : null,
onLongPressEnd: hasPoster ? (_) { onLongPressEnd: hasPoster ? (_) {
setState(() => _draggingPoster = false); _draggingPoster.value = false;
context.read<AppProvider>().updateMovie(movie.copyWith(coverOffset: _posterOffset.value)); final offset = _posterOffset.value;
UserPrefs().setCoverOffset(widget.movie.id, offset);
context.read<AppProvider>().updateMovieCoverOffset(widget.movie.id, offset);
} : null, } : null,
child: ValueListenableBuilder<double>( child: ValueListenableBuilder<double>(
valueListenable: _posterOffset, valueListenable: _posterOffset,
@@ -442,46 +451,54 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
) )
else else
_buildPosterPlaceholder(), _buildPosterPlaceholder(),
// 底部白色渐变过渡(拖动时隐藏) // 底部渐变 + 拖动遮罩
if (!_draggingPoster) ValueListenableBuilder<bool>(
Positioned( valueListenable: _draggingPoster,
left: 0, right: 0, bottom: 0, builder: (context, dragging, _) {
child: IgnorePointer( return Stack(
child: Container( children: [
height: 60, if (!dragging)
decoration: BoxDecoration( Positioned(
gradient: LinearGradient( left: 0, right: 0, bottom: 0,
begin: Alignment.topCenter, child: IgnorePointer(
end: Alignment.bottomCenter, child: Container(
colors: [ height: 60,
colors.surface.withValues(alpha: 0), decoration: BoxDecoration(
colors.surface, gradient: LinearGradient(
], begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colors.surface.withValues(alpha: 0),
colors.surface,
],
),
),
),
),
), ),
), if (dragging) ...[
), Positioned.fill(
), child: Container(color: Colors.black.withValues(alpha: 0.3)),
), ),
// 长按拖动时的遮罩 + 提示 Positioned(
if (_draggingPoster) ...[ left: 0, right: 0, bottom: 20,
Positioned.fill( child: Center(
child: Container(color: Colors.black.withValues(alpha: 0.3)), child: Container(
), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
Positioned( decoration: BoxDecoration(
left: 0, right: 0, bottom: 20, color: Colors.black.withValues(alpha: 0.6),
child: Center( borderRadius: BorderRadius.circular(20),
child: Container( ),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: const Text('上下滑动调整图片位置',
decoration: BoxDecoration( style: TextStyle(fontSize: 13, color: Colors.white70)),
color: Colors.black.withValues(alpha: 0.6), ),
borderRadius: BorderRadius.circular(20), ),
), ),
child: const Text('上下滑动调整图片位置', ],
style: TextStyle(fontSize: 13, color: Colors.white70)), ],
), );
), },
), ),
],
], ],
); );
}, },

View File

@@ -385,10 +385,17 @@ class _MovieFormPageState extends State<MovieFormPage> {
final colors = Theme.of(context).colorScheme; final colors = Theme.of(context).colorScheme;
final isEdit = widget.movie != null; final isEdit = widget.movie != null;
return Scaffold( return PopScope(
backgroundColor: colors.surface, canPop: false,
appBar: AppBar( onPopInvokedWithResult: (didPop, result) async {
title: Text(isEdit ? '编辑影视' : '添加影视'), 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: [ actions: [
// 快捷添加按钮(仅添加模式显示) // 快捷添加按钮(仅添加模式显示)
if (!isEdit) if (!isEdit)
@@ -598,10 +605,9 @@ class _MovieFormPageState extends State<MovieFormPage> {
], ],
), ),
), ),
),
); );
} }
/// 构建信息卡片(用于网格布局)
Widget _buildInfoCard({ Widget _buildInfoCard({
required String label, required String label,
required String value, required String value,
@@ -1800,6 +1806,55 @@ class _MovieFormPageState extends State<MovieFormPage> {
} }
} }
/// 检查表单是否有内容
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<bool> _confirmLeave() async {
if (!_hasContent()) return true;
final colors = Theme.of(context).colorScheme;
final result = await showDialog<bool>(
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<void> _saveMovie() async { Future<void> _saveMovie() async {
if (!_formKey.currentState!.validate()) { if (!_formKey.currentState!.validate()) {

View File

@@ -92,10 +92,11 @@ class _MovieTabPageState extends State<MovieTabPage> {
_lastStatusIndex = statusIdx; _lastStatusIndex = statusIdx;
_initialized = true; _initialized = true;
final status = _statusMap[statusIdx] ?? 'watched'; 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); final list = await provider.loadMoviesPaged(status: status, offset: 0);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_items.clear();
_items.addAll(list); _items.addAll(list);
_offset = list.length; _offset = list.length;
_hasMore = list.length >= 20; _hasMore = list.length >= 20;

View File

@@ -1,4 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -6,7 +5,6 @@ import '../../providers/app_provider.dart';
import '../../widgets/fade_in_local_image.dart'; import '../../widgets/fade_in_local_image.dart';
import '../../models/data_models.dart'; import '../../models/data_models.dart';
import 'note_share_page.dart'; import 'note_share_page.dart';
import '../../widgets/fade_in_local_image.dart';
/// 笔记详情页 /// 笔记详情页
class NoteDetailPage extends StatefulWidget { class NoteDetailPage extends StatefulWidget {

View File

@@ -30,7 +30,6 @@ class _NoteFormPageState extends State<NoteFormPage> {
final ImagePicker _picker = ImagePicker(); final ImagePicker _picker = ImagePicker();
String? _tempNoteId; // 新建模式时使用的临时笔记ID String? _tempNoteId; // 新建模式时使用的临时笔记ID
String _editorMode = 'edit'; // 'edit' | 'preview' String _editorMode = 'edit'; // 'edit' | 'preview'
int _charCount = 0;
static const _weekdays = ['', '', '', '', '', '', '']; static const _weekdays = ['', '', '', '', '', '', ''];
@@ -41,10 +40,6 @@ class _NoteFormPageState extends State<NoteFormPage> {
_titleController = TextEditingController(text: note?.title ?? ''); _titleController = TextEditingController(text: note?.title ?? '');
final text = note?.content ?? ''; final text = note?.content ?? '';
_contentController = TextEditingController(text: text); _contentController = TextEditingController(text: text);
_charCount = text.length;
_contentController.addListener(() {
setState(() => _charCount = _contentController.text.length);
});
_createdAt = note?.createdAt ?? DateTime.now(); _createdAt = note?.createdAt ?? DateTime.now();
_tags = note != null ? List.from(note.tags) : []; _tags = note != null ? List.from(note.tags) : [];
_images = note != null ? List.from(note.images) : []; _images = note != null ? List.from(note.images) : [];
@@ -61,123 +56,109 @@ class _NoteFormPageState extends State<NoteFormPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme; final colors = Theme.of(context).colorScheme;
return Scaffold( final topPadding = MediaQuery.of(context).padding.top;
backgroundColor: colors.surface, return PopScope(
resizeToAvoidBottomInset: true, canPop: false,
appBar: AppBar( onPopInvokedWithResult: (didPop, result) async {
title: GestureDetector( if (didPop) return;
onLongPress: _showTitleDialog, final shouldPop = await _confirmLeave();
child: _buildAppBarTitle(), if (shouldPop && context.mounted) Navigator.pop(context);
), },
actions: [ child: Scaffold(
Padding( backgroundColor: colors.surface,
padding: const EdgeInsets.only(right: 12), resizeToAvoidBottomInset: false,
child: TextButton( body: LayoutBuilder(
onPressed: _saveNote, builder: (context, constraints) {
style: TextButton.styleFrom( final keyboardH = MediaQuery.of(context).viewInsets.bottom;
backgroundColor: colors.primary, final contentH = constraints.maxHeight * 0.4;
foregroundColor: colors.onPrimary, return Stack(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), children: [
shape: RoundedRectangleBorder( Column(
borderRadius: BorderRadius.circular(20), children: [
), // 顶部区域 — 固定不动
minimumSize: Size.zero, _buildHeader(colors, topPadding),
),
child: const Text('保存', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), // 可滚动内容
), Expanded(
), child: CustomScrollView(
], slivers: [
), // 标题行(点击编辑)
body: Stack( SliverToBoxAdapter(child: _buildTitleInput(colors)),
children: [
// 主内容区域 — 图片网格固定在内容下方 // 编辑区域 — 固定高度
Padding( SliverToBoxAdapter(
padding: const EdgeInsets.only(bottom: 56), child: SizedBox(height: contentH, child: _buildContentArea()),
child: Column( ),
children: [
// 顶部信息栏 // 图片 + 标签 + 字数 — 随内容撑开
Container( SliverToBoxAdapter(child: _buildImageGrid()),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), SliverToBoxAdapter(child: Padding(
decoration: BoxDecoration( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
border: Border( child: _buildTagChips(),
bottom: BorderSide(color: colors.outline, width: 0.5), )),
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( Widget _buildHeader(ColorScheme colors, double topPadding) {
child: _buildContentArea(), return Container(
), padding: EdgeInsets.fromLTRB(4, topPadding + 4, 12, 10),
decoration: BoxDecoration(
// 图片区域 border: Border(bottom: BorderSide(color: colors.outlineVariant, width: 0.5)),
_buildImageRow(), ),
], 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),
), ),
), ),
GestureDetector(
// 底部浮动工具栏 — 独立于主内容,键盘弹起时单独上移 onTap: _saveNote,
Positioned( child: Container(
left: 0, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
right: 0, decoration: BoxDecoration(
bottom: 0, color: colors.primary,
child: _buildFloatingToolbar(), borderRadius: BorderRadius.circular(20),
),
child: Text('保存', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onPrimary)),
),
), ),
], ],
), ),
@@ -389,17 +370,17 @@ class _NoteFormPageState extends State<NoteFormPage> {
strutStyle: const StrutStyle( strutStyle: const StrutStyle(
forceStrutHeight: true, forceStrutHeight: true,
height: 1.6, height: 1.6,
fontSize: 16, fontSize: 14,
), ),
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 14,
color: colors.onSurface, color: colors.onSurface,
height: 1.6, height: 1.6,
), ),
decoration: InputDecoration( decoration: InputDecoration(
hintText: '使用 Markdown 格式书写...', hintText: '使用 Markdown 格式书写...',
hintStyle: TextStyle( hintStyle: TextStyle(
fontSize: 16, fontSize: 14,
color: colors.onSurface.withValues(alpha: 0.25), color: colors.onSurface.withValues(alpha: 0.25),
height: 1.6, height: 1.6,
), ),
@@ -480,24 +461,15 @@ class _NoteFormPageState extends State<NoteFormPage> {
)); ));
} }
/// 构建标签选择器 /// 标签 chips 行(右侧展示)
/// 构建标签横向滚动行 Widget _buildTagChips() {
Widget _buildTagRow() { return Wrap(
return Container( spacing: 6,
height: 28, runSpacing: 4,
margin: const EdgeInsets.only(top: 6, bottom: 2), children: [
child: ListView.separated( for (int i = 0; i < _tags.length; i++) _buildTagChip(i),
scrollDirection: Axis.horizontal, _buildAddTagButton(),
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();
},
),
); );
} }
@@ -726,96 +698,72 @@ class _NoteFormPageState extends State<NoteFormPage> {
} }
} }
Widget _buildAppBarTitle() { /// 标题输入行
final colors = Theme.of(context).colorScheme; Widget _buildTitleInput(ColorScheme colors) {
final base = _isEditing ? '编辑笔记' : '新建笔记'; return Padding(
final t = _titleController.text.trim(); padding: const EdgeInsets.symmetric(horizontal: 16),
if (t.isEmpty) { child: TextField(
return Row( controller: _titleController,
mainAxisSize: MainAxisSize.min, maxLines: 1,
children: [ style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: colors.onSurface),
Text(base), decoration: InputDecoration(
const SizedBox(width: 6), hintText: '添加标题',
Icon(Icons.edit, size: 14, color: colors.onSurface.withValues(alpha: 0.4)), 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),
final display = t.length > 4 ? '${t.substring(0, 4)}' : t; isDense: true,
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),
), ),
content: TextField( onChanged: (_) => setState(() {}),
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),
);
},
); );
} }
/// 保存笔记 /// 检查表单是否有内容
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<bool> _confirmLeave() async {
if (!_hasContent()) return true;
final colors = Theme.of(context).colorScheme;
final result = await showDialog<bool>(
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<void> _saveNote() async { Future<void> _saveNote() async {
final content = _contentController.text.trim(); final content = _contentController.text.trim();
final title = _titleController.text.trim(); final title = _titleController.text.trim();
@@ -956,48 +904,41 @@ class _NoteFormPageState extends State<NoteFormPage> {
} }
} }
/// 构建图片横向滚动行 /// 构建图片网格(一行三个)
Widget _buildImageRow() { Widget _buildImageGrid() {
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(),
],
),
);
}
return Container( return Container(
height: 88, width: double.infinity,
decoration: BoxDecoration( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
color: colors.surface, child: Wrap(
boxShadow: [ spacing: 10,
BoxShadow( runSpacing: 10,
color: Colors.black.withValues(alpha: 0.02), children: [
blurRadius: 4, for (int i = 0; i < _images.length; i++) _buildGridImageItem(i),
offset: const Offset(0, -1), _buildAddImageButton(),
),
], ],
), ),
child: ListView.separated( );
scrollDirection: Axis.horizontal, }
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
itemCount: _images.length + 1, Widget _buildGridImageItem(int index) {
separatorBuilder: (_, __) => const SizedBox(width: 10), final colors = Theme.of(context).colorScheme;
itemBuilder: (context, index) { final size = (MediaQuery.of(context).size.width - 16 * 2 - 10 * 2) / 3;
if (index < _images.length) { return InkWell(
return _buildImageItem(index); onTap: () => _showImagePreview(index),
} onLongPress: () => _showDeleteImageDialog(index),
return _buildAddImageButton(); 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<NoteFormPage> {
/// 添加图片按钮 /// 添加图片按钮
Widget _buildAddImageButton() { Widget _buildAddImageButton() {
final colors = Theme.of(context).colorScheme; final colors = Theme.of(context).colorScheme;
final size = (MediaQuery.of(context).size.width - 16 * 2 - 10 * 2) / 3;
return InkWell( return InkWell(
onTap: _pickImage, onTap: _pickImage,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(10),
child: Container( child: Container(
width: 64, width: size,
height: 64, height: size,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(10),
color: colors.surfaceContainerHigh, color: colors.surfaceContainerHigh,
border: Border.all(color: colors.outline, width: 0.5), border: Border.all(color: colors.outline, width: 0.5),
), ),
child: Icon( child: Icon(
Icons.add_photo_alternate_outlined, Icons.add_photo_alternate_outlined,
size: 24, size: 28,
color: colors.onSurface.withValues(alpha: 0.3), 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) { void _showImagePreview(int index) {
showDialog( showDialog(

View File

@@ -81,10 +81,10 @@ class _NoteTabPageState extends State<NoteTabPage> {
Future<void> _loadFirst() async { Future<void> _loadFirst() async {
_initialized = true; _initialized = true;
setState(() { _isLoading = true; _items.clear(); _offset = 0; _hasMore = true; }); setState(() { _isLoading = true; _offset = 0; _hasMore = true; });
final list = await context.read<AppProvider>().loadNotesPaged(offset: 0); final list = await context.read<AppProvider>().loadNotesPaged(offset: 0);
if (!mounted) return; 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<void> _loadMore() async { Future<void> _loadMore() async {
@@ -118,6 +118,7 @@ class _NoteTabPageState extends State<NoteTabPage> {
Widget _buildSkeleton() { Widget _buildSkeleton() {
switch (_layoutStyle) { switch (_layoutStyle) {
case 1: return _buildWaterfallSkeleton(); case 1: return _buildWaterfallSkeleton();
case 2: return const NoteSkeletonTimeline();
default: return const NoteSkeletonList(); default: return const NoteSkeletonList();
} }
} }

View File

@@ -322,6 +322,26 @@ class AppProvider extends ChangeNotifier {
await loadMovies(); await loadMovies();
} }
/// 仅更新封面偏移量(不触发全量刷新)
Future<void> 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<void> 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<void> removeMovie(String id) async { Future<void> removeMovie(String id) async {
if (_useRemote) { if (_useRemote) {
await ServerDataService.instance.deleteMovie(id); await ServerDataService.instance.deleteMovie(id);

View File

@@ -82,6 +82,12 @@ class BookDao {
); );
}); });
// 仅更新封面偏移量(不触发全量刷新)
Future<void> 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<int> deleteBook(String id) => _wrap('deleteBook', () async { Future<int> deleteBook(String id) => _wrap('deleteBook', () async {
final db = await _dbHelper.database; final db = await _dbHelper.database;

View File

@@ -57,7 +57,7 @@ class DatabaseHelper {
return await openDatabase( return await openDatabase(
path, path,
version: 15, version: 16,
onCreate: _createDB, onCreate: _createDB,
onUpgrade: _onUpgrade, onUpgrade: _onUpgrade,
); );
@@ -119,8 +119,26 @@ class DatabaseHelper {
await _createReaderBooksTable(db); await _createReaderBooksTable(db);
} }
if (oldVersion < 15) { if (oldVersion < 15) {
await db.execute('ALTER TABLE movies ADD COLUMN cover_offset REAL DEFAULT 0'); // 安全添加 cover_offset 列(防止列已存在时报错)
await db.execute('ALTER TABLE books ADD COLUMN cover_offset REAL DEFAULT 0'); 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');
}
} }
} }

View File

@@ -139,6 +139,12 @@ class MovieDao {
); );
}); });
// 仅更新封面偏移量(不触发全量刷新)
Future<void> 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<int> deleteMovie(String id) => _wrap('deleteMovie', () async { Future<int> deleteMovie(String id) => _wrap('deleteMovie', () async {
final db = await _dbHelper.database; final db = await _dbHelper.database;

View File

@@ -61,6 +61,12 @@ class UserPrefs {
int get detailPageStyle => prefs.getInt('detailPageStyle') ?? 0; int get detailPageStyle => prefs.getInt('detailPageStyle') ?? 0;
Future<bool> setDetailPageStyle(int value) => prefs.setInt('detailPageStyle', value); Future<bool> setDetailPageStyle(int value) => prefs.setInt('detailPageStyle', value);
// ========== 封面位置 ==========
/// 获取封面偏移量(-1.0 到 1.00 = 居中)
double getCoverOffset(String itemId) => prefs.getDouble('coverOffset_$itemId') ?? 0.0;
Future<bool> setCoverOffset(String itemId, double value) => prefs.setDouble('coverOffset_$itemId', value);
// ========== 主界面显示设置 ========== // ========== 主界面显示设置 ==========
/// 是否启用底部导航栏滚动隐藏(默认开启) /// 是否启用底部导航栏滚动隐藏(默认开启)

View File

@@ -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),
],
),
),
),
],
),
),
);
}
}