diff --git a/lib/features/feed/feed_page.dart b/lib/features/feed/feed_page.dart index a6fc0de..aa2f95f 100644 --- a/lib/features/feed/feed_page.dart +++ b/lib/features/feed/feed_page.dart @@ -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( diff --git a/lib/features/institutions/institutions_page.dart b/lib/features/institutions/institutions_page.dart index 9a5b97c..0977d39 100644 --- a/lib/features/institutions/institutions_page.dart +++ b/lib/features/institutions/institutions_page.dart @@ -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, diff --git a/lib/features/listen/listen_page.dart b/lib/features/listen/listen_page.dart index 9cbf472..6316911 100644 --- a/lib/features/listen/listen_page.dart +++ b/lib/features/listen/listen_page.dart @@ -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, + ), + ), + ), + ); + } +} diff --git a/lib/features/profile/profile_page.dart b/lib/features/profile/profile_page.dart index e3d66dc..8f54bbb 100644 --- a/lib/features/profile/profile_page.dart +++ b/lib/features/profile/profile_page.dart @@ -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, + ), + ], + ], + ), ); } } diff --git a/lib/features/reports/reports_page.dart b/lib/features/reports/reports_page.dart index 84c0bfc..075d06a 100644 --- a/lib/features/reports/reports_page.dart +++ b/lib/features/reports/reports_page.dart @@ -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 ? '未找到相关研报' : '当前筛选下暂无研报', diff --git a/lib/features/shared/report_card_widget.dart b/lib/features/shared/report_card_widget.dart index 5e8962e..a90f48e 100644 --- a/lib/features/shared/report_card_widget.dart +++ b/lib/features/shared/report_card_widget.dart @@ -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, + ); } } diff --git a/lib/features/shell_page.dart b/lib/features/shell_page.dart index f806ad1..4be9a46 100644 --- a/lib/features/shell_page.dart +++ b/lib/features/shell_page.dart @@ -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 createState() => _ShellPageState(); +} + +class _ShellPageState extends ConsumerState { + 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( + 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 { diff --git a/lib/widgets/app_buttons.dart b/lib/widgets/app_buttons.dart index 70d4d36..8862f30 100644 --- a/lib/widgets/app_buttons.dart +++ b/lib/widgets/app_buttons.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import '../theme/yanting_text.dart'; import '../theme/yanting_tokens.dart'; class AppButton extends StatelessWidget { @@ -10,6 +11,7 @@ class AppButton extends StatelessWidget { this.icon, this.kind = AppButtonKind.primary, this.expand = false, + this.compact = false, super.key, }); @@ -18,45 +20,49 @@ class AppButton extends StatelessWidget { final IconData? icon; final AppButtonKind kind; final bool expand; + final bool compact; @override Widget build(BuildContext context) { - final leading = icon == null ? null : Icon(icon, size: 16); - final width = expand ? double.infinity : null; - final child = Text(label); - - return switch (kind) { - 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 variant = switch (kind) { + AppButtonKind.primary => ShadButtonVariant.primary, + AppButtonKind.dark => ShadButtonVariant.primary, + AppButtonKind.accent => ShadButtonVariant.secondary, + AppButtonKind.ghost => ShadButtonVariant.outline, }; + 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; } } diff --git a/lib/widgets/app_card.dart b/lib/widgets/app_card.dart index bd7ca0d..ab274e4 100644 --- a/lib/widgets/app_card.dart +++ b/lib/widgets/app_card.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:shadcn_ui/shadcn_ui.dart'; import '../theme/yanting_tokens.dart'; @@ -21,29 +20,31 @@ class AppCard extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = ShadTheme.of(context); final radius = BorderRadius.circular(YantingRadius.xl); - final content = ShadCard( - padding: padding, - backgroundColor: color, - radius: radius, - border: ShadBorder.all(color: borderColor), - shadows: const [], - child: child, + final decoration = BoxDecoration( + color: color, + borderRadius: radius, + border: Border.all(color: borderColor), ); - if (onTap == null) return content; + if (onTap == null) { + return DecoratedBox( + decoration: decoration, + child: Padding(padding: padding, child: child), + ); + } return Material( color: Colors.transparent, borderRadius: radius, - child: InkWell( - borderRadius: radius, - splashColor: theme.colorScheme.mutedForeground.withValues(alpha: 0.08), - highlightColor: theme.colorScheme.mutedForeground.withValues( - alpha: 0.04, + child: Ink( + decoration: decoration, + child: InkWell( + borderRadius: radius, + splashColor: YantingColors.mutedForeground.withValues(alpha: 0.08), + highlightColor: YantingColors.mutedForeground.withValues(alpha: 0.04), + onTap: onTap, + child: Padding(padding: padding, child: child), ), - onTap: onTap, - child: content, ), ); } diff --git a/lib/widgets/institution_card.dart b/lib/widgets/institution_card.dart index c9f29d8..cfaaafb 100644 --- a/lib/widgets/institution_card.dart +++ b/lib/widgets/institution_card.dart @@ -23,14 +23,14 @@ class InstitutionCard extends StatelessWidget { : institution.nameCn.characters.take(2).toString(); return AppCard( onTap: onTap, - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ InstitutionLogo( logoUrl: institution.logoUrl, initials: initials, - size: 52, + size: 48, ), const SizedBox(width: 14), Expanded( @@ -54,10 +54,10 @@ class InstitutionCard extends StatelessWidget { style: YantingText.meta, ), ], - const SizedBox(height: 11), + const SizedBox(height: 8), Wrap( - spacing: 8, - runSpacing: 8, + spacing: 7, + runSpacing: 7, children: [ if (institution.institutionType.isNotEmpty) AppBadge( @@ -71,14 +71,14 @@ class InstitutionCard extends StatelessWidget { ], ), ), - const SizedBox(width: 8), + const SizedBox(width: 10), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( '${institution.reportCount}', style: YantingText.sectionTitle.copyWith( - fontSize: 21, + fontSize: 20, fontFeatures: YantingTypographyFeatures.tabularNums, ), ),