generated from dellevin/template
完善
This commit is contained in:
@@ -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)),
|
],
|
||||||
),
|
],
|
||||||
),
|
);
|
||||||
),
|
},
|
||||||
],
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)),
|
],
|
||||||
),
|
);
|
||||||
),
|
},
|
||||||
),
|
),
|
||||||
],
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.0,0 = 居中)
|
||||||
|
double getCoverOffset(String itemId) => prefs.getDouble('coverOffset_$itemId') ?? 0.0;
|
||||||
|
Future<bool> setCoverOffset(String itemId, double value) => prefs.setDouble('coverOffset_$itemId', value);
|
||||||
|
|
||||||
// ========== 主界面显示设置 ==========
|
// ========== 主界面显示设置 ==========
|
||||||
|
|
||||||
/// 是否启用底部导航栏滚动隐藏(默认开启)
|
/// 是否启用底部导航栏滚动隐藏(默认开启)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user