diff --git a/lib/pages/book/book_detail_page.dart b/lib/pages/book/book_detail_page.dart index 3ef872d..b273473 100644 --- a/lib/pages/book/book_detail_page.dart +++ b/lib/pages/book/book_detail_page.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../../widgets/fade_in_local_image.dart'; import 'package:share_plus/share_plus.dart'; import 'package:cross_file/cross_file.dart'; import '../../providers/app_provider.dart'; @@ -188,10 +189,9 @@ class _BookDetailPageState extends State { Widget _buildCoverSection(Book book) { return SizedBox.expand( child: book.coverPath != null && book.coverPath!.isNotEmpty - ? Image.file( - File(book.coverPath!), + ? FadeInLocalImage( + path: book.coverPath, fit: BoxFit.cover, - errorBuilder: (_, __, ___) => _buildCoverPlaceholder(), ) : _buildCoverPlaceholder(), ); diff --git a/lib/pages/book/book_form_page.dart b/lib/pages/book/book_form_page.dart index e65a5bc..794eb96 100644 --- a/lib/pages/book/book_form_page.dart +++ b/lib/pages/book/book_form_page.dart @@ -6,6 +6,7 @@ import 'package:path/path.dart' as p; import 'package:provider/provider.dart'; import 'package:http/http.dart' as http; import '../../providers/app_provider.dart'; +import '../../widgets/fade_in_local_image.dart'; import '../../models/data_models.dart'; import '../../utils/toast_util.dart'; import '../../utils/image_path_helper.dart'; @@ -921,10 +922,9 @@ class _BookFormPageState extends State { ), clipBehavior: Clip.antiAlias, child: hasCover - ? Image.file( - File(_coverPath!), + ? FadeInLocalImage( + path: _coverPath, fit: BoxFit.cover, - errorBuilder: (_, __, ___) => _buildCoverPlaceholder(), ) : _buildCoverPlaceholder(), ), diff --git a/lib/pages/book/book_share_page.dart b/lib/pages/book/book_share_page.dart index 29d12dd..6de8066 100644 --- a/lib/pages/book/book_share_page.dart +++ b/lib/pages/book/book_share_page.dart @@ -6,6 +6,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; import '../../models/data_models.dart'; import '../../utils/toast_util.dart'; +import '../../widgets/fade_in_local_image.dart'; /// 书籍分享海报页面 class BookSharePage extends StatefulWidget { @@ -102,8 +103,8 @@ class _BookSharePageState extends State { if (hasCover) ClipRRect( borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), - child: Image.file( - File(book.coverPath!), + child: FadeInLocalImage( + path: book.coverPath, width: 320, height: 200, fit: BoxFit.cover, diff --git a/lib/pages/book/book_tab_page.dart b/lib/pages/book/book_tab_page.dart index 1b4f83c..5854c8c 100644 --- a/lib/pages/book/book_tab_page.dart +++ b/lib/pages/book/book_tab_page.dart @@ -1,14 +1,15 @@ -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../../models/data_models.dart'; import '../../providers/app_provider.dart'; import '../../utils/user_prefs.dart'; import '../../widgets/book_status_bar.dart'; import '../../widgets/book_list_item.dart'; import '../../widgets/animated_star_rating.dart'; import '../../widgets/shimmer_skeleton.dart'; +import '../../widgets/fade_in_local_image.dart'; -/// 阅读标签页 +/// 阅读标签页(分页 + 触底加载) class BookTabPage extends StatefulWidget { const BookTabPage({super.key}); @@ -18,217 +19,215 @@ class BookTabPage extends StatefulWidget { class _BookTabPageState extends State { int _layoutStyle = 0; - bool _firstLoad = true; + final List _items = []; + bool _hasMore = true; + bool _isLoading = false; + int _offset = 0; + int _lastStatusIndex = -1; + bool _initialized = false; + int _lastDataCount = -1; + DateTime? _lastUpdatedAt; + late ScrollController _scrollController; + + static const _statusMap = {0: 'read', 1: 'reading', 2: 'want_to_read'}; @override void initState() { super.initState(); _layoutStyle = UserPrefs().bookLayoutStyle; + _scrollController = ScrollController()..addListener(_onScroll); WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) setState(() => _firstLoad = false); + final provider = context.read(); + provider.addListener(_onDataChanged); + _lastDataCount = provider.books.length; + if (provider.books.isNotEmpty) _lastUpdatedAt = provider.books.first.updatedAt; + _loadFirst(); }); } @override - Widget build(BuildContext context) { - return Column( - children: [ - const BookStatusBar(), - Expanded( - child: _buildBookList(context), - ), - ], - ); + void dispose() { + _scrollController.dispose(); + super.dispose(); } - Widget _buildBookList(BuildContext context) { + void _onDataChanged() { + if (!_initialized || !mounted) return; + final provider = context.read(); + final count = provider.books.length; + final latest = provider.books.isNotEmpty ? provider.books.first.updatedAt : null; + if (count != _lastDataCount || latest != _lastUpdatedAt) { + _lastDataCount = count; + _lastUpdatedAt = latest; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _loadFirst(); + }); + } + } + + void _onScroll() { + if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { + _loadMore(); + } + } + + Future _loadFirst() async { + final provider = context.read(); + final statusIdx = provider.bookStatusIndex; + _lastStatusIndex = statusIdx; + _initialized = true; + final status = _statusMap[statusIdx] ?? 'read'; + setState(() { _isLoading = true; _items.clear(); _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; }); + } + + Future _loadMore() async { + if (_isLoading || !_hasMore) return; + setState(() => _isLoading = true); + final provider = context.read(); + final status = _statusMap[provider.bookStatusIndex] ?? 'read'; + final list = await provider.loadBooksPaged(status: status, offset: _offset); + if (!mounted) return; + setState(() { _items.addAll(list); _offset += list.length; _hasMore = list.length >= 20; _isLoading = false; }); + } + + Future _refresh() async { + await context.read().loadBooks(); + await _loadFirst(); + } + + @override + Widget build(BuildContext context) { + return Column(children: [ + const BookStatusBar(), + Expanded(child: _buildBody(context)), + ]); + } + + Widget _buildBody(BuildContext context) { final colors = Theme.of(context).colorScheme; - return Consumer( - builder: (context, provider, child) { - final statusMap = {0: 'read', 1: 'reading', 2: 'want_to_read'}; - final currentStatus = statusMap[provider.bookStatusIndex]!; - final books = provider.getBooksByStatus(currentStatus); + return Consumer(builder: (context, provider, _) { + if (_initialized && provider.bookStatusIndex != _lastStatusIndex) { + _lastStatusIndex = provider.bookStatusIndex; + WidgetsBinding.instance.addPostFrameCallback((_) => _loadFirst()); + } + if (_items.isEmpty && _isLoading) return _buildSkeleton(); + if (_items.isEmpty) { + return RefreshIndicator(onRefresh: _refresh, color: colors.primary, backgroundColor: colors.surface, + child: ListView(physics: const AlwaysScrollableScrollPhysics(), children: [_buildEmptyState(context, provider.bookStatusIndex)])); + } + return RefreshIndicator(onRefresh: _refresh, color: colors.primary, backgroundColor: colors.surface, + child: _layoutStyle == 1 ? _buildListView() : _buildGridView()); + }); + } - if (_firstLoad) { - return _buildSkeleton(); - } - - if (books.isEmpty) { - return RefreshIndicator( - onRefresh: () async => await provider.loadBooks(), - color: colors.primary, - backgroundColor: colors.surface, - child: ListView( - physics: const AlwaysScrollableScrollPhysics(), - children: [_buildEmptyState(context, provider.bookStatusIndex)], - ), - ); - } - - if (_layoutStyle == 1) { - return _buildListView(books, provider); - } - return _buildGridView(books, provider); + Widget _buildGridView() { + return GridView.builder(controller: _scrollController, + padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3, childAspectRatio: 0.55, crossAxisSpacing: 12, mainAxisSpacing: 16), + itemCount: _items.length + (_hasMore ? 1 : 0), + itemBuilder: (context, index) { + if (index >= _items.length) return _buildLoadMore(); + return BookListItem(book: _items[index]); }, ); } - Widget _buildGridView(List books, AppProvider provider) { - final colors = Theme.of(context).colorScheme; - return RefreshIndicator( - onRefresh: () async => await provider.loadBooks(), - color: colors.primary, - backgroundColor: colors.surface, - child: GridView.builder( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - childAspectRatio: 0.55, - crossAxisSpacing: 12, - mainAxisSpacing: 16, - ), - itemCount: books.length, - itemBuilder: (context, index) => BookListItem(book: books[index]), - ), + Widget _buildListView() { + return ListView.builder(controller: _scrollController, + padding: const EdgeInsets.fromLTRB(12, 8, 12, 100), + itemCount: _items.length + (_hasMore ? 1 : 0), + itemBuilder: (context, index) { + if (index >= _items.length) return _buildLoadMore(); + return _buildListCard(_items[index]); + }, ); } - Widget _buildListView(List books, AppProvider provider) { - final colors = Theme.of(context).colorScheme; - return RefreshIndicator( - onRefresh: () async => await provider.loadBooks(), - color: colors.primary, - backgroundColor: colors.surface, - child: ListView.builder( - padding: const EdgeInsets.fromLTRB(12, 8, 12, 100), - itemCount: books.length, - itemBuilder: (context, index) => _buildListCard(books[index]), - ), + Widget _buildLoadMore() { + return Padding(padding: const EdgeInsets.symmetric(vertical: 20), + child: Center(child: _isLoading + ? SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Theme.of(context).colorScheme.primary)) + : Text('没有更多了', style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3)))), ); } - Widget _buildListCard(book) { + Widget _buildListCard(Book book) { final colors = Theme.of(context).colorScheme; return GestureDetector( onTap: () => Navigator.pushNamed(context, '/book-detail', arguments: book), onLongPress: () => _showDeleteDialog(context, book), - child: Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: colors.surfaceContainerHigh, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Container( - width: 48, height: 64, - decoration: BoxDecoration( - color: colors.outlineVariant, - borderRadius: BorderRadius.circular(6), - ), - clipBehavior: Clip.antiAlias, - child: book.coverPath != null && book.coverPath!.isNotEmpty - ? Image.file(File(book.coverPath!), fit: BoxFit.cover, - errorBuilder: (_, __, ___) => Icon(Icons.menu_book_outlined, size: 22, color: colors.onSurface.withValues(alpha: 0.25))) - : Icon(Icons.menu_book_outlined, size: 22, color: colors.onSurface.withValues(alpha: 0.25)), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(book.title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)), - if (book.authors.isNotEmpty) ...[ - const SizedBox(height: 3), - Text(book.authors.take(2).join('、'), maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35))), - ], - const SizedBox(height: 6), - if (book.rating != null) - AnimatedStarRating(rating: book.rating!, starSize: 12, showNumber: true) - else - const SizedBox(height: 14), - ], - ), - ), - const SizedBox(width: 8), - Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.2), size: 20), - ], - ), + child: Container(margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(12), + decoration: BoxDecoration(color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(12)), + child: Row(children: [ + Container(width: 48, height: 64, + decoration: BoxDecoration(color: colors.outlineVariant, borderRadius: BorderRadius.circular(6)), + clipBehavior: Clip.antiAlias, + child: book.coverPath != null && book.coverPath!.isNotEmpty + ? FadeInLocalImage(path: book.coverPath, fit: BoxFit.cover, + errorWidget: Icon(Icons.menu_book_outlined, size: 22, color: colors.onSurface.withValues(alpha: 0.25))) + : Icon(Icons.menu_book_outlined, size: 22, color: colors.onSurface.withValues(alpha: 0.25)), + ), + const SizedBox(width: 12), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(book.title, maxLines: 1, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)), + if (book.authors.isNotEmpty) ...[const SizedBox(height: 3), + Text(book.authors.take(2).join('、'), maxLines: 1, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35))), + ], + const SizedBox(height: 6), + if (book.rating != null) AnimatedStarRating(rating: book.rating!, starSize: 12, showNumber: true) + else const SizedBox(height: 14), + ])), + const SizedBox(width: 8), + Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.2), size: 20), + ]), ), ); } - void _showDeleteDialog(BuildContext context, book) { + void _showDeleteDialog(BuildContext context, Book book) { final colors = Theme.of(context).colorScheme; - 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('确定要删除《${book.title}》吗?删除后可在回收站恢复。', - style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))), - ), - ElevatedButton( - onPressed: () async { - await context.read().removeBook(book.id); - Navigator.pop(ctx); - }, - 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), - ), - ); + 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('确定要删除《${book.title}》吗?删除后可在回收站恢复。', + style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))), + ElevatedButton(onPressed: () async { await context.read().removeBook(book.id); Navigator.pop(ctx); _loadFirst(); }, + 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), + )); } - Widget _buildSkeleton() { - return _layoutStyle == 1 - ? MovieSkeletonGrid() - : const BookSkeletonGrid(); - } + Widget _buildSkeleton() => _layoutStyle == 1 ? const MovieSkeletonGrid() : const BookSkeletonGrid(); Widget _buildEmptyState(BuildContext context, int statusIndex) { final colors = Theme.of(context).colorScheme; final statusText = ['已读', '在读', '想读'][statusIndex]; - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 80, height: 80, - decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(20)), - child: Icon(Icons.menu_book_outlined, size: 40, color: colors.onSurface.withValues(alpha: 0.25)), - ), - const SizedBox(height: 20), - Text('暂无$statusText的书籍', style: TextStyle(fontSize: 16, color: colors.onSurface.withValues(alpha: 0.4))), - const SizedBox(height: 24), - InkWell( - onTap: () { - final statusMap = {0: 'read', 1: 'reading', 2: 'want_to_read'}; - Navigator.pushNamed(context, '/book-form', arguments: {'initialStatus': statusMap[statusIndex]!}); - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(8)), - child: Text('添加记录', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: colors.onPrimary)), - ), - ), - ], + return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + Container(width: 80, height: 80, + decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(20)), + child: Icon(Icons.menu_book_outlined, size: 40, color: colors.onSurface.withValues(alpha: 0.25))), + const SizedBox(height: 20), + Text('暂无$statusText的书籍', style: TextStyle(fontSize: 16, color: colors.onSurface.withValues(alpha: 0.4))), + const SizedBox(height: 24), + InkWell(onTap: () { + final statusMap = {0: 'read', 1: 'reading', 2: 'want_to_read'}; + Navigator.pushNamed(context, '/book-form', arguments: {'initialStatus': statusMap[statusIndex]!}); + }, + child: Container(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(8)), + child: Text('添加记录', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: colors.onPrimary))), ), - ); + ])); } } diff --git a/lib/pages/movies/movie_detail_page.dart b/lib/pages/movies/movie_detail_page.dart index 6948e0a..f6ac6a4 100644 --- a/lib/pages/movies/movie_detail_page.dart +++ b/lib/pages/movies/movie_detail_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; +import '../../widgets/fade_in_local_image.dart'; import 'package:share_plus/share_plus.dart'; import 'package:cross_file/cross_file.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -193,10 +194,9 @@ class _MovieDetailPageState extends State { Widget _buildPosterSection(Movie movie) { return SizedBox.expand( child: movie.posterPath != null && movie.posterPath!.isNotEmpty - ? Image.file( - File(movie.posterPath!), + ? FadeInLocalImage( + path: movie.posterPath, fit: BoxFit.cover, - errorBuilder: (_, __, ___) => _buildPosterPlaceholder(), ) : _buildPosterPlaceholder(), ); diff --git a/lib/pages/movies/movie_form_page.dart b/lib/pages/movies/movie_form_page.dart index 76dd575..ff974d8 100644 --- a/lib/pages/movies/movie_form_page.dart +++ b/lib/pages/movies/movie_form_page.dart @@ -6,6 +6,7 @@ import 'package:path/path.dart' as p; import 'package:provider/provider.dart'; import 'package:http/http.dart' as http; import '../../providers/app_provider.dart'; +import '../../widgets/fade_in_local_image.dart'; import '../../models/data_models.dart'; import '../../utils/toast_util.dart'; import '../../utils/image_path_helper.dart'; @@ -1381,10 +1382,9 @@ class _MovieFormPageState extends State { ), clipBehavior: Clip.antiAlias, child: hasPoster - ? Image.file( - File(_posterPath!), + ? FadeInLocalImage( + path: _posterPath, fit: BoxFit.cover, - errorBuilder: (_, __, ___) => _buildCoverPlaceholder(), ) : _buildCoverPlaceholder(), ), diff --git a/lib/pages/movies/movie_posters_page.dart b/lib/pages/movies/movie_posters_page.dart index 1df2372..92ea01c 100644 --- a/lib/pages/movies/movie_posters_page.dart +++ b/lib/pages/movies/movie_posters_page.dart @@ -8,6 +8,7 @@ import 'package:provider/provider.dart'; import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; import 'package:http/http.dart' as http; import '../../providers/app_provider.dart'; +import '../../widgets/fade_in_local_image.dart'; import '../../models/data_models.dart'; import '../../utils/toast_util.dart'; import '../../utils/image_path_helper.dart'; @@ -158,15 +159,9 @@ class _MoviePostersPageState extends State { fit: StackFit.expand, children: [ // 海报图片 - Image.file( - File(poster.posterPath), + FadeInLocalImage( + path: poster.posterPath, fit: BoxFit.cover, - errorBuilder: (_, __, ___) => Center( - child: Icon( - Icons.broken_image, - color: colors.onSurface.withValues(alpha: 0.25), - ), - ), ), // 渐变遮罩(底部) Positioned( diff --git a/lib/pages/movies/movie_review_detail_page.dart b/lib/pages/movies/movie_review_detail_page.dart index d769520..22f61e8 100644 --- a/lib/pages/movies/movie_review_detail_page.dart +++ b/lib/pages/movies/movie_review_detail_page.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../../models/data_models.dart'; import '../../providers/app_provider.dart'; import '../../utils/toast_util.dart'; +import '../../widgets/fade_in_local_image.dart'; import 'movie_review_form_page.dart'; /// 影评详情页 @@ -121,7 +122,8 @@ class _MovieReviewDetailPageState extends State { decoration: BoxDecoration(borderRadius: BorderRadius.circular(6), color: colors.surfaceContainerHighest), clipBehavior: Clip.antiAlias, child: movie.posterPath != null - ? Image.file(File(movie.posterPath!), fit: BoxFit.cover, errorBuilder: (_, __, ___) => Icon(Icons.movie_outlined, size: 22, color: colors.onSurface.withValues(alpha: 0.25))) + ? FadeInLocalImage(path: movie.posterPath, fit: BoxFit.cover, + errorWidget: Icon(Icons.movie_outlined, size: 22, color: colors.onSurface.withValues(alpha: 0.25))) : Icon(Icons.movie_outlined, size: 22, color: colors.onSurface.withValues(alpha: 0.25)), ), const SizedBox(width: 12), diff --git a/lib/pages/movies/movie_review_form_page.dart b/lib/pages/movies/movie_review_form_page.dart index 5941b8c..ea8dca6 100644 --- a/lib/pages/movies/movie_review_form_page.dart +++ b/lib/pages/movies/movie_review_form_page.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../providers/app_provider.dart'; +import '../../widgets/fade_in_local_image.dart'; import '../../models/data_models.dart'; import '../../utils/toast_util.dart'; @@ -155,8 +156,8 @@ class _MovieReviewFormPageState extends State { ), clipBehavior: Clip.antiAlias, child: movie.posterPath != null - ? Image.file(File(movie.posterPath!), fit: BoxFit.cover, - errorBuilder: (_, __, ___) => Icon(Icons.movie_outlined, size: 24, color: colors.onSurface.withValues(alpha: 0.25))) + ? FadeInLocalImage(path: movie.posterPath, fit: BoxFit.cover, + errorWidget: Icon(Icons.movie_outlined, size: 24, color: colors.onSurface.withValues(alpha: 0.25))) : Icon(Icons.movie_outlined, size: 24, color: colors.onSurface.withValues(alpha: 0.25)), ), const SizedBox(width: 14), diff --git a/lib/pages/movies/movie_share_page.dart b/lib/pages/movies/movie_share_page.dart index 59cfe46..88533e5 100644 --- a/lib/pages/movies/movie_share_page.dart +++ b/lib/pages/movies/movie_share_page.dart @@ -7,6 +7,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; import '../../models/data_models.dart'; import '../../utils/toast_util.dart'; +import '../../widgets/fade_in_local_image.dart'; /// 影视分享海报页面 class MovieSharePage extends StatefulWidget { @@ -103,8 +104,8 @@ class _MovieSharePageState extends State { if (hasPoster) ClipRRect( borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), - child: Image.file( - File(movie.posterPath!), + child: FadeInLocalImage( + path: movie.posterPath, width: 320, height: 200, fit: BoxFit.cover, diff --git a/lib/pages/movies/movie_tab_page.dart b/lib/pages/movies/movie_tab_page.dart index ef4089f..0db8512 100644 --- a/lib/pages/movies/movie_tab_page.dart +++ b/lib/pages/movies/movie_tab_page.dart @@ -1,14 +1,15 @@ -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../../models/data_models.dart'; import '../../providers/app_provider.dart'; import '../../utils/user_prefs.dart'; import '../../widgets/movie_status_bar.dart'; import '../../widgets/movie_list_item.dart'; import '../../widgets/animated_star_rating.dart'; import '../../widgets/shimmer_skeleton.dart'; +import '../../widgets/fade_in_local_image.dart'; -/// 观影标签页 +/// 观影标签页(分页 + 触底加载) class MovieTabPage extends StatefulWidget { const MovieTabPage({super.key}); @@ -18,17 +19,98 @@ class MovieTabPage extends StatefulWidget { class _MovieTabPageState extends State { int _layoutStyle = 0; - bool _firstLoad = true; + final List _items = []; + bool _hasMore = true; + bool _isLoading = false; + int _offset = 0; + int _lastStatusIndex = -1; + bool _initialized = false; + int _lastDataCount = -1; + DateTime? _lastUpdatedAt; + late ScrollController _scrollController; + + static const _statusMap = {0: 'watched', 1: 'watching', 2: 'want_to_watch'}; @override void initState() { super.initState(); _layoutStyle = UserPrefs().movieLayoutStyle; + _scrollController = ScrollController()..addListener(_onScroll); WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) setState(() => _firstLoad = false); + final provider = context.read(); + provider.addListener(_onDataChanged); + _lastDataCount = provider.movies.length; + if (provider.movies.isNotEmpty) _lastUpdatedAt = provider.movies.first.updatedAt; + _loadFirst(); }); } + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _onDataChanged() { + if (!_initialized || !mounted) return; + final provider = context.read(); + final count = provider.movies.length; + final latest = provider.movies.isNotEmpty ? provider.movies.first.updatedAt : null; + if (count != _lastDataCount || latest != _lastUpdatedAt) { + _lastDataCount = count; + _lastUpdatedAt = latest; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _loadFirst(); + }); + } + } + + void _onScroll() { + if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { + _loadMore(); + } + } + + String get _currentStatus => _statusMap[context.read().movieStatusIndex] ?? 'watched'; + + Future _loadFirst() async { + final provider = context.read(); + final statusIdx = provider.movieStatusIndex; + _lastStatusIndex = statusIdx; + _initialized = true; + final status = _statusMap[statusIdx] ?? 'watched'; + setState(() { _isLoading = true; _items.clear(); _offset = 0; _hasMore = true; }); + final list = await provider.loadMoviesPaged(status: status, offset: 0); + if (!mounted) return; + setState(() { + _items.addAll(list); + _offset = list.length; + _hasMore = list.length >= 20; + _isLoading = false; + }); + } + + Future _loadMore() async { + if (_isLoading || !_hasMore) return; + setState(() => _isLoading = true); + final provider = context.read(); + final status = _statusMap[provider.movieStatusIndex] ?? 'watched'; + final list = await provider.loadMoviesPaged(status: status, offset: _offset); + if (!mounted) return; + setState(() { + _items.addAll(list); + _offset += list.length; + _hasMore = list.length >= 20; + _isLoading = false; + }); + } + + Future _refresh() async { + final provider = context.read(); + await provider.loadMovies(); + await _loadFirst(); + } + @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; @@ -36,30 +118,26 @@ class _MovieTabPageState extends State { children: [ const MovieStatusBar(), Divider(height: 0.5, thickness: 0.5, color: colors.outline), - Expanded( - child: _buildMovieList(context), - ), + Expanded(child: _buildBody(context)), ], ); } - Widget _buildMovieList(BuildContext context) { + Widget _buildBody(BuildContext context) { final colors = Theme.of(context).colorScheme; return Consumer( - builder: (context, provider, child) { - final statusMap = {0: 'watched', 1: 'watching', 2: 'want_to_watch'}; - final currentStatus = statusMap[provider.movieStatusIndex]!; - final allMovies = provider.movies.where((m) => !m.isDeleted).toList(); - if (_firstLoad && allMovies.isEmpty) { - return _buildSkeleton(); + builder: (context, provider, _) { + // 状态切换时重新加载(跳过首次未初始化的情况) + if (_initialized && provider.movieStatusIndex != _lastStatusIndex) { + _lastStatusIndex = provider.movieStatusIndex; + WidgetsBinding.instance.addPostFrameCallback((_) => _loadFirst()); } - _firstLoad = false; - final movies = provider.getMoviesByStatus(currentStatus); + if (_items.isEmpty && _isLoading) return _buildSkeleton(); - if (movies.isEmpty) { + if (_items.isEmpty) { return RefreshIndicator( - onRefresh: () async => await provider.loadMovies(), + onRefresh: _refresh, color: colors.primary, backgroundColor: colors.surface, child: ListView( @@ -69,49 +147,55 @@ class _MovieTabPageState extends State { ); } - if (_layoutStyle == 1) { - return _buildListView(movies, provider); - } - return _buildGridView(movies, provider); + return RefreshIndicator( + onRefresh: _refresh, + color: colors.primary, + backgroundColor: colors.surface, + child: _layoutStyle == 1 ? _buildListView() : _buildGridView(), + ); }, ); } - Widget _buildGridView(List movies, AppProvider provider) { - final colors = Theme.of(context).colorScheme; - return RefreshIndicator( - onRefresh: () async => await provider.loadMovies(), - color: colors.primary, - backgroundColor: colors.surface, - child: GridView.builder( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - childAspectRatio: 0.55, - crossAxisSpacing: 12, - mainAxisSpacing: 16, - ), - itemCount: movies.length, - itemBuilder: (context, index) => MovieListItem(movie: movies[index]), + Widget _buildGridView() { + return GridView.builder( + controller: _scrollController, + padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, childAspectRatio: 0.55, crossAxisSpacing: 12, mainAxisSpacing: 16, + ), + itemCount: _items.length + (_hasMore ? 1 : 0), + itemBuilder: (context, index) { + if (index >= _items.length) return _buildLoadMoreIndicator(); + return MovieListItem(movie: _items[index]); + }, + ); + } + + Widget _buildListView() { + return ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.fromLTRB(12, 8, 12, 100), + itemCount: _items.length + (_hasMore ? 1 : 0), + itemBuilder: (context, index) { + if (index >= _items.length) return _buildLoadMoreIndicator(); + return _buildListCard(_items[index]); + }, + ); + } + + Widget _buildLoadMoreIndicator() { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Center( + child: _isLoading + ? SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Theme.of(context).colorScheme.primary)) + : Text('没有更多了', style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3))), ), ); } - Widget _buildListView(List movies, AppProvider provider) { - final colors = Theme.of(context).colorScheme; - return RefreshIndicator( - onRefresh: () async => await provider.loadMovies(), - color: colors.primary, - backgroundColor: colors.surface, - child: ListView.builder( - padding: const EdgeInsets.fromLTRB(12, 8, 12, 100), - itemCount: movies.length, - itemBuilder: (context, index) => _buildListCard(movies[index]), - ), - ); - } - - Widget _buildListCard(movie) { + Widget _buildListCard(Movie movie) { final colors = Theme.of(context).colorScheme; return GestureDetector( onTap: () => Navigator.pushNamed(context, '/movie-detail', arguments: movie), @@ -119,78 +203,59 @@ class _MovieTabPageState extends State { child: Container( margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: colors.surfaceContainerHigh, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Container( - width: 48, height: 64, - decoration: BoxDecoration( - color: colors.outlineVariant, - borderRadius: BorderRadius.circular(6), - ), - clipBehavior: Clip.antiAlias, - child: movie.posterPath != null && movie.posterPath!.isNotEmpty - ? Image.file(File(movie.posterPath!), fit: BoxFit.cover, - errorBuilder: (_, __, ___) => Icon(Icons.movie_outlined, size: 22, color: colors.onSurface.withValues(alpha: 0.25))) - : Icon(Icons.movie_outlined, size: 22, color: colors.onSurface.withValues(alpha: 0.25)), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(movie.title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)), - if (movie.alternateTitles.isNotEmpty) ...[ - const SizedBox(height: 3), - Text(movie.alternateTitles.take(2).join('、'), maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35))), - ], - const SizedBox(height: 6), - if (movie.rating != null) - AnimatedStarRating(rating: movie.rating!, starSize: 12, showNumber: true) - else - const SizedBox(height: 14), - ], - ), - ), - const SizedBox(width: 8), - Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.2), size: 20), - ], - ), + decoration: BoxDecoration(color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(12)), + child: Row(children: [ + Container( + width: 48, height: 64, + decoration: BoxDecoration(color: colors.outlineVariant, borderRadius: BorderRadius.circular(6)), + clipBehavior: Clip.antiAlias, + child: movie.posterPath != null && movie.posterPath!.isNotEmpty + ? FadeInLocalImage(path: movie.posterPath, fit: BoxFit.cover, + errorWidget: Icon(Icons.movie_outlined, size: 22, color: colors.onSurface.withValues(alpha: 0.25))) + : Icon(Icons.movie_outlined, size: 22, color: colors.onSurface.withValues(alpha: 0.25)), + ), + const SizedBox(width: 12), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(movie.title, maxLines: 1, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)), + if (movie.alternateTitles.isNotEmpty) ...[ + const SizedBox(height: 3), + Text(movie.alternateTitles.take(2).join('、'), maxLines: 1, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35))), + ], + const SizedBox(height: 6), + if (movie.rating != null) AnimatedStarRating(rating: movie.rating!, starSize: 12, showNumber: true) + else const SizedBox(height: 14), + ])), + const SizedBox(width: 8), + Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.2), size: 20), + ]), ), ); } - void _showDeleteDialog(BuildContext context, movie) { + void _showDeleteDialog(BuildContext context, Movie movie) { final colors = Theme.of(context).colorScheme; showDialog( context: context, builder: (ctx) => AlertDialog( - backgroundColor: colors.surface, - elevation: 0, + 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('确定要删除《${movie.title}》吗?删除后可在回收站恢复。', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)), actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))), - ), + TextButton(onPressed: () => Navigator.pop(ctx), + child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))), ElevatedButton( onPressed: () async { await context.read().removeMovie(movie.id); Navigator.pop(ctx); + _loadFirst(); }, - 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), - ), + 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('删除'), ), ], @@ -199,42 +264,24 @@ class _MovieTabPageState extends State { ); } - Widget _buildSkeleton() { - return _layoutStyle == 1 ? _buildListSkeleton() : const MovieSkeletonGrid(); - } + Widget _buildSkeleton() => _layoutStyle == 1 ? _buildListSkeleton() : const MovieSkeletonGrid(); Widget _buildListSkeleton() { final colors = Theme.of(context).colorScheme; return ListView.builder( - padding: const EdgeInsets.fromLTRB(12, 8, 12, 100), - itemCount: 6, + padding: const EdgeInsets.fromLTRB(12, 8, 12, 100), itemCount: 6, itemBuilder: (_, __) => Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: colors.surfaceContainerHigh, - borderRadius: BorderRadius.circular(12), - ), - child: const Row( - children: [ - ShimmerSkeleton(width: 48, height: 64, borderRadius: 6), - SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShimmerSkeleton(width: 160, height: 16), - SizedBox(height: 6), - ShimmerSkeleton(width: 100, height: 12), - SizedBox(height: 6), - ShimmerSkeleton(width: 70, height: 12), - ], - ), - ), - SizedBox(width: 8), - ShimmerSkeleton(width: 20, height: 20, borderRadius: 10), - ], - ), + margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(12), + decoration: BoxDecoration(color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(12)), + child: const Row(children: [ + ShimmerSkeleton(width: 48, height: 64, borderRadius: 6), SizedBox(width: 12), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + ShimmerSkeleton(width: 160, height: 16), SizedBox(height: 6), + ShimmerSkeleton(width: 100, height: 12), SizedBox(height: 6), + ShimmerSkeleton(width: 70, height: 12), + ])), + SizedBox(width: 8), ShimmerSkeleton(width: 20, height: 20, borderRadius: 10), + ]), ), ); } @@ -242,31 +289,24 @@ class _MovieTabPageState extends State { Widget _buildEmptyState(BuildContext context, int statusIndex) { final colors = Theme.of(context).colorScheme; final statusText = ['已看', '在看', '想看'][statusIndex]; - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 80, height: 80, - decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(20)), - child: Icon(Icons.movie_outlined, size: 40, color: colors.onSurface.withValues(alpha: 0.25)), - ), - const SizedBox(height: 20), - Text('暂无$statusText的影片', style: TextStyle(fontSize: 16, color: colors.onSurface.withValues(alpha: 0.4))), - const SizedBox(height: 24), - InkWell( - onTap: () { - final statusMap = {0: 'watched', 1: 'watching', 2: 'want_to_watch'}; - Navigator.pushNamed(context, '/movie-form', arguments: {'initialStatus': statusMap[statusIndex]!}); - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(8)), - child: Text('添加记录', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: colors.onPrimary)), - ), - ), - ], + return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + Container(width: 80, height: 80, + decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(20)), + child: Icon(Icons.movie_outlined, size: 40, color: colors.onSurface.withValues(alpha: 0.25))), + const SizedBox(height: 20), + Text('暂无$statusText的影片', style: TextStyle(fontSize: 16, color: colors.onSurface.withValues(alpha: 0.4))), + const SizedBox(height: 24), + InkWell( + onTap: () { + final statusMap = {0: 'watched', 1: 'watching', 2: 'want_to_watch'}; + Navigator.pushNamed(context, '/movie-form', arguments: {'initialStatus': statusMap[statusIndex]!}); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(8)), + child: Text('添加记录', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: colors.onPrimary)), + ), ), - ); + ])); } } diff --git a/lib/pages/movies/poster_gallery_page.dart b/lib/pages/movies/poster_gallery_page.dart index db6861d..4181c86 100644 --- a/lib/pages/movies/poster_gallery_page.dart +++ b/lib/pages/movies/poster_gallery_page.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import '../../models/data_models.dart'; +import '../../widgets/fade_in_local_image.dart'; /// 海报画廊页面 - 支持左右滑动浏览 class PosterGalleryPage extends StatefulWidget { @@ -53,16 +54,9 @@ class _PosterGalleryPageState extends State { minScale: 0.5, maxScale: 3.0, child: Center( - child: Image.file( - File(poster.posterPath), + child: FadeInLocalImage( + path: poster.posterPath, fit: BoxFit.contain, - errorBuilder: (_, __, ___) => const Center( - child: Icon( - Icons.broken_image, - color: Colors.white54, - size: 64, - ), - ), ), ), ); diff --git a/lib/pages/note/note_detail_page.dart b/lib/pages/note/note_detail_page.dart index 7537ada..7d616be 100644 --- a/lib/pages/note/note_detail_page.dart +++ b/lib/pages/note/note_detail_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:provider/provider.dart'; 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'; @@ -176,10 +177,10 @@ class _NoteDetailPageState extends State { border: Border.all(color: colors.outlineVariant, width: 0.5), ), clipBehavior: Clip.antiAlias, - child: Image.file( - File(images[index]), + child: FadeInLocalImage( + path: images[index], fit: BoxFit.cover, - errorBuilder: (_, __, ___) => Container( + errorWidget: Container( color: colors.surfaceContainerHighest, child: Icon(Icons.broken_image_outlined, size: 20, color: colors.onSurface.withValues(alpha: 0.25)), ), diff --git a/lib/pages/note/note_form_page.dart b/lib/pages/note/note_form_page.dart index 415c7d8..581d1f3 100644 --- a/lib/pages/note/note_form_page.dart +++ b/lib/pages/note/note_form_page.dart @@ -8,6 +8,7 @@ import '../../providers/app_provider.dart'; import '../../models/data_models.dart'; import '../../utils/toast_util.dart'; import '../../utils/image_path_helper.dart'; +import '../../widgets/fade_in_local_image.dart'; /// 添加/编辑笔记页面 - 极简书写界面 class NoteFormPage extends StatefulWidget { @@ -1037,8 +1038,8 @@ class _NoteFormPageState extends State { border: Border.all(color: colors.outline, width: 0.5), ), clipBehavior: Clip.antiAlias, - child: Image.file( - File(_images[index]), + child: FadeInLocalImage( + path: _images[index], fit: BoxFit.cover, ), ), @@ -1060,8 +1061,8 @@ class _NoteFormPageState extends State { boundaryMargin: const EdgeInsets.all(20), minScale: 0.5, maxScale: 4, - child: Image.file( - File(_images[index]), + child: FadeInLocalImage( + path: _images[index], fit: BoxFit.contain, ), ), diff --git a/lib/pages/note/note_share_page.dart b/lib/pages/note/note_share_page.dart index 4bb56bc..5d81a0c 100644 --- a/lib/pages/note/note_share_page.dart +++ b/lib/pages/note/note_share_page.dart @@ -7,6 +7,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; import '../../models/data_models.dart'; import '../../utils/toast_util.dart'; +import '../../widgets/fade_in_local_image.dart'; /// 笔记分享海报页面 class NoteSharePage extends StatefulWidget { @@ -105,11 +106,11 @@ class _NoteSharePageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ // 首张图片 - if (hasImages && File(note.images.first).existsSync()) + if (hasImages) ClipRRect( borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), - child: Image.file( - File(note.images.first), + child: FadeInLocalImage( + path: note.images.first, width: 320, height: 200, fit: BoxFit.cover, diff --git a/lib/pages/note/note_tab_page.dart b/lib/pages/note/note_tab_page.dart index 05690bf..2a6e0be 100644 --- a/lib/pages/note/note_tab_page.dart +++ b/lib/pages/note/note_tab_page.dart @@ -1,4 +1,3 @@ -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../providers/app_provider.dart'; @@ -8,7 +7,7 @@ import '../../widgets/note_list_item.dart'; import '../../widgets/shimmer_skeleton.dart'; import '../../widgets/fade_in_local_image.dart'; -/// 笔记标签页 +/// 笔记标签页(分页 + 触底加载) class NoteTabPage extends StatefulWidget { const NoteTabPage({super.key}); @@ -17,213 +16,127 @@ class NoteTabPage extends StatefulWidget { } class _NoteTabPageState extends State { - static const int _pageSize = 50; - final List _displayedNotes = []; - bool _isLoading = false; + final List _items = []; bool _hasMore = true; - final ScrollController _scrollController = ScrollController(); - + bool _isLoading = false; + int _offset = 0; + late ScrollController _scrollController; int _layoutStyle = 0; - bool _firstLoad = true; + bool _initialized = false; + int _lastDataCount = -1; + DateTime? _lastUpdatedAt; @override void initState() { super.initState(); - _scrollController.addListener(_onScroll); _layoutStyle = UserPrefs().noteLayoutStyle; + _scrollController = ScrollController()..addListener(_onScroll); WidgetsBinding.instance.addPostFrameCallback((_) { - _loadMoreNotes(); - if (mounted) setState(() => _firstLoad = false); + final provider = context.read(); + provider.addListener(_onDataChanged); + _lastDataCount = provider.notes.length; + if (provider.notes.isNotEmpty) _lastUpdatedAt = provider.notes.first.updatedAt; + _loadFirst(); }); } - int _lastNotesCount = 0; - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - final provider = context.watch(); - final allNotes = provider.notes; - - if (allNotes.length != _lastNotesCount && _displayedNotes.isNotEmpty) { - _lastNotesCount = allNotes.length; - setState(() { - _displayedNotes.clear(); - _hasMore = true; - }); - Future.microtask(() => _loadMoreNotes()); - } else { - _lastNotesCount = allNotes.length; - } - } - @override void dispose() { _scrollController.dispose(); super.dispose(); } - void _onScroll() { - if (_scrollController.position.pixels >= - _scrollController.position.maxScrollExtent - 200) { - _loadMoreNotes(); + void _onDataChanged() { + if (!_initialized || !mounted) return; + final provider = context.read(); + final count = provider.notes.length; + final latest = provider.notes.isNotEmpty ? provider.notes.first.updatedAt : null; + if (count != _lastDataCount || latest != _lastUpdatedAt) { + _lastDataCount = count; + _lastUpdatedAt = latest; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _loadFirst(); + }); } } - Future _loadMoreNotes() async { - if (_isLoading || !_hasMore) return; - - setState(() => _isLoading = true); - - await Future.microtask(() { - final provider = context.read(); - final allNotes = provider.notes; - - final startIndex = _displayedNotes.length; - final endIndex = (startIndex + _pageSize).clamp(0, allNotes.length); - - if (startIndex >= allNotes.length) { - _hasMore = false; - } else { - final newNotes = allNotes.sublist(startIndex, endIndex); - _displayedNotes.addAll(newNotes); - _hasMore = endIndex < allNotes.length; - } - }); - - if (mounted) { - setState(() => _isLoading = false); + void _onScroll() { + if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { + _loadMore(); } } + Future _loadFirst() async { + _initialized = true; + setState(() { _isLoading = true; _items.clear(); _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; }); + } + + Future _loadMore() async { + if (_isLoading || !_hasMore) return; + setState(() => _isLoading = true); + final list = await context.read().loadNotesPaged(offset: _offset); + if (!mounted) return; + setState(() { _items.addAll(list); _offset += list.length; _hasMore = list.length >= 20; _isLoading = false; }); + } + Future _refresh() async { - final provider = context.read(); - await provider.loadNotes(); - setState(() { - _displayedNotes.clear(); - _hasMore = true; - }); - await _loadMoreNotes(); + await context.read().loadNotes(); + await _loadFirst(); } @override Widget build(BuildContext context) { - return Column( - children: [ - Expanded( - child: Consumer( - builder: (context, provider, child) { - final allNotes = provider.notes; - _syncDisplayedNotes(allNotes); - - if (_firstLoad) { - return _buildSkeleton(); - } - - if (allNotes.isEmpty && _displayedNotes.isEmpty) { - final colors = Theme.of(context).colorScheme; - return RefreshIndicator( - onRefresh: _refresh, - color: colors.primary, - backgroundColor: colors.surface, - child: ListView( - physics: const AlwaysScrollableScrollPhysics(), - children: [_buildEmptyState(context)], - ), - ); - } - - if (_layoutStyle == 1) { - return _buildWaterfallView(); - } - if (_layoutStyle == 2) { - return _buildTimelineView(); - } - return _buildListView(); - }, - ), - ), - ], - ); + final colors = Theme.of(context).colorScheme; + return Consumer(builder: (context, provider, _) { + if (_items.isEmpty && _isLoading) return _buildSkeleton(); + if (_items.isEmpty) { + return RefreshIndicator(onRefresh: _refresh, color: colors.primary, backgroundColor: colors.surface, + child: ListView(physics: const AlwaysScrollableScrollPhysics(), children: [_buildEmptyState(context)])); + } + if (_layoutStyle == 1) return _buildWaterfallView(); + if (_layoutStyle == 2) return _buildTimelineView(); + return _buildListView(); + }); } Widget _buildSkeleton() { switch (_layoutStyle) { - case 1: - return _buildWaterfallSkeleton(); - case 2: - return const NoteSkeletonList(); - default: - return const NoteSkeletonList(); + case 1: return _buildWaterfallSkeleton(); + default: return const NoteSkeletonList(); } } Widget _buildWaterfallSkeleton() { final colors = Theme.of(context).colorScheme; - return SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(12, 8, 12, 100), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: List.generate(2, (_) => Expanded( - child: Column( - children: List.generate(4, (_) => Container( - margin: const EdgeInsets.only(bottom: 8), - decoration: BoxDecoration( - color: colors.surface, - borderRadius: BorderRadius.circular(10), - boxShadow: [ - BoxShadow(color: Colors.black.withValues(alpha: 0.04), blurRadius: 6, offset: const Offset(0, 2)), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const ShimmerSkeleton(width: double.infinity, height: 140, borderRadius: 10), - Padding( - padding: const EdgeInsets.all(10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const ShimmerSkeleton(width: double.infinity, height: 14), - const SizedBox(height: 6), - const ShimmerSkeleton(width: double.infinity, height: 12), - const SizedBox(height: 6), - const ShimmerSkeleton(width: 60, height: 10), - ], - ), - ), - ], - ), - )), - ), - )), - ), + return SingleChildScrollView(padding: const EdgeInsets.fromLTRB(12, 8, 12, 100), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: List.generate(2, (_) => Expanded( + child: Column(children: List.generate(4, (_) => Container(margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(10), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.04), blurRadius: 6, offset: const Offset(0, 2))]), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const ShimmerSkeleton(width: double.infinity, height: 140, borderRadius: 10), + Padding(padding: const EdgeInsets.all(10), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const ShimmerSkeleton(width: double.infinity, height: 14), const SizedBox(height: 6), + const ShimmerSkeleton(width: double.infinity, height: 12), const SizedBox(height: 6), + const ShimmerSkeleton(width: 60, height: 10), + ])), + ]), + ))), + ))), ); } Widget _buildListView() { final colors = Theme.of(context).colorScheme; - return RefreshIndicator( - onRefresh: _refresh, - color: colors.primary, - backgroundColor: colors.surface, - child: ListView.builder( - controller: _scrollController, - padding: const EdgeInsets.fromLTRB(12, 10, 12, 100), - itemCount: _displayedNotes.length + (_hasMore ? 1 : 0), + return RefreshIndicator(onRefresh: _refresh, color: colors.primary, backgroundColor: colors.surface, + child: ListView.builder(controller: _scrollController, padding: const EdgeInsets.fromLTRB(12, 10, 12, 100), + itemCount: _items.length + (_hasMore ? 1 : 0), itemBuilder: (context, index) { - if (index >= _displayedNotes.length) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: Center( - child: SizedBox( - width: 20, height: 20, - child: CircularProgressIndicator(strokeWidth: 2, color: colors.primary), - ), - ), - ); - } - return NoteListItem(note: _displayedNotes[index]); + if (index >= _items.length) return _buildLoadMore(); + return NoteListItem(note: _items[index]); }, ), ); @@ -231,25 +144,12 @@ class _NoteTabPageState extends State { Widget _buildTimelineView() { final colors = Theme.of(context).colorScheme; - return RefreshIndicator( - onRefresh: _refresh, - color: colors.primary, - backgroundColor: colors.surface, - child: ListView.builder( - controller: _scrollController, - padding: const EdgeInsets.fromLTRB(12, 8, 12, 100), - itemCount: _displayedNotes.length + (_hasMore ? 1 : 0), + return RefreshIndicator(onRefresh: _refresh, color: colors.primary, backgroundColor: colors.surface, + child: ListView.builder(controller: _scrollController, padding: const EdgeInsets.fromLTRB(12, 8, 12, 100), + itemCount: _items.length + (_hasMore ? 1 : 0), itemBuilder: (context, index) { - if (index >= _displayedNotes.length) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: Center( - child: SizedBox(width: 20, height: 20, - child: CircularProgressIndicator(strokeWidth: 2, color: colors.primary)), - ), - ); - } - return _buildTimelineItem(_displayedNotes[index]); + if (index >= _items.length) return _buildLoadMore(); + return _buildTimelineItem(_items[index]); }, ), ); @@ -258,127 +158,54 @@ class _NoteTabPageState extends State { Widget _buildTimelineItem(Note note) { final colors = Theme.of(context).colorScheme; return GestureDetector( - onTap: () { - Navigator.pushNamed(context, '/note-detail', arguments: note).then((_) async { - await context.read().loadNotes(); - }); - }, + onTap: () => Navigator.pushNamed(context, '/note-detail', arguments: note).then((_) => _loadFirst()), onLongPress: () => _showDeleteDialog(context, note), - child: IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SizedBox( - width: 40, - child: Column( - children: [ - Container( - width: 10, - height: 10, - decoration: BoxDecoration( - color: colors.primary, - shape: BoxShape.circle, - border: Border.all(color: colors.surface, width: 2), - ), - ), - 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: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _formatFullDate(note.updatedAt), - style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4)), - ), - if (note.title.isNotEmpty) ...[ - const SizedBox(height: 6), - Text( - note.title, - style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - const SizedBox(height: 6), - Text( - _getPreviewText(note), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.5), height: 1.5), - ), - if (note.tags.isNotEmpty) ...[ - const SizedBox(height: 8), - Wrap( - spacing: 6, - runSpacing: 4, - children: note.tags.map((tag) => Container( - padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), - decoration: BoxDecoration( - color: colors.surface, - borderRadius: BorderRadius.circular(4), - ), - child: Text(tag, style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.4))), - )).toList(), - ), - ], - ], - ), - ), - ), - ], - ), - ), + child: IntrinsicHeight(child: Row(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + SizedBox(width: 40, child: Column(children: [ + Container(width: 10, height: 10, + decoration: BoxDecoration(color: colors.primary, shape: BoxShape.circle, border: Border.all(color: colors.surface, width: 2))), + 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: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + Text(_formatFullDate(note.updatedAt), style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), + if (note.title.isNotEmpty) ...[const SizedBox(height: 6), + Text(note.title, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface), maxLines: 1, overflow: TextOverflow.ellipsis), + ], + const SizedBox(height: 6), + Text(_getPreviewText(note), maxLines: 2, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.5), height: 1.5)), + if (note.tags.isNotEmpty) ...[const SizedBox(height: 8), + Wrap(spacing: 6, runSpacing: 4, children: note.tags.map((tag) => Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(4)), + child: Text(tag, style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.4))), + )).toList()), + ], + ]), + )), + ])), ); } - String _formatFullDate(DateTime date) { - return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')} ' - '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; - } + String _formatFullDate(DateTime date) => + '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')} ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; Widget _buildWaterfallView() { final colors = Theme.of(context).colorScheme; final leftItems = []; final rightItems = []; - for (int i = 0; i < _displayedNotes.length; i++) { - if (i % 2 == 0) { - leftItems.add(_displayedNotes[i]); - } else { - rightItems.add(_displayedNotes[i]); - } + for (int i = 0; i < _items.length; i++) { + (i % 2 == 0 ? leftItems : rightItems).add(_items[i]); } - - return RefreshIndicator( - onRefresh: _refresh, - color: colors.primary, - backgroundColor: colors.surface, - child: SingleChildScrollView( - controller: _scrollController, - padding: const EdgeInsets.fromLTRB(12, 8, 12, 100), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded(child: Column(children: leftItems.map((n) => _buildWaterfallCard(n)).toList())), - const SizedBox(width: 8), - Expanded(child: Column(children: rightItems.map((n) => _buildWaterfallCard(n)).toList())), - ], - ), + return RefreshIndicator(onRefresh: _refresh, color: colors.primary, backgroundColor: colors.surface, + child: SingleChildScrollView(controller: _scrollController, padding: const EdgeInsets.fromLTRB(12, 8, 12, 100), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Expanded(child: Column(children: [...leftItems.map(_buildWaterfallCard), if (_hasMore) _buildLoadMore()])), + const SizedBox(width: 8), + Expanded(child: Column(children: rightItems.map(_buildWaterfallCard).toList())), + ]), ), ); } @@ -389,137 +216,56 @@ class _NoteTabPageState extends State { final images = note.images; final hasImage = images.isNotEmpty; final extraCount = images.length - 1; - return GestureDetector( - onTap: () { - Navigator.pushNamed(context, '/note-detail', arguments: note).then((_) async { - await context.read().loadNotes(); - }); - }, + onTap: () => Navigator.pushNamed(context, '/note-detail', arguments: note).then((_) => _loadFirst()), onLongPress: () => _showDeleteDialog(context, note), - child: Container( - margin: const EdgeInsets.only(bottom: 8), - decoration: BoxDecoration( - color: colors.surface, - borderRadius: BorderRadius.circular(10), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.04), - blurRadius: 6, - offset: const Offset(0, 2), - ), - ], - ), + child: Container(margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(10), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.04), blurRadius: 6, offset: const Offset(0, 2))]), clipBehavior: Clip.antiAlias, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - if (hasImage) - Stack( - children: [ - ClipRRect( - borderRadius: const BorderRadius.vertical(top: Radius.circular(10)), - child: FadeInLocalImage( - path: images.first, - fit: BoxFit.cover, - errorWidget: const SizedBox.shrink(), - ), - ), - if (extraCount > 0) - Positioned( - top: 6, - right: 6, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.45), - borderRadius: BorderRadius.circular(10), - ), - child: Text( - '+$extraCount', - style: const TextStyle(fontSize: 11, color: Colors.white, fontWeight: FontWeight.w600), - ), - ), - ), - ], - ), - Padding( - padding: const EdgeInsets.fromLTRB(10, 8, 10, 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - if (note.title.isNotEmpty) - Text( - note.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: colors.onSurface, - height: 1.3, - ), - ), - if (contentText.isNotEmpty && contentText != '(无内容)') ...[ - if (note.title.isNotEmpty) const SizedBox(height: 4), - Text( - contentText, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 11, - color: colors.onSurface.withValues(alpha: 0.4), - height: 1.4, - ), - ), - ], - const SizedBox(height: 6), - Row( - children: [ - Expanded( - child: Text( - _formatTime(note.updatedAt), - style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.25)), - ), - ), - if (note.tags.isNotEmpty) - Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), - decoration: BoxDecoration( - color: colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(3), - ), - child: Text( - note.tags.first, - style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.4)), - ), - ), - ], - ), - ], + child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + if (hasImage) Stack(children: [ + ClipRRect(borderRadius: const BorderRadius.vertical(top: Radius.circular(10)), + child: FadeInLocalImage(path: images.first, fit: BoxFit.cover, errorWidget: const SizedBox.shrink())), + if (extraCount > 0) Positioned(top: 6, right: 6, + child: Container(padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.45), borderRadius: BorderRadius.circular(10)), + child: Text('+$extraCount', style: const TextStyle(fontSize: 11, color: Colors.white, fontWeight: FontWeight.w600)), ), ), - ], - ), + ]), + Padding(padding: const EdgeInsets.fromLTRB(10, 8, 10, 10), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + if (note.title.isNotEmpty) Text(note.title, maxLines: 2, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.onSurface, height: 1.3)), + if (contentText.isNotEmpty && contentText != '(无内容)') ...[ + if (note.title.isNotEmpty) const SizedBox(height: 4), + Text(contentText, maxLines: 2, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4), height: 1.4)), + ], + const SizedBox(height: 6), + Row(children: [ + Expanded(child: Text(_formatTime(note.updatedAt), + style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.25)))), + if (note.tags.isNotEmpty) Container(padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(3)), + child: Text(note.tags.first, style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.4))), + ), + ]), + ]), + ), + ]), ), ); } String _getPreviewText(Note note) { - final text = note.content - .replaceAll(RegExp(r'#'), '') - .replaceAll(RegExp(r'\*'), '') - .replaceAll(RegExp(r'`'), '') - .replaceAll(RegExp(r'[\[\]\(\)]'), '') - .trim(); + final text = note.content.replaceAll(RegExp(r'[#*\[\]\(\)]'), '').trim(); return text.isEmpty ? '(无内容)' : text; } String _formatTime(DateTime date) { - final now = DateTime.now(); - final diff = now.difference(date); + final diff = DateTime.now().difference(date); if (diff.inMinutes < 1) return '刚刚'; if (diff.inHours < 1) return '${diff.inMinutes}分钟前'; if (diff.inDays < 1) return '${diff.inHours}小时前'; @@ -527,102 +273,47 @@ class _NoteTabPageState extends State { return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; } - void _showDeleteDialog(BuildContext context, Note note) { - final colors = Theme.of(context).colorScheme; - showDialog( - context: context, - builder: (context) => AlertDialog( - backgroundColor: colors.surface, - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - title: Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), - content: Text('确定要删除这条笔记吗?删除后可在回收站恢复。', - style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - style: TextButton.styleFrom( - foregroundColor: colors.onSurface.withValues(alpha: 0.6), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - ), - child: const Text('取消'), - ), - ElevatedButton( - onPressed: () async { - await context.read().removeNote(note.id); - Navigator.pop(context); - }, - style: ElevatedButton.styleFrom( - backgroundColor: colors.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), - ), + Widget _buildLoadMore() { + return Padding(padding: const EdgeInsets.symmetric(vertical: 20), + child: Center(child: _isLoading + ? SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Theme.of(context).colorScheme.primary)) + : Text('没有更多了', style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3)))), ); } - void _syncDisplayedNotes(List allNotes) { - final validNoteIds = allNotes.map((n) => n.id).toSet(); - final initialLength = _displayedNotes.length; - _displayedNotes.removeWhere((note) => !validNoteIds.contains(note.id)); - - final displayedIds = _displayedNotes.map((n) => n.id).toSet(); - final hasNewNotes = allNotes.any((note) => !displayedIds.contains(note.id)); - - bool hasUpdates = false; - for (int i = 0; i < _displayedNotes.length; i++) { - final localNote = _displayedNotes[i]; - final providerNote = allNotes.firstWhere((n) => n.id == localNote.id); - if (localNote.updatedAt != providerNote.updatedAt) { - hasUpdates = true; - break; - } - } - - if (_displayedNotes.length < initialLength || hasNewNotes || hasUpdates) { - _displayedNotes.clear(); - _displayedNotes.addAll(allNotes); - _hasMore = false; - } + void _showDeleteDialog(BuildContext context, Note note) { + final colors = Theme.of(context).colorScheme; + 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), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))), + ElevatedButton(onPressed: () async { await context.read().removeNote(note.id); Navigator.pop(ctx); _loadFirst(); }, + 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), + )); } Widget _buildEmptyState(BuildContext context) { final colors = Theme.of(context).colorScheme; - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 80, height: 80, - decoration: BoxDecoration( - color: colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(20), - ), - child: Icon(Icons.note_outlined, size: 40, color: colors.onSurface.withValues(alpha: 0.25)), - ), - const SizedBox(height: 20), - Text('暂无笔记', style: TextStyle(fontSize: 16, color: colors.onSurface.withValues(alpha: 0.4))), - const SizedBox(height: 24), - InkWell( - onTap: () => Navigator.pushNamed(context, '/note-form'), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - decoration: BoxDecoration( - color: colors.primary, - borderRadius: BorderRadius.circular(8), - ), - child: Text('添加记录', - style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: colors.onPrimary)), - ), - ), - ], + return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + Container(width: 80, height: 80, + decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(20)), + child: Icon(Icons.note_outlined, size: 40, color: colors.onSurface.withValues(alpha: 0.25))), + const SizedBox(height: 20), + Text('暂无笔记', style: TextStyle(fontSize: 16, color: colors.onSurface.withValues(alpha: 0.4))), + const SizedBox(height: 24), + InkWell(onTap: () => Navigator.pushNamed(context, '/note-form'), + child: Container(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(8)), + child: Text('添加记录', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: colors.onPrimary))), ), - ); + ])); } } diff --git a/lib/pages/profile_page.dart b/lib/pages/profile_page.dart index 1cf39c8..53e0018 100644 --- a/lib/pages/profile_page.dart +++ b/lib/pages/profile_page.dart @@ -10,6 +10,7 @@ import '../utils/user_prefs.dart'; import '../utils/toast_util.dart'; import 'recycle_bin_page.dart'; import 'sync/backup_page.dart'; +import '../widgets/fade_in_local_image.dart'; import 'statistics_page.dart'; import 'sync/cloud_sync_page.dart'; import 'app_icon_picker_page.dart'; @@ -129,8 +130,8 @@ class _ProfilePageState extends State { ), clipBehavior: Clip.antiAlias, child: _avatarPath != null && _avatarPath!.isNotEmpty - ? Image.file(File(_avatarPath!), fit: BoxFit.cover, - errorBuilder: (_, __, ___) => Icon(Icons.person_outline, size: 32, color: colors.onSurface.withValues(alpha: 0.25))) + ? FadeInLocalImage(path: _avatarPath, fit: BoxFit.cover, + errorWidget: Icon(Icons.person_outline, size: 32, color: colors.onSurface.withValues(alpha: 0.25))) : Icon(Icons.person_outline, size: 32, color: colors.onSurface.withValues(alpha: 0.25)), ), ), diff --git a/lib/pages/search_page.dart b/lib/pages/search_page.dart index a26c89d..89726de 100644 --- a/lib/pages/search_page.dart +++ b/lib/pages/search_page.dart @@ -7,6 +7,7 @@ import '../models/data_models.dart'; import 'movies/movie_detail_page.dart'; import 'book/book_detail_page.dart'; import 'note/note_detail_page.dart'; +import '../widgets/fade_in_local_image.dart'; /// 搜索页面 class SearchPage extends StatefulWidget { @@ -379,7 +380,8 @@ class _SearchPageState extends State { decoration: BoxDecoration(color: colors.outlineVariant, borderRadius: BorderRadius.circular(6)), clipBehavior: Clip.antiAlias, child: path != null && path.isNotEmpty - ? Image.file(File(path), fit: BoxFit.cover, errorBuilder: (_, __, ___) => Icon(fallback, size: 20, color: colors.onSurface.withValues(alpha: 0.25))) + ? FadeInLocalImage(path: path, fit: BoxFit.cover, + errorWidget: Icon(fallback, size: 20, color: colors.onSurface.withValues(alpha: 0.25))) : Icon(fallback, size: 20, color: colors.onSurface.withValues(alpha: 0.25)), ); } diff --git a/lib/pages/stroll_page.dart b/lib/pages/stroll_page.dart index d336544..1a68435 100644 --- a/lib/pages/stroll_page.dart +++ b/lib/pages/stroll_page.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import '../providers/app_provider.dart'; import '../models/data_models.dart'; import '../widgets/animated_star_rating.dart'; +import '../widgets/fade_in_local_image.dart'; /// 漫步页面 - 随机发现内容 class StrollPage extends StatefulWidget { @@ -190,7 +191,7 @@ class _StrollPageState extends State with SingleTickerProviderStateM style: TextStyle(fontSize: 15, color: colors.onSurface.withValues(alpha: 0.3), height: 1.6))) : Consumer(builder: (context, provider, _) { final item = _currentItem!; - final hasImage = item.imagePath != null && item.imagePath!.isNotEmpty && File(item.imagePath!).existsSync(); + final hasImage = item.imagePath != null && item.imagePath!.isNotEmpty; return FadeTransition( opacity: _fadeAnim, @@ -298,8 +299,7 @@ class _StrollPageState extends State with SingleTickerProviderStateM ), clipBehavior: Clip.antiAlias, child: hasImage - ? Image.file(File(item.imagePath!), fit: BoxFit.cover, - errorBuilder: (_, __, ___) => _buildPlaceholder(item)) + ? FadeInLocalImage(path: item.imagePath, fit: BoxFit.cover) : _buildPlaceholder(item), ); } @@ -321,8 +321,7 @@ class _StrollPageState extends State with SingleTickerProviderStateM mainAxisSize: MainAxisSize.min, children: [ if (hasImage) - Image.file(File(item.imagePath!), fit: BoxFit.cover, height: 200, width: double.infinity, - errorBuilder: (_, __, ___) => const SizedBox.shrink()), + FadeInLocalImage(path: item.imagePath, fit: BoxFit.cover, height: 200, width: double.infinity), Padding( padding: const EdgeInsets.all(20), child: Text(item.detail.isEmpty ? '(无内容)' : item.detail, diff --git a/lib/pages/sync/cloud_sync_page.dart b/lib/pages/sync/cloud_sync_page.dart index 8410574..da05cd3 100644 --- a/lib/pages/sync/cloud_sync_page.dart +++ b/lib/pages/sync/cloud_sync_page.dart @@ -31,7 +31,6 @@ class CloudSyncPage extends StatelessWidget { icon: Icons.sync_outlined, title: '服务端实时同步', subtitle: '自建服务端,多设备数据实时同步', - enabled: false, onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const ServerSyncPage())), ), diff --git a/lib/pages/sync/server_sync_page.dart b/lib/pages/sync/server_sync_page.dart index cca8971..eb2f69a 100644 --- a/lib/pages/sync/server_sync_page.dart +++ b/lib/pages/sync/server_sync_page.dart @@ -117,7 +117,7 @@ class _ServerSyncPageState extends State { _startStatusPolling(); await _prefs.setSyncEnabled(true); _syncEnabled = true; - await ServerSyncService.instance.uploadToServer(); + await ServerSyncService.instance.syncWithServer(); if (mounted) ToastUtil.show(context, '激活成功,实时同步已开启'); } else { final error = result?['error'] ?? '激活失败'; @@ -126,31 +126,28 @@ class _ServerSyncPageState extends State { } Future _toggleSync(bool value) async { - await _prefs.setSyncEnabled(value); - setState(() => _syncEnabled = value); - if (value && _isActivated) { - await ServerSyncService.instance.uploadToServer(); + // 开启同步:合并本地与服务端数据 + await _prefs.setSyncEnabled(true); + setState(() => _syncEnabled = true); + if (mounted) ToastUtil.show(context, '正在同步数据...'); + await ServerSyncService.instance.syncWithServer(); final provider = context.read(); await provider.loadMovies(); await provider.loadBooks(); await provider.loadNotes(); - if (mounted) ToastUtil.show(context, '已切换到服务端数据'); + if (mounted) ToastUtil.show(context, '同步已开启,数据已合并'); } else { - // 关闭同步:从服务端下载最新数据到本地 - if (mounted) ToastUtil.show(context, '正在从服务端同步数据...'); - final success = await ServerSyncService.instance.downloadToLocal(); - if (mounted) { - if (success) { - final provider = context.read(); - await provider.loadMovies(); - await provider.loadBooks(); - await provider.loadNotes(); - ToastUtil.show(context, '数据已下载到本地'); - } else { - ToastUtil.show(context, '下载失败,使用本地数据'); - } - } + // 关闭同步:先合并最新数据,再切换到本地 + if (mounted) ToastUtil.show(context, '正在同步数据到本地...'); + await ServerSyncService.instance.syncWithServer(); + await _prefs.setSyncEnabled(false); + setState(() => _syncEnabled = false); + final provider = context.read(); + await provider.loadMovies(); + await provider.loadBooks(); + await provider.loadNotes(); + if (mounted) ToastUtil.show(context, '同步已关闭,已切换到本地数据'); } } diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 3df95c0..55a09c3 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'package:flutter/material.dart'; import '../models/data_models.dart'; import '../utils/movie/movie_dao.dart'; @@ -62,6 +63,12 @@ class AppProvider extends ChangeNotifier { // 初始化数据库 Future initDatabase() async { + debugPrint('[AppProvider] initDatabase, _useRemote=$_useRemote'); + if (_useRemote) { + // 同步模式:从服务端拉取数据 + await Future.wait([loadMovies(), loadBooks(), loadNotes()]); + return; + } final results = await Future.wait([ _movieDao.getAllMovies(), _bookDao.getAllBooks(), @@ -70,6 +77,7 @@ class AppProvider extends ChangeNotifier { _movies = results[0] as List; _books = results[1] as List; _notes = results[2] as List; + debugPrint('[AppProvider] 本地数据: movies=${_movies.length}, books=${_books.length}, notes=${_notes.length}'); notifyListeners(); } @@ -100,7 +108,9 @@ class AppProvider extends ChangeNotifier { // 加载影视数据 Future loadMovies() async { if (_useRemote) { + debugPrint('[AppProvider] loadMovies from server'); _movies = await ServerDataService.instance.getMovies(); + debugPrint('[AppProvider] server movies: ${_movies.length}'); notifyListeners(); return; } @@ -111,18 +121,22 @@ class AppProvider extends ChangeNotifier { // 加载书籍数据 Future loadBooks() async { if (_useRemote) { + debugPrint('[AppProvider] loadBooks from server'); _books = await ServerDataService.instance.getBooks(); + debugPrint('[AppProvider] server books: ${_books.length}'); notifyListeners(); return; } _books = await _bookDao.getAllBooks(); notifyListeners(); } - + // 加载笔记数据 Future loadNotes() async { if (_useRemote) { + debugPrint('[AppProvider] loadNotes from server'); _notes = await ServerDataService.instance.getNotes(); + debugPrint('[AppProvider] server notes: ${_notes.length}'); notifyListeners(); return; } @@ -130,6 +144,30 @@ class AppProvider extends ChangeNotifier { notifyListeners(); } + // ─── 分页加载(供列表页触底加载使用)──────────────────────── + static const int _pageSize = 20; + + Future> loadMoviesPaged({String? status, required int offset}) async { + if (_useRemote) { + return ServerDataService.instance.getMovies(status: status, limit: _pageSize, offset: offset); + } + return _movieDao.getMoviesPaged(status: status, limit: _pageSize, offset: offset); + } + + Future> loadBooksPaged({String? status, required int offset}) async { + if (_useRemote) { + return ServerDataService.instance.getBooks(status: status, limit: _pageSize, offset: offset); + } + return _bookDao.getBooksPaged(status: status, limit: _pageSize, offset: offset); + } + + Future> loadNotesPaged({required int offset}) async { + if (_useRemote) { + return ServerDataService.instance.getNotes(limit: _pageSize, offset: offset); + } + return _noteDao.getNotesPaged(limit: _pageSize, offset: offset); + } + // Getters int get mainTabIndex => _mainTabIndex; int get bottomNavIndex => _bottomNavIndex; @@ -216,8 +254,13 @@ class AppProvider extends ChangeNotifier { Future _uploadImagesIfRemote(List paths) async { if (!_useRemote) return; final valid = paths.where((p) => p != null && p!.isNotEmpty).cast().toList(); - if (valid.isNotEmpty) { - await ServerDataService.uploadLocalImages(valid); + if (valid.isEmpty) return; + final exist = []; + for (final p in valid) { + if (File(p).existsSync()) exist.add(p); + } + if (exist.isNotEmpty) { + await ServerDataService.uploadLocalImages(exist); } } diff --git a/lib/utils/book/book_dao.dart b/lib/utils/book/book_dao.dart index af3dcec..7698f4c 100644 --- a/lib/utils/book/book_dao.dart +++ b/lib/utils/book/book_dao.dart @@ -19,6 +19,20 @@ class BookDao { return List.generate(maps.length, (i) => Book.fromJson(maps[i])); } + // 分页查询书籍记录 + Future> getBooksPaged({String? status, int limit = 20, int offset = 0}) async { + final db = await _dbHelper.database; + String where = 'is_deleted = 0'; + List whereArgs = []; + if (status != null && status.isNotEmpty) { + where += ' AND status = ?'; + whereArgs.add(status); + } + final maps = await db.query('books', where: where, whereArgs: whereArgs, + orderBy: 'created_at DESC', limit: limit, offset: offset); + return List.generate(maps.length, (i) => Book.fromJson(maps[i])); + } + // 根据状态筛选书籍记录 Future> getBooksByStatus(String status) async { final db = await _dbHelper.database; diff --git a/lib/utils/movie/movie_dao.dart b/lib/utils/movie/movie_dao.dart index 944f30d..81cc529 100644 --- a/lib/utils/movie/movie_dao.dart +++ b/lib/utils/movie/movie_dao.dart @@ -19,6 +19,20 @@ class MovieDao { return List.generate(maps.length, (i) => Movie.fromJson(maps[i])); } + // 分页查询影视记录 + Future> getMoviesPaged({String? status, int limit = 20, int offset = 0}) async { + final db = await _dbHelper.database; + String where = 'is_deleted = 0'; + List whereArgs = []; + if (status != null && status.isNotEmpty) { + where += ' AND status = ?'; + whereArgs.add(status); + } + final maps = await db.query('movies', where: where, whereArgs: whereArgs, + orderBy: 'created_at DESC', limit: limit, offset: offset); + return List.generate(maps.length, (i) => Movie.fromJson(maps[i])); + } + // 根据状态筛选影视记录 Future> getMoviesByStatus(String status) async { final db = await _dbHelper.database; diff --git a/lib/utils/note/note_dao.dart b/lib/utils/note/note_dao.dart index 08cc239..cf9141b 100644 --- a/lib/utils/note/note_dao.dart +++ b/lib/utils/note/note_dao.dart @@ -19,6 +19,14 @@ class NoteDao { return List.generate(maps.length, (i) => Note.fromJson(maps[i])); } + // 分页查询笔记 + Future> getNotesPaged({int limit = 20, int offset = 0}) async { + final db = await _dbHelper.database; + final maps = await db.query('notes', where: 'is_deleted = 0', + orderBy: 'created_at DESC', limit: limit, offset: offset); + return List.generate(maps.length, (i) => Note.fromJson(maps[i])); + } + // 根据ID获取笔记 Future getNoteById(String id) async { final db = await _dbHelper.database; diff --git a/lib/utils/sync/server_data_service.dart b/lib/utils/sync/server_data_service.dart index af47901..5b51e60 100644 --- a/lib/utils/sync/server_data_service.dart +++ b/lib/utils/sync/server_data_service.dart @@ -26,19 +26,31 @@ class ServerDataService { bool get isAvailable => _baseUrl.isNotEmpty && _code.isNotEmpty; Future _post(String path, [Map? extra]) async { - final resp = await http.post( - Uri.parse('$_baseUrl$path'), - headers: _headers, - body: jsonEncode(_body(extra)), - ).timeout(const Duration(seconds: 30)); - if (resp.statusCode != 200) return null; - return jsonDecode(resp.body); + try { + final url = '$_baseUrl$path'; + debugPrint('[ServerData] POST $url'); + final resp = await http.post( + Uri.parse(url), + headers: _headers, + body: jsonEncode(_body(extra)), + ).timeout(const Duration(seconds: 30)); + debugPrint('[ServerData] ${resp.statusCode} $path'); + if (resp.statusCode != 200) return null; + return jsonDecode(resp.body); + } catch (e) { + debugPrint('[ServerData] ERROR $path: $e'); + return null; + } } // ─── 影视 ──────────────────────────────────────────────────── - Future> getMovies() async { - final data = await _post('/api/data/movies'); + Future> getMovies({String? status, int? limit, int? offset}) async { + final body = {}; + if (status != null && status.isNotEmpty) body['status'] = status; + if (limit != null) body['limit'] = limit; + if (offset != null) body['offset'] = offset; + final data = await _post('/api/data/movies', body.isEmpty ? null : body); if (data == null || data['movies'] == null) return []; return (data['movies'] as List).map((m) => Movie.fromJson(m as Map)).toList(); } @@ -55,8 +67,12 @@ class ServerDataService { // ─── 书籍 ──────────────────────────────────────────────────── - Future> getBooks() async { - final data = await _post('/api/data/books'); + Future> getBooks({String? status, int? limit, int? offset}) async { + final body = {}; + if (status != null && status.isNotEmpty) body['status'] = status; + if (limit != null) body['limit'] = limit; + if (offset != null) body['offset'] = offset; + final data = await _post('/api/data/books', body.isEmpty ? null : body); if (data == null || data['books'] == null) return []; return (data['books'] as List).map((b) => Book.fromJson(b as Map)).toList(); } @@ -73,8 +89,11 @@ class ServerDataService { // ─── 笔记 ──────────────────────────────────────────────────── - Future> getNotes() async { - final data = await _post('/api/data/notes'); + Future> getNotes({int? limit, int? offset}) async { + final body = {}; + if (limit != null) body['limit'] = limit; + if (offset != null) body['offset'] = offset; + final data = await _post('/api/data/notes', body.isEmpty ? null : body); if (data == null || data['notes'] == null) return []; return (data['notes'] as List).map((n) => Note.fromJson(n as Map)).toList(); } @@ -89,6 +108,29 @@ class ServerDataService { return data != null; } + // ─── 批量同步 ──────────────────────────────────────────────── + + Future> batchSync({ + List? movies, + List? books, + List? notes, + List>? tags, + }) async { + final data = await _post('/api/data/batch_sync', { + if (movies != null) 'movies': movies.map((m) => m.toJson()).toList(), + if (books != null) 'books': books.map((b) => b.toJson()).toList(), + if (notes != null) 'notes': notes.map((n) => n.toJson()).toList(), + if (tags != null) 'tags': tags, + }); + if (data == null) return {}; + return { + 'movies': (data['movies'] as int?) ?? 0, + 'books': (data['books'] as int?) ?? 0, + 'notes': (data['notes'] as int?) ?? 0, + 'tags': (data['tags'] as int?) ?? 0, + }; + } + // ─── 标签 ──────────────────────────────────────────────────── Future>> getTags(String? type) async { diff --git a/lib/utils/sync/server_sync_service.dart b/lib/utils/sync/server_sync_service.dart index 497a474..c762640 100644 --- a/lib/utils/sync/server_sync_service.dart +++ b/lib/utils/sync/server_sync_service.dart @@ -2,16 +2,21 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as p; +import 'package:sqflite/sqflite.dart'; import '../user_prefs.dart'; import '../database_helper.dart'; +import '../../models/data_models.dart'; +import '../movie/movie_dao.dart'; +import '../book/book_dao.dart'; +import '../note/note_dao.dart'; +import 'server_data_service.dart'; /// 服务端实时同步服务 -/// - 开启时:上传一次本地数据到服务器,后续 CRUD 走 API -/// - 关闭时:从服务器下载数据到本地,切换本地数据库 +/// - 开启时:智能合并本地与服务端数据 +/// - 关闭时:从服务器下载数据到本地 class ServerSyncService { static final ServerSyncService instance = ServerSyncService._(); ServerSyncService._(); @@ -46,59 +51,131 @@ class ServerSyncService { try { final d = jsonDecode(s); return d is Map ? d : null; } catch (_) { return null; } } - /// 开启同步:上传本地数据到服务器 - Future uploadToServer() async { + /// 开启同步:智能合并本地与服务端数据 + Future syncWithServer() async { if (!isConfigured || _isSyncing) return false; _isSyncing = true; try { - final url = _prefs.syncServerUrl; - final code = _prefs.syncActivationCode; - final deviceId = _prefs.deviceId; + final server = ServerDataService.instance; - final dbPath = await DatabaseHelper.instance.databasePath; - if (dbPath == null || !File(dbPath).existsSync()) { - debugPrint('[Sync] 数据库文件不存在'); - return false; - } + // 读取本地数据 + final localMovies = await MovieDao().getAllMovies(); + final localBooks = await BookDao().getAllBooks(); + final localNotes = await NoteDao().getAllNotes(); - final request = http.MultipartRequest('POST', Uri.parse('$url/api/sync/upload')); - request.fields['code'] = code; - request.fields['device_id'] = deviceId; - request.files.add(await http.MultipartFile.fromPath('database', dbPath)); + // 读取服务端数据 + final remoteMovies = await server.getMovies(); + final remoteBooks = await server.getBooks(); + final remoteNotes = await server.getNotes(); - final appDir = await getApplicationDocumentsDirectory(); - final imgDir = Directory(p.join(appDir.path, 'images')); - if (await imgDir.exists()) { - await for (final entity in imgDir.list(recursive: true)) { - if (entity is File) { - final relPath = p.relative(entity.path, from: appDir.path).replaceAll('\\', '/'); - request.files.add(await http.MultipartFile('images', entity.readAsBytes().asStream(), await entity.length(), filename: relPath)); + // 需要 push 到服务端的数据 + final pushMovies = []; + final pushBooks = []; + final pushNotes = []; + final imagePaths = []; + + // 服务端无数据 → 全量 push + if (remoteMovies.isEmpty && remoteBooks.isEmpty && remoteNotes.isEmpty) { + pushMovies.addAll(localMovies); + pushBooks.addAll(localBooks); + pushNotes.addAll(localNotes); + } else { + // 按 updated_at 合并 + final remoteMovieMap = {for (final m in remoteMovies) m.id: m}; + for (final m in localMovies) { + final r = remoteMovieMap.remove(m.id); + if (r == null || m.updatedAt.isAfter(r.updatedAt)) { + pushMovies.add(m); + } else { + await _upsertLocalMovie(m: r); } } + for (final r in remoteMovieMap.values) { + await _upsertLocalMovie(m: r); + } + + final remoteBookMap = {for (final b in remoteBooks) b.id: b}; + for (final b in localBooks) { + final r = remoteBookMap.remove(b.id); + if (r == null || b.updatedAt.isAfter(r.updatedAt)) { + pushBooks.add(b); + } else { + await _upsertLocalBook(b: r); + } + } + for (final r in remoteBookMap.values) { + await _upsertLocalBook(b: r); + } + + final remoteNoteMap = {for (final n in remoteNotes) n.id: n}; + for (final n in localNotes) { + final r = remoteNoteMap.remove(n.id); + if (r == null || n.updatedAt.isAfter(r.updatedAt)) { + pushNotes.add(n); + } else { + await _upsertLocalNote(n: r); + } + } + for (final r in remoteNoteMap.values) { + await _upsertLocalNote(n: r); + } } - final avatarsDir = Directory(p.join(appDir.path, 'avatars')); - if (await avatarsDir.exists()) { - await for (final entity in avatarsDir.list()) { - if (entity is File) { - final relPath = p.relative(entity.path, from: appDir.path).replaceAll('\\', '/'); - request.files.add(await http.MultipartFile('images', entity.readAsBytes().asStream(), await entity.length(), filename: relPath)); - } + // 批量 push 到服务端 + if (pushMovies.isNotEmpty || pushBooks.isNotEmpty || pushNotes.isNotEmpty) { + final db = await DatabaseHelper.instance.database; + final localTags = await db.query('tags'); + final result = await server.batchSync( + movies: pushMovies.isEmpty ? null : pushMovies, + books: pushBooks.isEmpty ? null : pushBooks, + notes: pushNotes.isEmpty ? null : pushNotes, + tags: localTags.isEmpty ? null : localTags.cast>(), + ); + debugPrint('[Sync] 批量推送: $result'); + + // 收集需要上传的图片 + for (final m in pushMovies) { + if (m.posterPath != null && m.posterPath!.isNotEmpty) imagePaths.add(m.posterPath!); + } + for (final b in pushBooks) { + if (b.coverPath != null && b.coverPath!.isNotEmpty) imagePaths.add(b.coverPath!); + } + for (final n in pushNotes) { + imagePaths.addAll(n.images.where((i) => i.isNotEmpty)); } } - final resp = await request.send().timeout(const Duration(seconds: 300)); - if (resp.statusCode == 200) { - debugPrint('[Sync] 上传成功'); - return true; + // 上传图片 + if (imagePaths.isNotEmpty) { + debugPrint('[Sync] 上传 ${imagePaths.length} 张图片'); + await ServerDataService.uploadLocalImages(imagePaths); } - debugPrint('[Sync] 上传失败 HTTP ${resp.statusCode}'); + + debugPrint('[Sync] 合并完成'); + return true; } catch (e) { - debugPrint('[Sync] 上传异常: $e'); + debugPrint('[Sync] 合并异常: $e'); + return false; } finally { _isSyncing = false; } - return false; + } + + // ─── 合并辅助方法 ──────────────────────────────────────────── + + Future _upsertLocalMovie({required Movie m}) async { + final db = await DatabaseHelper.instance.database; + await db.insert('movies', m.toJson(), conflictAlgorithm: ConflictAlgorithm.replace); + } + + Future _upsertLocalBook({required Book b}) async { + final db = await DatabaseHelper.instance.database; + await db.insert('books', b.toJson(), conflictAlgorithm: ConflictAlgorithm.replace); + } + + Future _upsertLocalNote({required Note n}) async { + final db = await DatabaseHelper.instance.database; + await db.insert('notes', n.toJson(), conflictAlgorithm: ConflictAlgorithm.replace); } /// 关闭同步:从服务器下载数据到本地 diff --git a/lib/utils/usage_stats_service.dart b/lib/utils/usage_stats_service.dart index b2837ff..2179b77 100644 --- a/lib/utils/usage_stats_service.dart +++ b/lib/utils/usage_stats_service.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:io' show Platform; import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'user_prefs.dart'; @@ -16,9 +17,10 @@ class UsageStatsService with WidgetsBindingObserver { final UserPrefs _prefs = UserPrefs(); - /// 统计服务器地址,发布前替换为实际地址,置空则禁用 - static String serverUrl = 'http://api.mooknote.iletter.top/'; - // static String serverUrl = 'http://192.168.31.48:27050/'; + /// 统计服务器地址,debug 走局域网,release 走线上 + static String serverUrl = kDebugMode + ? 'http://192.168.31.48:27047/' + : 'http://api.mooknote.iletter.top/'; Timer? _heartbeatTimer; bool _started = false; diff --git a/lib/widgets/custom_drawer.dart b/lib/widgets/custom_drawer.dart index c7dfc2c..c339f3e 100644 --- a/lib/widgets/custom_drawer.dart +++ b/lib/widgets/custom_drawer.dart @@ -8,6 +8,7 @@ import '../pages/stroll_page.dart'; import '../pages/markdown_reader/md_reader_tab_page.dart'; import '../pages/tag_management_page.dart'; import '../pages/profile_page.dart'; +import 'fade_in_local_image.dart'; /// 自定义侧边栏 class CustomDrawer extends StatefulWidget { @@ -97,9 +98,8 @@ class _CustomDrawerState extends State { ), clipBehavior: Clip.antiAlias, child: avatarPath != null && avatarPath.isNotEmpty - ? Image.file(File(avatarPath), fit: BoxFit.cover, - errorBuilder: (_, __, ___) => - Icon(Icons.person_outline, size: 26, color: colors.onSurface.withValues(alpha: 0.3))) + ? FadeInLocalImage(path: avatarPath, fit: BoxFit.cover, + errorWidget: Icon(Icons.person_outline, size: 26, color: colors.onSurface.withValues(alpha: 0.3))) : Icon(Icons.person_outline, size: 26, color: colors.onSurface.withValues(alpha: 0.3)), ), const SizedBox(width: 14),