generated from dellevin/template
适配平板
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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, '已删除');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
31
lib/utils/responsive.dart
Normal file
31
lib/utils/responsive.dart
Normal file
@@ -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);
|
||||
}
|
||||
167
lib/widgets/add_sheet.dart
Normal file
167
lib/widgets/add_sheet.dart
Normal file
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<CustomDrawer> createState() => _CustomDrawerState();
|
||||
@@ -62,37 +63,40 @@ class _CustomDrawerState extends State<CustomDrawer> {
|
||||
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<CustomDrawer> {
|
||||
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<CustomDrawer> {
|
||||
|
||||
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<CustomDrawer> {
|
||||
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<CustomDrawer> {
|
||||
}
|
||||
|
||||
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)));
|
||||
|
||||
31
lib/widgets/detail_placeholder.dart
Normal file
31
lib/widgets/detail_placeholder.dart
Normal file
@@ -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))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
36
lib/widgets/master_detail_scaffold.dart
Normal file
36
lib/widgets/master_detail_scaffold.dart
Normal file
@@ -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!),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AppProvider>();
|
||||
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,
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user