From 1192f8f7e06bc3c7fae4a4bc743fb04c88c38318 Mon Sep 17 00:00:00 2001 From: DelLevin-Home Date: Tue, 30 Jun 2026 21:51:38 +0800 Subject: [PATCH] =?UTF-8?q?=E9=80=82=E9=85=8D=E5=B9=B3=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/book/book_detail_page.dart | 21 +- lib/pages/book/book_tab_page.dart | 55 ++++- lib/pages/epub_reader/epub_library_page.dart | 38 ++-- lib/pages/home_page.dart | 59 ++++++ lib/pages/movies/movie_detail_page.dart | 30 ++- lib/pages/movies/movie_tab_page.dart | 65 ++++-- lib/pages/note/note_detail_page.dart | 15 +- lib/pages/note/note_tab_page.dart | 70 ++++--- lib/providers/app_provider.dart | 23 +++ lib/utils/responsive.dart | 31 +++ lib/widgets/add_sheet.dart | 167 +++++++++++++++ lib/widgets/book_list_item.dart | 20 +- lib/widgets/bottom_nav_bar.dart | 204 +------------------ lib/widgets/custom_drawer.dart | 71 ++++--- lib/widgets/detail_placeholder.dart | 31 +++ lib/widgets/master_detail_scaffold.dart | 36 ++++ lib/widgets/movie_list_item.dart | 20 +- lib/widgets/note_list_item.dart | 13 +- lib/widgets/shimmer_skeleton.dart | 107 +++++----- 19 files changed, 699 insertions(+), 377 deletions(-) create mode 100644 lib/utils/responsive.dart create mode 100644 lib/widgets/add_sheet.dart create mode 100644 lib/widgets/detail_placeholder.dart create mode 100644 lib/widgets/master_detail_scaffold.dart diff --git a/lib/pages/book/book_detail_page.dart b/lib/pages/book/book_detail_page.dart index 7788018..571eae6 100644 --- a/lib/pages/book/book_detail_page.dart +++ b/lib/pages/book/book_detail_page.dart @@ -17,8 +17,9 @@ import '../epub_reader/reader_screen.dart'; /// 书籍详情页 - 极简主义设计 class BookDetailPage extends StatefulWidget { final Book book; + final bool embedded; - const BookDetailPage({super.key, required this.book}); + const BookDetailPage({super.key, required this.book, this.embedded = false}); @override State createState() => _BookDetailPageState(); @@ -152,8 +153,10 @@ class _BookDetailPageState extends State { child: Row(children: [ const SizedBox(width: 4), IconButton( - icon: const Icon(Icons.arrow_back_ios_new, color: Colors.white, size: 18), - onPressed: () => Navigator.pop(context), + icon: Icon(widget.embedded ? Icons.close : Icons.arrow_back_ios_new, color: Colors.white, size: 18), + onPressed: widget.embedded + ? () => context.read().selectBook(null) + : () => Navigator.pop(context), ), ValueListenableBuilder( valueListenable: _showTitle, @@ -370,8 +373,10 @@ class _BookDetailPageState extends State { child: Row(children: [ const SizedBox(width: 4), IconButton( - icon: Icon(Icons.arrow_back_ios_new, color: colors.onSurface, size: 18), - onPressed: () => Navigator.pop(context), + icon: Icon(widget.embedded ? Icons.close : Icons.arrow_back_ios_new, color: colors.onSurface, size: 18), + onPressed: widget.embedded + ? () => context.read().selectBook(null) + : () => Navigator.pop(context), ), const SizedBox(width: 4), Expanded( @@ -1228,7 +1233,11 @@ class _BookDetailPageState extends State { await context.read().removeBook(widget.book.id); if (!mounted) return; Navigator.pop(context); - Navigator.pop(context); + if (widget.embedded) { + context.read().selectBook(null); + } else { + Navigator.pop(context); + } ToastUtil.show(context, '已删除'); }, style: ElevatedButton.styleFrom( diff --git a/lib/pages/book/book_tab_page.dart b/lib/pages/book/book_tab_page.dart index 3445702..d6c03be 100644 --- a/lib/pages/book/book_tab_page.dart +++ b/lib/pages/book/book_tab_page.dart @@ -2,12 +2,16 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../models/data_models.dart'; import '../../providers/app_provider.dart'; +import '../../utils/responsive.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'; +import '../../widgets/master_detail_scaffold.dart'; +import '../../widgets/detail_placeholder.dart'; +import 'book_detail_page.dart'; /// 阅读标签页(分页 + 触底加载) class BookTabPage extends StatefulWidget { @@ -30,6 +34,14 @@ class _BookTabPageState extends State { int _lastScrollSignal = 0; int _prevBookCount = -1; + void _onBookTap(Book book) { + if (Breakpoint.isWideContent(context)) { + context.read().selectBook(book); + } else { + Navigator.pushNamed(context, '/book-detail', arguments: book); + } + } + static const _statusMap = {0: 'read', 1: 'reading', 2: 'want_to_read'}; @override @@ -108,10 +120,22 @@ class _BookTabPageState extends State { @override Widget build(BuildContext context) { - return Column(children: [ + final isWideContent = Breakpoint.isWideContent(context); + final provider = context.watch(); + final masterContent = Column(children: [ const BookStatusBar(), Expanded(child: _buildBody(context)), ]); + + if (!isWideContent) return masterContent; + + final selectedBook = provider.selectedBook; + return MasterDetailScaffold( + master: masterContent, + detail: selectedBook != null + ? BookDetailPage(book: selectedBook, embedded: true) + : const DetailPlaceholder(icon: Icons.menu_book_outlined, message: '选择一本书查看详情'), + ); } Widget _buildBody(BuildContext context) { @@ -132,15 +156,24 @@ class _BookTabPageState extends State { } 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]); - }, - ); + return LayoutBuilder(builder: (context, constraints) { + final crossAxisCount = responsiveCrossAxisCount(constraints.maxWidth, minItemWidth: 110); + return GridView.builder(controller: _scrollController, + padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: crossAxisCount, childAspectRatio: 0.55, crossAxisSpacing: 12, mainAxisSpacing: 16), + itemCount: _items.length + (_hasMore ? 1 : 0), + itemBuilder: (context, index) { + if (index >= _items.length) return _buildLoadMore(); + final item = _items[index]; + final provider = context.read(); + return BookListItem( + book: item, + selected: Breakpoint.isWideContent(context) && provider.selectedBook?.id == item.id, + onTap: () => _onBookTap(item), + ); + }, + ); + }); } Widget _buildListView() { @@ -165,7 +198,7 @@ class _BookTabPageState extends State { Widget _buildListCard(Book book) { final colors = Theme.of(context).colorScheme; return GestureDetector( - onTap: () => Navigator.pushNamed(context, '/book-detail', arguments: book), + onTap: () => _onBookTap(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)), diff --git a/lib/pages/epub_reader/epub_library_page.dart b/lib/pages/epub_reader/epub_library_page.dart index 0142bf5..acc21f3 100644 --- a/lib/pages/epub_reader/epub_library_page.dart +++ b/lib/pages/epub_reader/epub_library_page.dart @@ -4,6 +4,7 @@ import 'package:file_picker/file_picker.dart'; import '../../utils/epub/reader_dao.dart'; import '../../utils/epub/epub_service.dart'; import '../../utils/user_prefs.dart'; +import '../../utils/responsive.dart'; import 'epub_detail_page.dart'; import 'widgets/book_grid_item.dart'; @@ -228,22 +229,27 @@ class _EpubLibraryPageState extends State { } Widget _buildGridView(ColorScheme colors) { - return GridView.builder( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: 12, - mainAxisSpacing: 16, - childAspectRatio: 0.55, - ), - itemCount: _books.length, - itemBuilder: (context, index) { - final book = _books[index]; - return BookGridItem( - book: book, - viewMode: ViewMode.relaxed, - onTap: () => _openBook(book), - onLongPress: () => _deleteBook(book), + return LayoutBuilder( + builder: (context, constraints) { + final count = responsiveCrossAxisCount(constraints.maxWidth, minItemWidth: 120); + return GridView.builder( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: count, + crossAxisSpacing: 12, + mainAxisSpacing: 16, + childAspectRatio: 0.55, + ), + itemCount: _books.length, + itemBuilder: (context, index) { + final book = _books[index]; + return BookGridItem( + book: book, + viewMode: ViewMode.relaxed, + onTap: () => _openBook(book), + onLongPress: () => _deleteBook(book), + ); + }, ); }, ); diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index b451165..84d83fc 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -2,8 +2,10 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/app_provider.dart'; import '../utils/user_prefs.dart'; +import '../utils/responsive.dart'; import '../widgets/custom_drawer.dart'; import '../widgets/bottom_nav_bar.dart'; +import '../widgets/add_sheet.dart'; import 'main_content_page.dart'; import 'profile_page.dart'; @@ -57,6 +59,63 @@ class _HomePageState extends State { @override Widget build(BuildContext context) { + if (Breakpoint.isTablet(context)) { + return _buildTabletLayout(context); + } + return _buildPhoneLayout(context); + } + + Widget _buildTabletLayout(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Consumer( + builder: (context, provider, child) { + _onNavIndexChanged(provider); + return Scaffold( + body: Row( + children: [ + SizedBox( + width: 260, + child: Material( + color: colors.surfaceContainerHigh, + child: const CustomDrawer(embedded: true), + ), + ), + NavigationRail( + selectedIndex: provider.bottomNavIndex == 0 ? 0 : 1, + backgroundColor: colors.surface, + labelType: NavigationRailLabelType.all, + leading: Padding( + padding: const EdgeInsets.only(top: 8, bottom: 12), + child: FloatingActionButton.small( + onPressed: () => showAddSheet(context, provider), + child: const Icon(Icons.add), + ), + ), + onDestinationSelected: (index) { + provider.setBottomNavIndex(index == 0 ? 0 : 2); + }, + destinations: const [ + NavigationRailDestination( + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home), + label: Text('首页'), + ), + NavigationRailDestination( + icon: Icon(Icons.person_outline), + selectedIcon: Icon(Icons.person), + label: Text('我的'), + ), + ], + ), + Expanded(child: _buildPageView(provider)), + ], + ), + ); + }, + ); + } + + Widget _buildPhoneLayout(BuildContext context) { return Scaffold( drawer: context.watch().bottomNavIndex != 1 ? const CustomDrawer() diff --git a/lib/pages/movies/movie_detail_page.dart b/lib/pages/movies/movie_detail_page.dart index bb221c9..2228df5 100644 --- a/lib/pages/movies/movie_detail_page.dart +++ b/lib/pages/movies/movie_detail_page.dart @@ -15,8 +15,9 @@ import 'movie_share_page.dart'; /// 影视详情页 - 极简主义设计 class MovieDetailPage extends StatefulWidget { final Movie movie; + final bool embedded; - const MovieDetailPage({super.key, required this.movie}); + const MovieDetailPage({super.key, required this.movie, this.embedded = false}); @override State createState() => _MovieDetailPageState(); @@ -119,8 +120,12 @@ class _MovieDetailPageState extends State { child: Row(children: [ const SizedBox(width: 4), IconButton( - icon: Icon(Icons.arrow_back_ios_new, color: colors.onSurface, size: 18), - onPressed: () => Navigator.pop(context), + icon: widget.embedded + ? Icon(Icons.close, color: colors.onSurface, size: 18) + : Icon(Icons.arrow_back_ios_new, color: colors.onSurface, size: 18), + onPressed: widget.embedded + ? () => context.read().selectMovie(null) + : () => Navigator.pop(context), ), const SizedBox(width: 4), Expanded( @@ -186,8 +191,12 @@ class _MovieDetailPageState extends State { child: Row(children: [ const SizedBox(width: 4), IconButton( - icon: const Icon(Icons.arrow_back_ios_new, color: Colors.white, size: 18), - onPressed: () => Navigator.pop(context), + icon: widget.embedded + ? const Icon(Icons.close, color: Colors.white, size: 18) + : const Icon(Icons.arrow_back_ios_new, color: Colors.white, size: 18), + onPressed: widget.embedded + ? () => context.read().selectMovie(null) + : () => Navigator.pop(context), ), ValueListenableBuilder( valueListenable: _showTitle, @@ -1180,9 +1189,14 @@ class _MovieDetailPageState extends State { final provider = context.read(); await provider.removeMovie(widget.movie.id); if (!mounted) return; - final navigator = Navigator.of(context); - navigator.pop(); - navigator.pop(); + if (widget.embedded) { + Navigator.of(context).pop(); // close dialog + provider.selectMovie(null); + } else { + final navigator = Navigator.of(context); + navigator.pop(); + navigator.pop(); + } if (mounted) { ToastUtil.show(context, '已删除'); } diff --git a/lib/pages/movies/movie_tab_page.dart b/lib/pages/movies/movie_tab_page.dart index fead832..3a55174 100644 --- a/lib/pages/movies/movie_tab_page.dart +++ b/lib/pages/movies/movie_tab_page.dart @@ -8,6 +8,10 @@ import '../../widgets/movie_list_item.dart'; import '../../widgets/animated_star_rating.dart'; import '../../widgets/shimmer_skeleton.dart'; import '../../widgets/fade_in_local_image.dart'; +import '../../utils/responsive.dart'; +import '../../widgets/master_detail_scaffold.dart'; +import '../../widgets/detail_placeholder.dart'; +import 'movie_detail_page.dart'; /// 观影标签页(分页 + 触底加载) class MovieTabPage extends StatefulWidget { @@ -119,16 +123,37 @@ class _MovieTabPageState extends State { await _loadFirst(); } + void _onMovieTap(Movie movie) { + if (Breakpoint.isWideContent(context)) { + context.read().selectMovie(movie); + } else { + Navigator.pushNamed(context, '/movie-detail', arguments: movie); + } + } + @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; - return Column( + final isWideContent = Breakpoint.isWideContent(context); + final provider = context.watch(); + + final masterContent = Column( children: [ const MovieStatusBar(), - Divider(height: 0.5, thickness: 0.5, color: colors.outline), + Divider(height: 0.5, thickness: 0.5, color: colors.outlineVariant), Expanded(child: _buildBody(context)), ], ); + + if (!isWideContent) return masterContent; + + final selectedMovie = provider.selectedMovie; + return MasterDetailScaffold( + master: masterContent, + detail: selectedMovie != null + ? MovieDetailPage(movie: selectedMovie, embedded: true) + : const DetailPlaceholder(icon: Icons.movie_outlined, message: '选择一部影片查看详情'), + ); } Widget _buildBody(BuildContext context) { @@ -166,16 +191,28 @@ class _MovieTabPageState extends State { } 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]); + return LayoutBuilder( + builder: (context, constraints) { + final crossAxisCount = responsiveCrossAxisCount(constraints.maxWidth, minItemWidth: 110); + final isWideContent = Breakpoint.isWideContent(context); + final provider = context.read(); + return GridView.builder( + controller: _scrollController, + padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, childAspectRatio: 0.55, crossAxisSpacing: 12, mainAxisSpacing: 16, + ), + itemCount: _items.length + (_hasMore ? 1 : 0), + itemBuilder: (context, index) { + if (index >= _items.length) return _buildLoadMoreIndicator(); + final item = _items[index]; + return MovieListItem( + movie: item, + selected: isWideContent && provider.selectedMovie?.id == item.id, + onTap: () => _onMovieTap(item), + ); + }, + ); }, ); } @@ -206,7 +243,7 @@ class _MovieTabPageState extends State { Widget _buildListCard(Movie movie) { final colors = Theme.of(context).colorScheme; return GestureDetector( - onTap: () => Navigator.pushNamed(context, '/movie-detail', arguments: movie), + onTap: () => _onMovieTap(movie), onLongPress: () => _showDeleteDialog(context, movie), child: Container( margin: const EdgeInsets.only(bottom: 8), @@ -267,7 +304,7 @@ class _MovieTabPageState extends State { Widget _buildCoverCard(Movie movie) { final colors = Theme.of(context).colorScheme; return GestureDetector( - onTap: () => Navigator.pushNamed(context, '/movie-detail', arguments: movie), + onTap: () => _onMovieTap(movie), onLongPress: () => _showDeleteDialog(context, movie), child: Container( height: 200, diff --git a/lib/pages/note/note_detail_page.dart b/lib/pages/note/note_detail_page.dart index 483995e..799fbab 100644 --- a/lib/pages/note/note_detail_page.dart +++ b/lib/pages/note/note_detail_page.dart @@ -9,8 +9,9 @@ import 'note_share_page.dart'; /// 笔记详情页 class NoteDetailPage extends StatefulWidget { final Note note; + final bool embedded; - const NoteDetailPage({super.key, required this.note}); + const NoteDetailPage({super.key, required this.note, this.embedded = false}); @override State createState() => _NoteDetailPageState(); @@ -30,6 +31,12 @@ class _NoteDetailPageState extends State { return Scaffold( backgroundColor: colors.surface, appBar: AppBar( + leading: widget.embedded + ? IconButton( + icon: const Icon(Icons.close), + onPressed: () => context.read().selectNote(null), + ) + : null, titleSpacing: 0, title: Text( note.title.isNotEmpty @@ -374,7 +381,11 @@ class _NoteDetailPageState extends State { onPressed: () { Navigator.pop(ctx); context.read().removeNote(widget.note.id); - Navigator.pop(context); + if (widget.embedded) { + context.read().selectNote(null); + } else { + Navigator.pop(context); + } }, child: Text('删除', style: TextStyle(color: Theme.of(context).colorScheme.error)), ), diff --git a/lib/pages/note/note_tab_page.dart b/lib/pages/note/note_tab_page.dart index 59c49a9..3afa188 100644 --- a/lib/pages/note/note_tab_page.dart +++ b/lib/pages/note/note_tab_page.dart @@ -6,6 +6,10 @@ import '../../utils/user_prefs.dart'; import '../../widgets/note_list_item.dart'; import '../../widgets/shimmer_skeleton.dart'; import '../../widgets/fade_in_local_image.dart'; +import '../../utils/responsive.dart'; +import '../../widgets/master_detail_scaffold.dart'; +import '../../widgets/detail_placeholder.dart'; +import 'note_detail_page.dart'; /// 笔记标签页(分页 + 触底加载) class NoteTabPage extends StatefulWidget { @@ -90,6 +94,14 @@ class _NoteTabPageState extends State { setState(() { _items.addAll(list); _offset += list.length; _hasMore = list.length >= 20; _isLoading = false; }); } + void _onNoteTap(Note note) { + if (Breakpoint.isWideContent(context)) { + context.read().selectNote(note); + } else { + Navigator.pushNamed(context, '/note-detail', arguments: note).then((_) => _loadFirst()); + } + } + Future _refresh() async { await context.read().loadNotes(); await _loadFirst(); @@ -98,20 +110,28 @@ class _NoteTabPageState extends State { @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; + final isWideContent = Breakpoint.isWideContent(context); 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)])); } - return _buildContent(); + final masterContent = _buildContent(isWideContent); + if (!isWideContent) return masterContent; + return MasterDetailScaffold( + master: masterContent, + detail: provider.selectedNote != null + ? NoteDetailPage(note: provider.selectedNote!, embedded: true) + : const DetailPlaceholder(icon: Icons.note_outlined, message: '选择一条笔记查看详情'), + ); }); } - Widget _buildContent() { - if (_layoutStyle == 1) return _buildWaterfallView(); - if (_layoutStyle == 2) return _buildTimelineView(); - return _buildListView(); + Widget _buildContent(bool isWideContent) { + if (_layoutStyle == 1) return _buildWaterfallView(isWideContent); + if (_layoutStyle == 2) return _buildTimelineView(isWideContent); + return _buildListView(isWideContent); } Widget _buildSkeleton() { @@ -142,20 +162,21 @@ class _NoteTabPageState extends State { ); } - Widget _buildListView() { + Widget _buildListView(bool isWideContent) { 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: _items.length + (_hasMore ? 1 : 0), itemBuilder: (context, index) { if (index >= _items.length) return _buildLoadMore(); - return NoteListItem(note: _items[index]); + final selectedId = context.read().selectedNote?.id; + return NoteListItem(note: _items[index], selected: isWideContent && selectedId == _items[index].id, onTap: () => _onNoteTap(_items[index])); }, ), ); } - Widget _buildTimelineView() { + Widget _buildTimelineView(bool isWideContent) { 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), @@ -171,7 +192,7 @@ 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((_) => _loadFirst()), + onTap: () => _onNoteTap(note), onLongPress: () => _showNoteActions(note), child: IntrinsicHeight(child: Row(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox(width: 40, child: Column(children: [ @@ -208,19 +229,22 @@ class _NoteTabPageState extends State { 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 leftItems = []; - final rightItems = []; - for (int i = 0; i < _items.length; i++) { - (i % 2 == 0 ? leftItems : rightItems).add(_items[i]); - } - return SingleChildScrollView(controller: _scrollController, padding: const EdgeInsets.fromLTRB(12, 8, 12, 80), - child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded(child: Column(children: leftItems.map(_buildWaterfallCard).toList())), - const SizedBox(width: 8), - Expanded(child: Column(children: rightItems.map(_buildWaterfallCard).toList())), - ]), - ); + Widget _buildWaterfallView(bool isWideContent) { + return LayoutBuilder(builder: (context, constraints) { + final colCount = responsiveCrossAxisCount(constraints.maxWidth, minItemWidth: 160, minCount: 2, maxCount: 4); + final columns = List.generate(colCount, (_) => []); + for (int i = 0; i < _items.length; i++) { + columns[i % colCount].add(_items[i]); + } + return SingleChildScrollView(controller: _scrollController, padding: const EdgeInsets.fromLTRB(12, 8, 12, 80), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + for (int c = 0; c < colCount; c++) ...[ + if (c > 0) const SizedBox(width: 8), + Expanded(child: Column(children: columns[c].map(_buildWaterfallCard).toList())), + ], + ]), + ); + }); } Widget _buildWaterfallCard(Note note) { @@ -230,7 +254,7 @@ class _NoteTabPageState extends State { final hasImage = images.isNotEmpty; final extraCount = images.length - 1; return GestureDetector( - onTap: () => Navigator.pushNamed(context, '/note-detail', arguments: note).then((_) => _loadFirst()), + onTap: () => _onNoteTap(note), onLongPress: () => _showNoteActions(note), child: Container(margin: const EdgeInsets.only(bottom: 8), decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(10), diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index b9841d4..582c8c5 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -42,6 +42,11 @@ class AppProvider extends ChangeNotifier { // 底部导航栏是否可见 bool _bottomNavVisible = true; + // 平板 Master-Detail 选中项 + Movie? _selectedMovie; + Book? _selectedBook; + Note? _selectedNote; + // 主题模式 ThemeMode _themeMode = ThemeMode.system; @@ -144,6 +149,9 @@ class AppProvider extends ChangeNotifier { // Getters int get mainTabIndex => _mainTabIndex; int get bottomNavIndex => _bottomNavIndex; + Movie? get selectedMovie => _selectedMovie; + Book? get selectedBook => _selectedBook; + Note? get selectedNote => _selectedNote; int get movieStatusIndex => _movieStatusIndex; int get movieLayoutStyle => _movieLayoutStyle; int get bookStatusIndex => _bookStatusIndex; @@ -184,6 +192,21 @@ class AppProvider extends ChangeNotifier { notifyListeners(); } + void selectMovie(Movie? movie) { + _selectedMovie = movie; + notifyListeners(); + } + + void selectBook(Book? book) { + _selectedBook = book; + notifyListeners(); + } + + void selectNote(Note? note) { + _selectedNote = note; + notifyListeners(); + } + void setBottomNavVisible(bool visible) { if (_bottomNavVisible != visible) { _bottomNavVisible = visible; diff --git a/lib/utils/responsive.dart b/lib/utils/responsive.dart new file mode 100644 index 0000000..a38cd53 --- /dev/null +++ b/lib/utils/responsive.dart @@ -0,0 +1,31 @@ +import 'package:flutter/widgets.dart'; + +/// 响应式布局断点工具 +class Breakpoint { + /// ≥600dp: 平板布局(常驻侧边栏 + NavigationRail) + static const double tablet = 600.0; + + /// ≥900dp: 宽屏内容区(列表-详情并排显示) + static const double wideContent = 900.0; + + static bool isTablet(BuildContext context) => + MediaQuery.sizeOf(context).width >= tablet; + + static bool isPhone(BuildContext context) => + MediaQuery.sizeOf(context).width < tablet; + + /// 是否使用宽屏内容布局(列表+详情并排) + static bool isWideContent(BuildContext context) => + MediaQuery.sizeOf(context).width >= wideContent; +} + +/// 根据可用宽度动态计算网格列数 +int responsiveCrossAxisCount( + double availableWidth, { + double minItemWidth = 160, + int minCount = 2, + int maxCount = 6, +}) { + final count = (availableWidth / minItemWidth).floor(); + return count.clamp(minCount, maxCount); +} diff --git a/lib/widgets/add_sheet.dart b/lib/widgets/add_sheet.dart new file mode 100644 index 0000000..56b7835 --- /dev/null +++ b/lib/widgets/add_sheet.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; +import '../providers/app_provider.dart'; + +/// 新增记录弹窗 — 供底部导航栏和 NavigationRail 共用 + +void showQuickAddSheet(BuildContext context, AppProvider provider) { + final currentTab = provider.mainTabIndex; + switch (currentTab) { + case 0: + final statusMap = {0: 'watched', 1: 'watching', 2: 'want_to_watch'}; + final currentStatus = + statusMap[provider.movieStatusIndex] ?? 'want_to_watch'; + Navigator.pushNamed(context, '/movie-form', + arguments: {'initialStatus': currentStatus}); + case 1: + final statusMap = {0: 'read', 1: 'reading', 2: 'want_to_read'}; + final currentStatus = + statusMap[provider.bookStatusIndex] ?? 'want_to_read'; + Navigator.pushNamed(context, '/book-form', + arguments: {'initialStatus': currentStatus}); + case 2: + Navigator.pushNamed(context, '/note-form'); + default: + showAddSheet(context, provider); + } +} + +void showAddSheet(BuildContext context, AppProvider provider) { + final colors = Theme.of(context).colorScheme; + final outerContext = context; + showModalBottomSheet( + context: context, + backgroundColor: colors.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (ctx) { + final bc = Theme.of(ctx).colorScheme; + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: bc.onSurface.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + children: [ + Text('新增记录', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: bc.onSurface)), + ], + ), + ), + const SizedBox(height: 16), + _buildOption( + colors: bc, + icon: Icons.movie_outlined, + title: '添加观影', + subtitle: '记录你看过的电影', + onTap: () { + Navigator.pop(ctx); + final statusMap = { + 0: 'watched', + 1: 'watching', + 2: 'want_to_watch', + }; + final s = + statusMap[provider.movieStatusIndex] ?? 'want_to_watch'; + Navigator.pushNamed(outerContext, '/movie-form', + arguments: {'initialStatus': s}); + }, + ), + _buildOption( + colors: bc, + icon: Icons.menu_book_outlined, + title: '添加阅读', + subtitle: '记录你读过的书', + onTap: () { + Navigator.pop(ctx); + final statusMap = { + 0: 'read', + 1: 'reading', + 2: 'want_to_read', + }; + final s = + statusMap[provider.bookStatusIndex] ?? 'want_to_read'; + Navigator.pushNamed(outerContext, '/book-form', + arguments: {'initialStatus': s}); + }, + ), + _buildOption( + colors: bc, + icon: Icons.note_outlined, + title: '添加笔记', + subtitle: '记录你的想法和笔记', + onTap: () { + Navigator.pop(ctx); + Navigator.pushNamed(outerContext, '/note-form'); + }, + ), + ], + ), + ), + ); + }, + ); +} + +Widget _buildOption({ + required ColorScheme colors, + required IconData icon, + required String title, + required String subtitle, + required VoidCallback onTap, +}) { + return InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, size: 22, color: colors.onSurface.withValues(alpha: 0.6)), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: colors.onSurface)), + const SizedBox(height: 2), + Text(subtitle, + style: TextStyle( + fontSize: 13, + color: colors.onSurface.withValues(alpha: 0.4))), + ], + ), + ), + Icon(Icons.chevron_right, + color: colors.onSurface.withValues(alpha: 0.25), size: 20), + ], + ), + ), + ); +} diff --git a/lib/widgets/book_list_item.dart b/lib/widgets/book_list_item.dart index 34450b0..374bed2 100644 --- a/lib/widgets/book_list_item.dart +++ b/lib/widgets/book_list_item.dart @@ -9,18 +9,25 @@ import '../utils/toast_util.dart'; /// 书籍列表项组件 - 网格布局设计 class BookListItem extends StatelessWidget { final Book book; + final bool selected; + final VoidCallback? onTap; - const BookListItem({super.key, required this.book}); + const BookListItem({super.key, required this.book, this.selected = false, this.onTap}); @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; - return InkWell( - onTap: () { - Navigator.pushNamed(context, '/book-detail', arguments: book); - }, + return GestureDetector( + onTap: onTap ?? () => Navigator.pushNamed(context, '/book-detail', arguments: book), onLongPress: () => _showDeleteDialog(context), - child: Column( + child: Container( + decoration: selected + ? BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all(color: colors.primary, width: 2), + ) + : null, + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( @@ -44,6 +51,7 @@ class BookListItem extends StatelessWidget { const SizedBox(height: 16), ], ), + ), ); } diff --git a/lib/widgets/bottom_nav_bar.dart b/lib/widgets/bottom_nav_bar.dart index 4a9bdf8..4179cf4 100644 --- a/lib/widgets/bottom_nav_bar.dart +++ b/lib/widgets/bottom_nav_bar.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/app_provider.dart'; +import 'add_sheet.dart'; /// 自定义底部导航栏 - Dock栏悬浮设计 class CustomBottomNavBar extends StatelessWidget { @@ -98,8 +99,8 @@ class CustomBottomNavBar extends StatelessWidget { Widget _buildAddButton(BuildContext context, AppProvider provider) { final colors = Theme.of(context).colorScheme; return GestureDetector( - onTap: () => _showAddDialog(context, provider), - onLongPress: () => _showQuickAddDialog(context, provider), + onTap: () => showAddSheet(context, provider), + onLongPress: () => showQuickAddSheet(context, provider), child: Container( width: 44, height: 44, @@ -115,203 +116,4 @@ class CustomBottomNavBar extends StatelessWidget { ), ); } - - void _showQuickAddDialog(BuildContext context, AppProvider provider) { - final currentTab = provider.mainTabIndex; - - switch (currentTab) { - case 0: - final statusMap = { - 0: 'watched', - 1: 'watching', - 2: 'want_to_watch', - }; - final currentStatus = statusMap[provider.movieStatusIndex] ?? 'want_to_watch'; - Navigator.pushNamed( - context, - '/movie-form', - arguments: {'initialStatus': currentStatus}, - ); - break; - case 1: - final statusMap = { - 0: 'read', - 1: 'reading', - 2: 'want_to_read', - }; - final currentStatus = statusMap[provider.bookStatusIndex] ?? 'want_to_read'; - Navigator.pushNamed( - context, - '/book-form', - arguments: {'initialStatus': currentStatus}, - ); - break; - case 2: - Navigator.pushNamed(context, '/note-form'); - break; - default: - _showAddDialog(context, provider); - } - } - - void _showAddDialog(BuildContext context, AppProvider provider) { - final colors = Theme.of(context).colorScheme; - final outerContext = context; - showModalBottomSheet( - context: context, - backgroundColor: colors.surface, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - builder: (BuildContext context) { - final bottomColors = Theme.of(context).colorScheme; - return SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: bottomColors.onSurface.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(height: 20), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Row( - children: [ - Text( - '新增记录', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: bottomColors.onSurface, - ), - ), - ], - ), - ), - const SizedBox(height: 16), - _buildAddOption( - colors: bottomColors, - icon: Icons.movie_outlined, - title: '添加观影', - subtitle: '记录你看过的电影', - onTap: () { - Navigator.pop(context); - final statusMap = { - 0: 'watched', - 1: 'watching', - 2: 'want_to_watch', - }; - final currentStatus = statusMap[provider.movieStatusIndex] ?? 'want_to_watch'; - Navigator.pushNamed( - outerContext, - '/movie-form', - arguments: {'initialStatus': currentStatus}, - ); - }, - ), - _buildAddOption( - colors: bottomColors, - icon: Icons.menu_book_outlined, - title: '添加阅读', - subtitle: '记录你读过的书', - onTap: () { - Navigator.pop(context); - final statusMap = { - 0: 'read', - 1: 'reading', - 2: 'want_to_read', - }; - final currentStatus = statusMap[provider.bookStatusIndex] ?? 'want_to_read'; - Navigator.pushNamed( - outerContext, - '/book-form', - arguments: {'initialStatus': currentStatus}, - ); - }, - ), - _buildAddOption( - colors: bottomColors, - icon: Icons.note_outlined, - title: '添加笔记', - subtitle: '记录你的想法和笔记', - onTap: () { - Navigator.pop(context); - Navigator.pushNamed(outerContext, '/note-form'); - }, - ), - ], - ), - ), - ); - }, - ); - } - - Widget _buildAddOption({ - required ColorScheme colors, - required IconData icon, - required String title, - required String subtitle, - required VoidCallback onTap, - }) { - return InkWell( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), - child: Row( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(10), - ), - child: Icon( - icon, - size: 22, - color: colors.onSurface.withValues(alpha: 0.6), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: colors.onSurface, - ), - ), - const SizedBox(height: 2), - Text( - subtitle, - style: TextStyle( - fontSize: 13, - color: colors.onSurface.withValues(alpha: 0.4), - ), - ), - ], - ), - ), - Icon( - Icons.chevron_right, - color: colors.onSurface.withValues(alpha: 0.25), - size: 20, - ), - ], - ), - ), - ); - } } diff --git a/lib/widgets/custom_drawer.dart b/lib/widgets/custom_drawer.dart index 02afbdb..bebdef4 100644 --- a/lib/widgets/custom_drawer.dart +++ b/lib/widgets/custom_drawer.dart @@ -18,7 +18,8 @@ import 'fade_in_local_image.dart'; /// 自定义侧边栏 class CustomDrawer extends StatefulWidget { - const CustomDrawer({super.key}); + final bool embedded; + const CustomDrawer({super.key, this.embedded = false}); @override State createState() => _CustomDrawerState(); @@ -62,37 +63,40 @@ class _CustomDrawerState extends State { userPrefs.showSidebarMdReader || userPrefs.showSidebarEpub; - return Drawer( - backgroundColor: colors.surfaceContainerHigh, - child: SafeArea( - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildProfileCard(context), - if (showHeatmap) ...[ - const SizedBox(height: 16), - _buildCalendarSection(context), - ], - if (showRecent) ...[ - const SizedBox(height: 16), - _buildRecentSection(context), - ], - if (showTools) ...[ - const SizedBox(height: 16), - _buildToolsCard(context), - ], - Padding( - padding: const EdgeInsets.fromLTRB(20, 20, 20, 32), - child: Center( - child: Text('v$_version', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.2))), - ), - ), + final content = SafeArea( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildProfileCard(context), + if (showHeatmap) ...[ + const SizedBox(height: 16), + _buildCalendarSection(context), ], - ), + if (showRecent) ...[ + const SizedBox(height: 16), + _buildRecentSection(context), + ], + if (showTools) ...[ + const SizedBox(height: 16), + _buildToolsCard(context), + ], + Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 32), + child: Center( + child: Text('v$_version', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.2))), + ), + ), + ], ), ), ); + + if (widget.embedded) return content; + return Drawer( + backgroundColor: colors.surfaceContainerHigh, + child: content, + ); } // ─── 头像 + 统计卡片 ───────────────────────────────────────────────── @@ -215,7 +219,7 @@ class _CustomDrawerState extends State { final idx = i ~/ 2; final (icon, title, page) = items[idx]; return _buildToolItem(icon, title, () { - Navigator.pop(context); + if (!widget.embedded) Navigator.pop(context); Navigator.push(context, MaterialPageRoute(builder: (_) => page)); }, topRounded: idx == 0, bottomRounded: idx == items.length - 1); }), @@ -329,8 +333,11 @@ class _CustomDrawerState extends State { const totalWeeks = 20; const weekDays = 7; - const cellSize = 10.0; const cellGap = 1.5; + // 可用宽度 = drawer宽度 - margin(16*2) - container padding(18*2) + final drawerWidth = widget.embedded ? 260.0 : MediaQuery.sizeOf(context).width; + final availableWidth = drawerWidth - 32 - 36; + final cellSize = ((availableWidth - (totalWeeks - 1) * cellGap) / totalWeeks).clamp(6.0, 10.0); final cells = List.generate(weekDays, (_) => List.generate(totalWeeks, (_) => 0)); for (int week = 0; week < totalWeeks; week++) { @@ -376,7 +383,7 @@ class _CustomDrawerState extends State { children: List.generate(totalWeeks, (week) { final label = keepWeeks.contains(week) ? monthLabels[week] : null; return SizedBox( - width: cellSize + cellGap, + width: week < totalWeeks - 1 ? cellSize + cellGap : cellSize, child: label != null ? Text(label, style: TextStyle(fontSize: 9, color: colors.onSurface.withValues(alpha: 0.3))) : null, ); }), @@ -482,7 +489,7 @@ class _CustomDrawerState extends State { } void _openRecentItem(BuildContext context, _RecentItem item) { - Navigator.pop(context); + if (!widget.embedded) Navigator.pop(context); switch (item.type) { case 'movie': Navigator.push(context, MaterialPageRoute(builder: (_) => MovieDetailPage(movie: item.data as Movie))); diff --git a/lib/widgets/detail_placeholder.dart b/lib/widgets/detail_placeholder.dart new file mode 100644 index 0000000..0b316ee --- /dev/null +++ b/lib/widgets/detail_placeholder.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +/// Master-Detail 布局中右侧详情区的空白占位 +class DetailPlaceholder extends StatelessWidget { + final String message; + final IconData icon; + + const DetailPlaceholder({ + super.key, + this.message = '选择一条记录查看详情', + this.icon = Icons.touch_app_outlined, + }); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 64, color: colors.onSurface.withValues(alpha: 0.12)), + const SizedBox(height: 16), + Text(message, + style: TextStyle( + fontSize: 15, + color: colors.onSurface.withValues(alpha: 0.25))), + ], + ), + ); + } +} diff --git a/lib/widgets/master_detail_scaffold.dart b/lib/widgets/master_detail_scaffold.dart new file mode 100644 index 0000000..e8e05c9 --- /dev/null +++ b/lib/widgets/master_detail_scaffold.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import '../utils/responsive.dart'; + +/// Master-Detail 布局包装器 +/// 手机或 detail 为 null 时只显示 master;平板显示左右分栏 +class MasterDetailScaffold extends StatelessWidget { + final Widget master; + final Widget? detail; + final double masterFraction; + final double minMasterWidth; + + const MasterDetailScaffold({ + super.key, + required this.master, + this.detail, + this.masterFraction = 0.38, + this.minMasterWidth = 320, + }); + + @override + Widget build(BuildContext context) { + if (!Breakpoint.isWideContent(context) || detail == null) { + return master; + } + final screenWidth = MediaQuery.sizeOf(context).width; + final effectiveMasterWidth = + (screenWidth * masterFraction).clamp(minMasterWidth, screenWidth * 0.5); + return Row( + children: [ + SizedBox(width: effectiveMasterWidth, child: master), + const VerticalDivider(width: 0.5, thickness: 0.5), + Expanded(child: detail!), + ], + ); + } +} diff --git a/lib/widgets/movie_list_item.dart b/lib/widgets/movie_list_item.dart index ba81f83..9f736e1 100644 --- a/lib/widgets/movie_list_item.dart +++ b/lib/widgets/movie_list_item.dart @@ -9,18 +9,25 @@ import '../utils/toast_util.dart'; /// 观影列表项组件 - 网格布局设计 class MovieListItem extends StatelessWidget { final Movie movie; + final bool selected; + final VoidCallback? onTap; - const MovieListItem({super.key, required this.movie}); + const MovieListItem({super.key, required this.movie, this.selected = false, this.onTap}); @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; - return InkWell( - onTap: () { - Navigator.pushNamed(context, '/movie-detail', arguments: movie); - }, + return GestureDetector( + onTap: onTap ?? () => Navigator.pushNamed(context, '/movie-detail', arguments: movie), onLongPress: () => _showDeleteDialog(context), - child: Column( + child: Container( + decoration: selected + ? BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all(color: colors.primary, width: 2), + ) + : null, + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( @@ -44,6 +51,7 @@ class MovieListItem extends StatelessWidget { const SizedBox(height: 16), ], ), + ), ); } diff --git a/lib/widgets/note_list_item.dart b/lib/widgets/note_list_item.dart index d17104a..81f80d1 100644 --- a/lib/widgets/note_list_item.dart +++ b/lib/widgets/note_list_item.dart @@ -6,21 +6,25 @@ import '../models/data_models.dart'; /// 笔记列表项组件 - 卡片式设计,内容展示在卡片内 class NoteListItem extends StatelessWidget { final Note note; + final bool selected; + final VoidCallback? onTap; - const NoteListItem({super.key, required this.note}); + const NoteListItem({super.key, required this.note, this.selected = false, this.onTap}); @override Widget build(BuildContext context) { return RepaintBoundary( - child: _NoteListItemContent(note: note), + child: _NoteListItemContent(note: note, selected: selected, onTap: onTap), ); } } class _NoteListItemContent extends StatelessWidget { final Note note; + final bool selected; + final VoidCallback? onTap; - const _NoteListItemContent({required this.note}); + const _NoteListItemContent({required this.note, this.selected = false, this.onTap}); @override Widget build(BuildContext context) { @@ -28,7 +32,7 @@ class _NoteListItemContent extends StatelessWidget { final previewText = _getPreviewText(note); return GestureDetector( - onTap: () async { + onTap: onTap ?? () async { final provider = context.read(); await Navigator.pushNamed(context, '/note-detail', arguments: note); await provider.loadNotes(); @@ -40,6 +44,7 @@ class _NoteListItemContent extends StatelessWidget { decoration: BoxDecoration( color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(12), + border: selected ? Border.all(color: colors.primary, width: 1.5) : null, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/widgets/shimmer_skeleton.dart b/lib/widgets/shimmer_skeleton.dart index 87e0c2a..355cbfa 100644 --- a/lib/widgets/shimmer_skeleton.dart +++ b/lib/widgets/shimmer_skeleton.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../utils/responsive.dart'; /// 骨架屏加载组件 — 闪烁占位动画 class ShimmerSkeleton extends StatefulWidget { @@ -65,31 +66,36 @@ class MovieSkeletonGrid extends StatelessWidget { @override Widget build(BuildContext context) { - return GridView.builder( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - childAspectRatio: 0.55, - crossAxisSpacing: 12, - mainAxisSpacing: 16, - ), - itemCount: 9, - itemBuilder: (_, __) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: ShimmerSkeleton( - width: double.infinity, - height: double.infinity, - borderRadius: 8, - ), + return LayoutBuilder( + builder: (context, constraints) { + final count = responsiveCrossAxisCount(constraints.maxWidth, minItemWidth: 110); + return GridView.builder( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: count, + childAspectRatio: 0.55, + crossAxisSpacing: 12, + mainAxisSpacing: 16, ), - const SizedBox(height: 8), - const ShimmerSkeleton(width: double.infinity, height: 14), - const SizedBox(height: 4), - const ShimmerSkeleton(width: 70, height: 12), - ], - ), + itemCount: count * 3, + itemBuilder: (_, __) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ShimmerSkeleton( + width: double.infinity, + height: double.infinity, + borderRadius: 8, + ), + ), + const SizedBox(height: 8), + const ShimmerSkeleton(width: double.infinity, height: 14), + const SizedBox(height: 4), + const ShimmerSkeleton(width: 70, height: 12), + ], + ), + ); + }, ); } } @@ -100,31 +106,36 @@ class BookSkeletonGrid extends StatelessWidget { @override Widget build(BuildContext context) { - return GridView.builder( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - childAspectRatio: 0.55, - crossAxisSpacing: 12, - mainAxisSpacing: 16, - ), - itemCount: 9, - itemBuilder: (_, __) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: ShimmerSkeleton( - width: double.infinity, - height: double.infinity, - borderRadius: 4, - ), + return LayoutBuilder( + builder: (context, constraints) { + final count = responsiveCrossAxisCount(constraints.maxWidth, minItemWidth: 110); + return GridView.builder( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: count, + childAspectRatio: 0.55, + crossAxisSpacing: 12, + mainAxisSpacing: 16, ), - const SizedBox(height: 8), - const ShimmerSkeleton(width: double.infinity, height: 14), - const SizedBox(height: 4), - const ShimmerSkeleton(width: 70, height: 12), - ], - ), + itemCount: count * 3, + itemBuilder: (_, __) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ShimmerSkeleton( + width: double.infinity, + height: double.infinity, + borderRadius: 4, + ), + ), + const SizedBox(height: 8), + const ShimmerSkeleton(width: double.infinity, height: 14), + const SizedBox(height: 4), + const ShimmerSkeleton(width: 70, height: 12), + ], + ), + ); + }, ); } }