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/models/models.dart'; import '../../data/providers.dart'; import '../../data/state/app_interaction_state.dart'; import '../../theme/app_icons.dart'; import '../../theme/yanting_text.dart'; import '../../theme/yanting_tokens.dart'; import '../../widgets/app_buttons.dart'; import '../../widgets/app_card.dart'; import '../../widgets/badges.dart'; import '../../widgets/mini_player.dart'; import '../../widgets/sheets.dart'; import '../../widgets/states.dart'; import 'modules/renderer_registry.dart'; class ReportDetailPage extends HookConsumerWidget { const ReportDetailPage({ required this.reportId, required this.dataSource, this.player = const PlayerStateModel(), this.onStartAudio, this.onToggleAudio, this.onSeekAudio, this.onSpeed, super.key, }); final String reportId; final ReportDataSource dataSource; final PlayerStateModel player; final void Function( String audioId, String reportId, String title, int durationSec, )? onStartAudio; final VoidCallback? onToggleAudio; final void Function(int delta)? onSeekAudio; final VoidCallback? onSpeed; @override Widget build(BuildContext context, WidgetRef ref) { final retryCount = useState(0); final detailFuture = useMemoized(() => dataSource.reportDetail(reportId), [ dataSource, reportId, retryCount.value, ]); final snapshot = useFuture(detailFuture); const registry = ModuleRendererRegistry(); final theme = ShadTheme.of(context); return Scaffold( backgroundColor: theme.colorScheme.background, appBar: AppBar( backgroundColor: theme.colorScheme.background, surfaceTintColor: Colors.transparent, elevation: 0, title: const Text('研报详情'), bottom: PreferredSize( preferredSize: const Size.fromHeight(1), child: ColoredBox( color: theme.colorScheme.border, child: const SizedBox(height: 1, width: double.infinity), ), ), ), body: snapshot.connectionState != ConnectionState.done ? const LoadingState() : snapshot.hasError ? ErrorState( message: snapshot.error.toString(), onRetry: () => retryCount.value++, ) : _ReportDetailContent( detail: snapshot.data!, dataSource: dataSource, player: player, onStartAudio: onStartAudio, onToggleAudio: onToggleAudio, onSeekAudio: onSeekAudio, onSpeed: onSpeed, registry: registry, ), ); } } class _ReportDetailContent extends StatelessWidget { const _ReportDetailContent({ required this.detail, required this.dataSource, required this.player, required this.registry, this.onStartAudio, this.onToggleAudio, this.onSeekAudio, this.onSpeed, }); final ReportDetail detail; final ReportDataSource dataSource; final PlayerStateModel player; final ModuleRendererRegistry registry; final void Function( String audioId, String reportId, String title, int durationSec, )? onStartAudio; final VoidCallback? onToggleAudio; final void Function(int delta)? onSeekAudio; final VoidCallback? onSpeed; @override Widget build(BuildContext context) { final colors = ShadTheme.of(context).colorScheme; return ListView( padding: const EdgeInsets.fromLTRB( YantingSpacing.x4, 4, YantingSpacing.x4, 16, ), children: [ AppCard( color: colors.brandSoft, borderColor: colors.brandSoftBorder, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Wrap( spacing: YantingSpacing.x2, runSpacing: YantingSpacing.x2, children: [ AppBadge( text: detail.interpretationLabel, kind: BadgeKind.brand, ), if (detail.hasAudio) const AppBadge( text: '音频', icon: AppIcons.playCircle, kind: BadgeKind.audio, ), AppBadge( text: asString(detail.source['source_tier']), kind: BadgeKind.tier, ), ], ), const SizedBox(height: YantingSpacing.x3), Text( detail.titleCn, maxLines: 3, overflow: TextOverflow.ellipsis, style: YantingText.sectionTitle.copyWith(fontSize: 21), ), if (detail.oneLiner.isNotEmpty) ...[ const SizedBox(height: YantingSpacing.x2), Text(detail.oneLiner, style: YantingText.body), ], const SizedBox(height: YantingSpacing.x3), Text( '${detail.institution.nameCn} · ${formatDate(detail.releasedAt)}', style: YantingText.meta, ), ], ), ), const SizedBox(height: YantingSpacing.x4), _ActionBar(detail: detail), const SizedBox(height: YantingSpacing.x4), _Toc(modules: detail.modules), const SizedBox(height: YantingSpacing.x4), for (final module in detail.modules) ...[ registry.card( context: context, module: module, report: detail, dataSource: dataSource, player: player, onStartAudio: onStartAudio, onToggleAudio: onToggleAudio, onSeekAudio: onSeekAudio, onSpeed: onSpeed, ), const SizedBox(height: YantingSpacing.x4), ], ], ); } } class _ActionBar extends ConsumerWidget { const _ActionBar({required this.detail}); final ReportDetail detail; @override Widget build(BuildContext context, WidgetRef ref) { final auth = ref.watch(authControllerProvider); final profile = ref.watch(profileControllerProvider); final isFavorite = profile.favorites.contains(detail.id); final isSavedListen = profile.savedListens.contains(detail.id); return Row( children: [ Expanded( child: AppButton( label: isFavorite ? '已收藏' : '收藏', icon: isFavorite ? AppIcons.heartFill : AppIcons.heart, kind: AppButtonKind.ghost, onPressed: () => _runLoginRequiredAction( context, ref, auth, PendingLoginAction( action: LoginRequiredAction.favorite, reportId: detail.id, contextText: '登录后保存到你的收藏', ), ), ), ), const SizedBox(width: YantingSpacing.x2), if (detail.hasAudio) ...[ Expanded( child: AppButton( label: isSavedListen ? '已存听单' : '听单', icon: isSavedListen ? AppIcons.headphonesFill : AppIcons.headphones, kind: AppButtonKind.ghost, onPressed: () => _runLoginRequiredAction( context, ref, auth, PendingLoginAction( action: LoginRequiredAction.saveListen, reportId: detail.id, contextText: '登录后保存到你的听单', ), ), ), ), const SizedBox(width: YantingSpacing.x2), ], Expanded( child: AppButton( label: '原文', icon: AppIcons.externalLink, kind: AppButtonKind.ghost, onPressed: () => _showSourceSheet(context, ref), ), ), ], ); } void _runLoginRequiredAction( BuildContext context, WidgetRef ref, AuthState auth, PendingLoginAction action, ) { if (auth.loggedIn) { _applyPendingAction(ref, action); return; } ref.read(authControllerProvider.notifier).requireLogin(action); showLoginSheet( context, reason: action.contextText, onPhoneLogin: () => _loginAndApply(ref, LoginMethod.phone), onSecondaryLogin: () => _loginAndApply(ref, LoginMethod.wechat), ); } void _loginAndApply(WidgetRef ref, LoginMethod method) { ref.read(authControllerProvider.notifier).login(method).then((pending) { if (pending != null) { _applyPendingAction(ref, pending); } }); } void _applyPendingAction(WidgetRef ref, PendingLoginAction action) { final controller = ref.read(profileControllerProvider.notifier); switch (action.action) { case LoginRequiredAction.favorite: controller.toggleFavorite(action.reportId); case LoginRequiredAction.saveListen: controller.toggleSavedListen(action.reportId); } } void _showSourceSheet(BuildContext context, WidgetRef ref) { final targetUrl = asString( detail.source['url'], asString( detail.source['source_url'], asString(detail.source['original_url']), ), ); showOutboundSheet( context, title: detail.titleCn, onConfirm: () => ref .read(outboundRepositoryProvider) .recordOutbound( OutboundEvent( scene: 'report_source', refId: detail.id, targetUrl: targetUrl.isEmpty ? null : targetUrl, ), ), ); } } class _Toc extends StatelessWidget { const _Toc({required this.modules}); final List modules; @override Widget build(BuildContext context) { if (modules.isEmpty) return const SizedBox.shrink(); return SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: [ for (final module in modules) Padding( padding: const EdgeInsets.only(right: YantingSpacing.x2), child: AppBadge(text: module.titleCn, kind: BadgeKind.brand), ), ], ), ); } }