适配平板

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

167
lib/widgets/add_sheet.dart Normal file
View 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),
],
),
),
);
}

View File

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

View File

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

View File

@@ -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)));

View 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))),
],
),
);
}
}

View 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!),
],
);
}
}

View File

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

View File

@@ -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,

View File

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