适配平板

This commit is contained in:
DelLevin-Home
2026-06-30 21:51:38 +08:00
parent 18cda23f2a
commit 1192f8f7e0
19 changed files with 699 additions and 377 deletions

View File

@@ -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<BookDetailPage> createState() => _BookDetailPageState();
@@ -152,8 +153,10 @@ class _BookDetailPageState extends State<BookDetailPage> {
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<AppProvider>().selectBook(null)
: () => Navigator.pop(context),
),
ValueListenableBuilder<bool>(
valueListenable: _showTitle,
@@ -370,8 +373,10 @@ class _BookDetailPageState extends State<BookDetailPage> {
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<AppProvider>().selectBook(null)
: () => Navigator.pop(context),
),
const SizedBox(width: 4),
Expanded(
@@ -1228,7 +1233,11 @@ class _BookDetailPageState extends State<BookDetailPage> {
await context.read<AppProvider>().removeBook(widget.book.id);
if (!mounted) return;
Navigator.pop(context);
Navigator.pop(context);
if (widget.embedded) {
context.read<AppProvider>().selectBook(null);
} else {
Navigator.pop(context);
}
ToastUtil.show(context, '已删除');
},
style: ElevatedButton.styleFrom(

View File

@@ -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<BookTabPage> {
int _lastScrollSignal = 0;
int _prevBookCount = -1;
void _onBookTap(Book book) {
if (Breakpoint.isWideContent(context)) {
context.read<AppProvider>().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<BookTabPage> {
@override
Widget build(BuildContext context) {
return Column(children: [
final isWideContent = Breakpoint.isWideContent(context);
final provider = context.watch<AppProvider>();
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<BookTabPage> {
}
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<AppProvider>();
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<BookTabPage> {
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)),

View File

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

View File

@@ -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<HomePage> {
@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<AppProvider>(
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<AppProvider>().bottomNavIndex != 1
? const CustomDrawer()

View File

@@ -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<MovieDetailPage> createState() => _MovieDetailPageState();
@@ -119,8 +120,12 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
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<AppProvider>().selectMovie(null)
: () => Navigator.pop(context),
),
const SizedBox(width: 4),
Expanded(
@@ -186,8 +191,12 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
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<AppProvider>().selectMovie(null)
: () => Navigator.pop(context),
),
ValueListenableBuilder<bool>(
valueListenable: _showTitle,
@@ -1180,9 +1189,14 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
final provider = context.read<AppProvider>();
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, '已删除');
}

View File

@@ -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<MovieTabPage> {
await _loadFirst();
}
void _onMovieTap(Movie movie) {
if (Breakpoint.isWideContent(context)) {
context.read<AppProvider>().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<AppProvider>();
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<MovieTabPage> {
}
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<AppProvider>();
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<MovieTabPage> {
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<MovieTabPage> {
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,

View File

@@ -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<NoteDetailPage> createState() => _NoteDetailPageState();
@@ -30,6 +31,12 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
return Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(
leading: widget.embedded
? IconButton(
icon: const Icon(Icons.close),
onPressed: () => context.read<AppProvider>().selectNote(null),
)
: null,
titleSpacing: 0,
title: Text(
note.title.isNotEmpty
@@ -374,7 +381,11 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
onPressed: () {
Navigator.pop(ctx);
context.read<AppProvider>().removeNote(widget.note.id);
Navigator.pop(context);
if (widget.embedded) {
context.read<AppProvider>().selectNote(null);
} else {
Navigator.pop(context);
}
},
child: Text('删除', style: TextStyle(color: Theme.of(context).colorScheme.error)),
),

View File

@@ -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<NoteTabPage> {
setState(() { _items.addAll(list); _offset += list.length; _hasMore = list.length >= 20; _isLoading = false; });
}
void _onNoteTap(Note note) {
if (Breakpoint.isWideContent(context)) {
context.read<AppProvider>().selectNote(note);
} else {
Navigator.pushNamed(context, '/note-detail', arguments: note).then((_) => _loadFirst());
}
}
Future<void> _refresh() async {
await context.read<AppProvider>().loadNotes();
await _loadFirst();
@@ -98,20 +110,28 @@ class _NoteTabPageState extends State<NoteTabPage> {
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final isWideContent = Breakpoint.isWideContent(context);
return Consumer<AppProvider>(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<NoteTabPage> {
);
}
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<AppProvider>().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<NoteTabPage> {
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<NoteTabPage> {
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 = <Note>[];
final rightItems = <Note>[];
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, (_) => <Note>[]);
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<NoteTabPage> {
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),