fix:导航栏交互和UI

This commit is contained in:
jingyun
2026-06-05 16:05:32 +08:00
parent c5288f397d
commit 33d04a5545
10 changed files with 267 additions and 147 deletions
+2 -5
View File
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../data/api/report_data_source.dart';
import '../../data/content_providers.dart';
@@ -90,9 +89,7 @@ class FeedPage extends HookConsumerWidget {
],
),
),
const SizedBox(height: YantingSpacing.x3),
const ShadSeparator.horizontal(),
const SizedBox(height: YantingSpacing.x3),
const SizedBox(height: YantingSpacing.cardGap),
if (visible.isEmpty)
const EmptyState(
title: '暂无可推荐的研报解读',
@@ -115,7 +112,7 @@ class FeedPage extends HookConsumerWidget {
),
onPlayTap: () => _playFromReport(onPlay, visible.first),
),
const SizedBox(height: YantingSpacing.x6),
const SizedBox(height: YantingSpacing.sectionGap),
const SectionTitle(title: '最新解读', icon: Icons.chevron_right),
for (final report in visible.skip(1)) ...[
ReportCardWidget(
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../data/api/report_data_source.dart';
import '../../data/content_providers.dart';
@@ -43,9 +42,7 @@ class InstitutionsPage extends HookConsumerWidget {
),
children: [
const PageHeader(title: '机构', subtitle: '可获取研报的机构'),
const SizedBox(height: YantingSpacing.x3),
const ShadSeparator.horizontal(),
const SizedBox(height: YantingSpacing.x3),
const SizedBox(height: YantingSpacing.cardGap),
for (final item in sorted) ...[
InstitutionCard(
institution: item,
+35 -14
View File
@@ -52,8 +52,7 @@ class ListenPage extends HookConsumerWidget {
onPlay: () => onPlay(current),
),
const SectionTitle(title: '全部音频', icon: AppIcons.arrowRight),
const ShadSeparator.horizontal(),
const SizedBox(height: YantingSpacing.x3),
const SizedBox(height: YantingSpacing.cardGap),
for (final item in items.skip(1)) ...[
_AudioListCard(item: item, onPlay: () => onPlay(item)),
const SizedBox(height: YantingSpacing.x3),
@@ -115,12 +114,7 @@ class _ContinueListeningCard extends StatelessWidget {
const SizedBox(height: 16),
Row(
children: [
ShadButton(
onPressed: onPlay,
width: 48,
height: 48,
child: const Icon(AppIcons.play, size: 18),
),
_PlayControlButton(onPressed: onPlay, size: 54, iconSize: 22),
const SizedBox(width: 13),
Expanded(
child: Column(
@@ -197,14 +191,41 @@ class _AudioListCard extends StatelessWidget {
),
),
const SizedBox(width: 10),
ShadButton(
onPressed: onPlay,
width: 44,
height: 44,
child: const Icon(AppIcons.play, size: 16),
),
_PlayControlButton(onPressed: onPlay, size: 44, iconSize: 18),
],
),
);
}
}
class _PlayControlButton extends StatelessWidget {
const _PlayControlButton({
required this.onPressed,
required this.size,
required this.iconSize,
});
final VoidCallback onPressed;
final double size;
final double iconSize;
@override
Widget build(BuildContext context) {
return Material(
color: YantingColors.primary,
borderRadius: BorderRadius.circular(YantingRadius.pill),
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(YantingRadius.pill),
child: SizedBox.square(
dimension: size,
child: Icon(
AppIcons.play,
color: YantingColors.primaryForeground,
size: iconSize,
),
),
),
);
}
}
+13 -1
View File
@@ -141,7 +141,19 @@ class _MenuGroup extends StatelessWidget {
Widget build(BuildContext context) {
return AppCard(
padding: EdgeInsets.zero,
child: Column(children: children),
child: Column(
children: [
for (var index = 0; index < children.length; index++) ...[
children[index],
if (index != children.length - 1)
const Divider(
height: 1,
thickness: 1,
color: YantingColors.border,
),
],
],
),
);
}
}
+53 -40
View File
@@ -99,53 +99,66 @@ class ReportsPage extends HookConsumerWidget {
),
onChanged: (value) => query.value = value.trim(),
),
const SizedBox(height: YantingSpacing.x3),
Wrap(
spacing: YantingSpacing.x2,
runSpacing: YantingSpacing.x2,
const SizedBox(height: YantingSpacing.cardGap),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ShadButton.outline(
onPressed: items.isEmpty
? null
: () => _openFilterSheet(
context,
items: items,
topic: topic,
Expanded(
child: Wrap(
spacing: YantingSpacing.x2,
runSpacing: YantingSpacing.x2,
children: [
ShadButton.outline(
onPressed: items.isEmpty
? null
: () => _openFilterSheet(
context,
items: items,
topic: topic,
),
leading: const Icon(
LucideIcons.slidersHorizontal,
size: 16,
),
leading: const Icon(
LucideIcons.slidersHorizontal,
size: 16,
child: const Text('筛选'),
),
ShadButton.outline(
onPressed: () {},
leading: const Icon(
LucideIcons.arrowUpDown,
size: 16,
),
child: const Text('最新'),
),
ShadBadge.secondary(
onPressed: () => hasAudio.value = !currentHasAudio,
backgroundColor: currentHasAudio
? theme.colorScheme.foreground
: theme.colorScheme.secondary,
foregroundColor: currentHasAudio
? theme.colorScheme.background
: theme.colorScheme.secondaryForeground,
hoverBackgroundColor: currentHasAudio
? theme.colorScheme.foreground.withValues(
alpha: 0.9,
)
: theme.colorScheme.border,
child: const Text('音频'),
),
],
),
child: const Text('筛选'),
),
ShadButton.outline(
onPressed: () {},
leading: const Icon(LucideIcons.arrowUpDown, size: 16),
child: const Text('最新'),
),
ShadBadge.secondary(
onPressed: () => hasAudio.value = !currentHasAudio,
backgroundColor: currentHasAudio
? theme.colorScheme.foreground
: theme.colorScheme.secondary,
foregroundColor: currentHasAudio
? theme.colorScheme.background
: theme.colorScheme.secondaryForeground,
hoverBackgroundColor: currentHasAudio
? theme.colorScheme.foreground.withValues(alpha: 0.9)
: theme.colorScheme.border,
child: const Text('音频'),
const SizedBox(width: YantingSpacing.x2),
Padding(
padding: const EdgeInsets.only(top: 10),
child: Text(
'${filtered.length}',
style: YantingText.meta,
),
),
],
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: Text('${filtered.length}', style: YantingText.meta),
),
const SizedBox(height: YantingSpacing.x3),
const ShadSeparator.horizontal(),
const SizedBox(height: YantingSpacing.x3),
const SizedBox(height: YantingSpacing.cardGap),
if (filtered.isEmpty)
EmptyState(
title: currentQuery.isNotEmpty ? '未找到相关研报' : '当前筛选下暂无研报',
+17 -7
View File
@@ -31,8 +31,8 @@ class ReportCardWidget extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: WiseSpacing.x2,
runSpacing: WiseSpacing.x2,
spacing: hero ? WiseSpacing.x2 : 7,
runSpacing: hero ? WiseSpacing.x2 : 7,
children: [
AppBadge(text: report.interpretationLabel, kind: BadgeKind.brand),
if (report.hasAudio)
@@ -46,27 +46,32 @@ class ReportCardWidget extends StatelessWidget {
for (final topic in report.topics.take(3)) AppBadge(text: topic),
],
),
const SizedBox(height: WiseSpacing.x3),
SizedBox(height: hero ? WiseSpacing.x3 : 10),
Text(
report.titleCn,
maxLines: hero ? 3 : 2,
overflow: TextOverflow.ellipsis,
style: hero
? YantingText.sectionTitle.copyWith(fontSize: 21, height: 1.4)
: YantingText.cardTitle,
: YantingText.listTitle.copyWith(
fontSize: 17.5,
height: 1.38,
fontWeight: FontWeight.w700,
),
),
if (report.oneLiner.isNotEmpty) ...[
const SizedBox(height: WiseSpacing.x2),
SizedBox(height: hero ? WiseSpacing.x2 : 7),
Text(
report.oneLiner,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: YantingText.body.copyWith(
color: YantingColors.mutedForeground,
fontSize: hero ? null : 14,
),
),
],
const SizedBox(height: WiseSpacing.x3),
SizedBox(height: hero ? WiseSpacing.x3 : 10),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
@@ -96,6 +101,7 @@ class ReportCardWidget extends StatelessWidget {
label: '听研报',
icon: AppIcons.play,
kind: hero ? AppButtonKind.primary : AppButtonKind.accent,
compact: !hero,
onPressed: onPlayTap,
),
],
@@ -103,7 +109,11 @@ class ReportCardWidget extends StatelessWidget {
);
return hero
? HeroReportCard(onTap: onTap, child: child)
: AppCard(onTap: onTap, child: child);
: AppCard(
onTap: onTap,
padding: const EdgeInsets.all(16),
child: child,
);
}
}
+79 -16
View File
@@ -9,20 +9,40 @@ import '../theme/yanting_text.dart';
import '../widgets/bottom_tab_bar.dart';
import '../widgets/mini_player.dart';
class ShellPage extends ConsumerWidget {
class ShellPage extends ConsumerStatefulWidget {
const ShellPage({required this.child, required this.currentPath, super.key});
final Widget child;
final String currentPath;
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<ShellPage> createState() => _ShellPageState();
}
class _ShellPageState extends ConsumerState<ShellPage> {
static const double _compactHeaderThreshold = 34;
bool _showCompactHeader = false;
@override
void didUpdateWidget(covariant ShellPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.currentPath != widget.currentPath && _showCompactHeader) {
_showCompactHeader = false;
}
}
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final player = ref.watch(audioPlayerControllerProvider);
final controller = ref.read(audioPlayerControllerProvider.notifier);
final canPop = GoRouter.of(context).canPop();
final selectedIndex = _tabs.indexWhere((tab) => tab.path == currentPath);
final selectedIndex = _tabs.indexWhere(
(tab) => tab.path == widget.currentPath,
);
final safeIndex = selectedIndex < 0 ? 0 : selectedIndex;
final header = _headerForPath(widget.currentPath);
return Scaffold(
backgroundColor: theme.colorScheme.background,
@@ -30,30 +50,45 @@ class ShellPage extends ConsumerWidget {
backgroundColor: theme.colorScheme.background,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: true,
leading: canPop
? ShadIconButton.ghost(
onPressed: () => context.pop(),
icon: const Icon(LucideIcons.chevronLeft, size: 18),
)
: null,
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('研听', style: YantingText.listTitle),
Text('全球机构研报中文解读', style: YantingText.meta.copyWith(fontSize: 12)),
],
),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1),
child: ColoredBox(
color: theme.colorScheme.border,
child: const SizedBox(height: 1, width: double.infinity),
title: AnimatedOpacity(
opacity: _showCompactHeader ? 1 : 0,
duration: const Duration(milliseconds: 160),
curve: Curves.easeOut,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
header.title,
textAlign: TextAlign.center,
style: YantingText.listTitle,
),
if (header.subtitle.isNotEmpty)
Text(
header.subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: YantingText.meta.copyWith(fontSize: 12),
),
],
),
),
),
body: ColoredBox(
color: theme.colorScheme.background,
child: Stack(children: [Positioned.fill(child: child)]),
child: NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: Stack(children: [Positioned.fill(child: widget.child)]),
),
),
bottomNavigationBar: SafeArea(
top: false,
@@ -71,6 +106,34 @@ class ShellPage extends ConsumerWidget {
),
);
}
bool _handleScrollNotification(ScrollNotification notification) {
if (notification.metrics.axis != Axis.vertical) {
return false;
}
final next = notification.metrics.pixels > _compactHeaderThreshold;
if (next != _showCompactHeader && mounted) {
setState(() => _showCompactHeader = next);
}
return false;
}
}
_ShellHeader _headerForPath(String path) {
return switch (path) {
AppRoutes.reports => const _ShellHeader('研报', '全部已发布研报解读'),
AppRoutes.institutions => const _ShellHeader('机构', '可获取研报的机构'),
AppRoutes.listen => const _ShellHeader('听单', '已转音频的研报解读'),
AppRoutes.profile => const _ShellHeader('我的', ''),
_ => const _ShellHeader('研听', '全球机构研报中文解读'),
};
}
class _ShellHeader {
const _ShellHeader(this.title, this.subtitle);
final String title;
final String subtitle;
}
class _TabItem {