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/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../data/api/report_data_source.dart'; import '../../data/api/report_data_source.dart';
import '../../data/content_providers.dart'; import '../../data/content_providers.dart';
@@ -90,9 +89,7 @@ class FeedPage extends HookConsumerWidget {
], ],
), ),
), ),
const SizedBox(height: YantingSpacing.x3), const SizedBox(height: YantingSpacing.cardGap),
const ShadSeparator.horizontal(),
const SizedBox(height: YantingSpacing.x3),
if (visible.isEmpty) if (visible.isEmpty)
const EmptyState( const EmptyState(
title: '暂无可推荐的研报解读', title: '暂无可推荐的研报解读',
@@ -115,7 +112,7 @@ class FeedPage extends HookConsumerWidget {
), ),
onPlayTap: () => _playFromReport(onPlay, visible.first), onPlayTap: () => _playFromReport(onPlay, visible.first),
), ),
const SizedBox(height: YantingSpacing.x6), const SizedBox(height: YantingSpacing.sectionGap),
const SectionTitle(title: '最新解读', icon: Icons.chevron_right), const SectionTitle(title: '最新解读', icon: Icons.chevron_right),
for (final report in visible.skip(1)) ...[ for (final report in visible.skip(1)) ...[
ReportCardWidget( ReportCardWidget(
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../data/api/report_data_source.dart'; import '../../data/api/report_data_source.dart';
import '../../data/content_providers.dart'; import '../../data/content_providers.dart';
@@ -43,9 +42,7 @@ class InstitutionsPage extends HookConsumerWidget {
), ),
children: [ children: [
const PageHeader(title: '机构', subtitle: '可获取研报的机构'), const PageHeader(title: '机构', subtitle: '可获取研报的机构'),
const SizedBox(height: YantingSpacing.x3), const SizedBox(height: YantingSpacing.cardGap),
const ShadSeparator.horizontal(),
const SizedBox(height: YantingSpacing.x3),
for (final item in sorted) ...[ for (final item in sorted) ...[
InstitutionCard( InstitutionCard(
institution: item, institution: item,
+35 -14
View File
@@ -52,8 +52,7 @@ class ListenPage extends HookConsumerWidget {
onPlay: () => onPlay(current), onPlay: () => onPlay(current),
), ),
const SectionTitle(title: '全部音频', icon: AppIcons.arrowRight), const SectionTitle(title: '全部音频', icon: AppIcons.arrowRight),
const ShadSeparator.horizontal(), const SizedBox(height: YantingSpacing.cardGap),
const SizedBox(height: YantingSpacing.x3),
for (final item in items.skip(1)) ...[ for (final item in items.skip(1)) ...[
_AudioListCard(item: item, onPlay: () => onPlay(item)), _AudioListCard(item: item, onPlay: () => onPlay(item)),
const SizedBox(height: YantingSpacing.x3), const SizedBox(height: YantingSpacing.x3),
@@ -115,12 +114,7 @@ class _ContinueListeningCard extends StatelessWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(
children: [ children: [
ShadButton( _PlayControlButton(onPressed: onPlay, size: 54, iconSize: 22),
onPressed: onPlay,
width: 48,
height: 48,
child: const Icon(AppIcons.play, size: 18),
),
const SizedBox(width: 13), const SizedBox(width: 13),
Expanded( Expanded(
child: Column( child: Column(
@@ -197,14 +191,41 @@ class _AudioListCard extends StatelessWidget {
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
ShadButton( _PlayControlButton(onPressed: onPlay, size: 44, iconSize: 18),
onPressed: onPlay,
width: 44,
height: 44,
child: const Icon(AppIcons.play, size: 16),
),
], ],
), ),
); );
} }
} }
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) { Widget build(BuildContext context) {
return AppCard( return AppCard(
padding: EdgeInsets.zero, 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,
),
],
],
),
); );
} }
} }
+24 -11
View File
@@ -99,8 +99,12 @@ class ReportsPage extends HookConsumerWidget {
), ),
onChanged: (value) => query.value = value.trim(), onChanged: (value) => query.value = value.trim(),
), ),
const SizedBox(height: YantingSpacing.x3), const SizedBox(height: YantingSpacing.cardGap),
Wrap( Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Wrap(
spacing: YantingSpacing.x2, spacing: YantingSpacing.x2,
runSpacing: YantingSpacing.x2, runSpacing: YantingSpacing.x2,
children: [ children: [
@@ -120,7 +124,10 @@ class ReportsPage extends HookConsumerWidget {
), ),
ShadButton.outline( ShadButton.outline(
onPressed: () {}, onPressed: () {},
leading: const Icon(LucideIcons.arrowUpDown, size: 16), leading: const Icon(
LucideIcons.arrowUpDown,
size: 16,
),
child: const Text('最新'), child: const Text('最新'),
), ),
ShadBadge.secondary( ShadBadge.secondary(
@@ -132,20 +139,26 @@ class ReportsPage extends HookConsumerWidget {
? theme.colorScheme.background ? theme.colorScheme.background
: theme.colorScheme.secondaryForeground, : theme.colorScheme.secondaryForeground,
hoverBackgroundColor: currentHasAudio hoverBackgroundColor: currentHasAudio
? theme.colorScheme.foreground.withValues(alpha: 0.9) ? theme.colorScheme.foreground.withValues(
alpha: 0.9,
)
: theme.colorScheme.border, : theme.colorScheme.border,
child: const Text('音频'), child: const Text('音频'),
), ),
], ],
), ),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: Text('${filtered.length}', style: YantingText.meta),
), ),
const SizedBox(height: YantingSpacing.x3), const SizedBox(width: YantingSpacing.x2),
const ShadSeparator.horizontal(), Padding(
const SizedBox(height: YantingSpacing.x3), padding: const EdgeInsets.only(top: 10),
child: Text(
'${filtered.length}',
style: YantingText.meta,
),
),
],
),
const SizedBox(height: YantingSpacing.cardGap),
if (filtered.isEmpty) if (filtered.isEmpty)
EmptyState( EmptyState(
title: currentQuery.isNotEmpty ? '未找到相关研报' : '当前筛选下暂无研报', title: currentQuery.isNotEmpty ? '未找到相关研报' : '当前筛选下暂无研报',
+17 -7
View File
@@ -31,8 +31,8 @@ class ReportCardWidget extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Wrap( Wrap(
spacing: WiseSpacing.x2, spacing: hero ? WiseSpacing.x2 : 7,
runSpacing: WiseSpacing.x2, runSpacing: hero ? WiseSpacing.x2 : 7,
children: [ children: [
AppBadge(text: report.interpretationLabel, kind: BadgeKind.brand), AppBadge(text: report.interpretationLabel, kind: BadgeKind.brand),
if (report.hasAudio) if (report.hasAudio)
@@ -46,27 +46,32 @@ class ReportCardWidget extends StatelessWidget {
for (final topic in report.topics.take(3)) AppBadge(text: topic), for (final topic in report.topics.take(3)) AppBadge(text: topic),
], ],
), ),
const SizedBox(height: WiseSpacing.x3), SizedBox(height: hero ? WiseSpacing.x3 : 10),
Text( Text(
report.titleCn, report.titleCn,
maxLines: hero ? 3 : 2, maxLines: hero ? 3 : 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: hero style: hero
? YantingText.sectionTitle.copyWith(fontSize: 21, height: 1.4) ? 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) ...[ if (report.oneLiner.isNotEmpty) ...[
const SizedBox(height: WiseSpacing.x2), SizedBox(height: hero ? WiseSpacing.x2 : 7),
Text( Text(
report.oneLiner, report.oneLiner,
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: YantingText.body.copyWith( style: YantingText.body.copyWith(
color: YantingColors.mutedForeground, color: YantingColors.mutedForeground,
fontSize: hero ? null : 14,
), ),
), ),
], ],
const SizedBox(height: WiseSpacing.x3), SizedBox(height: hero ? WiseSpacing.x3 : 10),
Wrap( Wrap(
crossAxisAlignment: WrapCrossAlignment.center, crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8, spacing: 8,
@@ -96,6 +101,7 @@ class ReportCardWidget extends StatelessWidget {
label: '听研报', label: '听研报',
icon: AppIcons.play, icon: AppIcons.play,
kind: hero ? AppButtonKind.primary : AppButtonKind.accent, kind: hero ? AppButtonKind.primary : AppButtonKind.accent,
compact: !hero,
onPressed: onPlayTap, onPressed: onPlayTap,
), ),
], ],
@@ -103,7 +109,11 @@ class ReportCardWidget extends StatelessWidget {
); );
return hero return hero
? HeroReportCard(onTap: onTap, child: child) ? HeroReportCard(onTap: onTap, child: child)
: AppCard(onTap: onTap, child: child); : AppCard(
onTap: onTap,
padding: const EdgeInsets.all(16),
child: child,
);
} }
} }
+77 -14
View File
@@ -9,20 +9,40 @@ import '../theme/yanting_text.dart';
import '../widgets/bottom_tab_bar.dart'; import '../widgets/bottom_tab_bar.dart';
import '../widgets/mini_player.dart'; import '../widgets/mini_player.dart';
class ShellPage extends ConsumerWidget { class ShellPage extends ConsumerStatefulWidget {
const ShellPage({required this.child, required this.currentPath, super.key}); const ShellPage({required this.child, required this.currentPath, super.key});
final Widget child; final Widget child;
final String currentPath; final String currentPath;
@override @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 theme = ShadTheme.of(context);
final player = ref.watch(audioPlayerControllerProvider); final player = ref.watch(audioPlayerControllerProvider);
final controller = ref.read(audioPlayerControllerProvider.notifier); final controller = ref.read(audioPlayerControllerProvider.notifier);
final canPop = GoRouter.of(context).canPop(); 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 safeIndex = selectedIndex < 0 ? 0 : selectedIndex;
final header = _headerForPath(widget.currentPath);
return Scaffold( return Scaffold(
backgroundColor: theme.colorScheme.background, backgroundColor: theme.colorScheme.background,
@@ -30,30 +50,45 @@ class ShellPage extends ConsumerWidget {
backgroundColor: theme.colorScheme.background, backgroundColor: theme.colorScheme.background,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
elevation: 0, elevation: 0,
scrolledUnderElevation: 0,
centerTitle: true,
leading: canPop leading: canPop
? ShadIconButton.ghost( ? ShadIconButton.ghost(
onPressed: () => context.pop(), onPressed: () => context.pop(),
icon: const Icon(LucideIcons.chevronLeft, size: 18), icon: const Icon(LucideIcons.chevronLeft, size: 18),
) )
: null, : null,
title: Column( title: AnimatedOpacity(
crossAxisAlignment: CrossAxisAlignment.start, opacity: _showCompactHeader ? 1 : 0,
duration: const Duration(milliseconds: 160),
curve: Curves.easeOut,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Text('研听', style: YantingText.listTitle), Text(
Text('全球机构研报中文解读', style: YantingText.meta.copyWith(fontSize: 12)), header.title,
], textAlign: TextAlign.center,
style: YantingText.listTitle,
), ),
bottom: PreferredSize( if (header.subtitle.isNotEmpty)
preferredSize: const Size.fromHeight(1), Text(
child: ColoredBox( header.subtitle,
color: theme.colorScheme.border, maxLines: 1,
child: const SizedBox(height: 1, width: double.infinity), overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: YantingText.meta.copyWith(fontSize: 12),
),
],
), ),
), ),
), ),
body: ColoredBox( body: ColoredBox(
color: theme.colorScheme.background, 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( bottomNavigationBar: SafeArea(
top: false, 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 { class _TabItem {
+41 -35
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:shadcn_ui/shadcn_ui.dart';
import '../theme/yanting_text.dart';
import '../theme/yanting_tokens.dart'; import '../theme/yanting_tokens.dart';
class AppButton extends StatelessWidget { class AppButton extends StatelessWidget {
@@ -10,6 +11,7 @@ class AppButton extends StatelessWidget {
this.icon, this.icon,
this.kind = AppButtonKind.primary, this.kind = AppButtonKind.primary,
this.expand = false, this.expand = false,
this.compact = false,
super.key, super.key,
}); });
@@ -18,45 +20,49 @@ class AppButton extends StatelessWidget {
final IconData? icon; final IconData? icon;
final AppButtonKind kind; final AppButtonKind kind;
final bool expand; final bool expand;
final bool compact;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final leading = icon == null ? null : Icon(icon, size: 16); final variant = switch (kind) {
final width = expand ? double.infinity : null; AppButtonKind.primary => ShadButtonVariant.primary,
final child = Text(label); AppButtonKind.dark => ShadButtonVariant.primary,
AppButtonKind.accent => ShadButtonVariant.secondary,
return switch (kind) { AppButtonKind.ghost => ShadButtonVariant.outline,
AppButtonKind.primary => ShadButton(
width: width,
onPressed: onPressed,
leading: leading,
child: child,
),
AppButtonKind.dark => ShadButton(
width: width,
onPressed: onPressed,
leading: leading,
backgroundColor: YantingColors.foreground,
foregroundColor: YantingColors.background,
hoverBackgroundColor: YantingColors.foreground.withValues(alpha: 0.9),
child: child,
),
AppButtonKind.accent => ShadButton.secondary(
width: width,
onPressed: onPressed,
leading: leading,
backgroundColor: YantingColors.brandSoft,
foregroundColor: YantingColors.primaryForeground,
hoverBackgroundColor: YantingColors.brandSoftBorder,
child: child,
),
AppButtonKind.ghost => ShadButton.outline(
width: width,
onPressed: onPressed,
leading: leading,
child: child,
),
}; };
final colors = switch (kind) {
AppButtonKind.primary => (null, null, null),
AppButtonKind.dark => (
YantingColors.foreground,
YantingColors.background,
YantingColors.foreground.withValues(alpha: 0.9),
),
AppButtonKind.accent => (
YantingColors.brandSoft,
YantingColors.primaryForeground,
YantingColors.brandSoftBorder,
),
AppButtonKind.ghost => (null, null, null),
};
final button = ShadButton.raw(
variant: variant,
enabled: onPressed != null,
onPressed: onPressed,
width: expand ? double.infinity : null,
height: compact ? 36 : 44,
padding: EdgeInsets.symmetric(horizontal: compact ? 16 : 20),
backgroundColor: colors.$1,
foregroundColor: colors.$2,
hoverBackgroundColor: colors.$3,
leading: icon == null ? null : Icon(icon, size: compact ? 15 : 16),
gap: compact ? 5 : 7,
textStyle: (compact ? YantingText.badge : YantingText.body).copyWith(
color: colors.$2,
fontWeight: FontWeight.w600,
),
child: Text(label),
);
return expand ? SizedBox(width: double.infinity, child: button) : button;
} }
} }
+16 -15
View File
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../theme/yanting_tokens.dart'; import '../theme/yanting_tokens.dart';
@@ -21,29 +20,31 @@ class AppCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final radius = BorderRadius.circular(YantingRadius.xl); final radius = BorderRadius.circular(YantingRadius.xl);
final content = ShadCard( final decoration = BoxDecoration(
padding: padding, color: color,
backgroundColor: color, borderRadius: radius,
radius: radius, border: Border.all(color: borderColor),
border: ShadBorder.all(color: borderColor),
shadows: const [],
child: child,
); );
if (onTap == null) return content; if (onTap == null) {
return DecoratedBox(
decoration: decoration,
child: Padding(padding: padding, child: child),
);
}
return Material( return Material(
color: Colors.transparent, color: Colors.transparent,
borderRadius: radius, borderRadius: radius,
child: Ink(
decoration: decoration,
child: InkWell( child: InkWell(
borderRadius: radius, borderRadius: radius,
splashColor: theme.colorScheme.mutedForeground.withValues(alpha: 0.08), splashColor: YantingColors.mutedForeground.withValues(alpha: 0.08),
highlightColor: theme.colorScheme.mutedForeground.withValues( highlightColor: YantingColors.mutedForeground.withValues(alpha: 0.04),
alpha: 0.04,
),
onTap: onTap, onTap: onTap,
child: content, child: Padding(padding: padding, child: child),
),
), ),
); );
} }
+8 -8
View File
@@ -23,14 +23,14 @@ class InstitutionCard extends StatelessWidget {
: institution.nameCn.characters.take(2).toString(); : institution.nameCn.characters.take(2).toString();
return AppCard( return AppCard(
onTap: onTap, onTap: onTap,
padding: const EdgeInsets.all(16), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
InstitutionLogo( InstitutionLogo(
logoUrl: institution.logoUrl, logoUrl: institution.logoUrl,
initials: initials, initials: initials,
size: 52, size: 48,
), ),
const SizedBox(width: 14), const SizedBox(width: 14),
Expanded( Expanded(
@@ -54,10 +54,10 @@ class InstitutionCard extends StatelessWidget {
style: YantingText.meta, style: YantingText.meta,
), ),
], ],
const SizedBox(height: 11), const SizedBox(height: 8),
Wrap( Wrap(
spacing: 8, spacing: 7,
runSpacing: 8, runSpacing: 7,
children: [ children: [
if (institution.institutionType.isNotEmpty) if (institution.institutionType.isNotEmpty)
AppBadge( AppBadge(
@@ -71,14 +71,14 @@ class InstitutionCard extends StatelessWidget {
], ],
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 10),
Column( Column(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Text( Text(
'${institution.reportCount}', '${institution.reportCount}',
style: YantingText.sectionTitle.copyWith( style: YantingText.sectionTitle.copyWith(
fontSize: 21, fontSize: 20,
fontFeatures: YantingTypographyFeatures.tabularNums, fontFeatures: YantingTypographyFeatures.tabularNums,
), ),
), ),