import 'package:flutter/material.dart'; import '../../../data/api/report_data_source.dart'; import '../../../data/models/models.dart'; import '../../../theme/wise_tokens.dart'; import '../../../widgets/app_card.dart'; import '../../../widgets/badges.dart'; import '../../../widgets/mini_player.dart'; typedef StartModuleAudio = void Function( String audioId, String reportId, String title, int durationSec, ); class ModuleRendererRegistry { const ModuleRendererRegistry(); Widget card({ required BuildContext context, required DisplayModule module, required ReportDetail report, required ReportDataSource dataSource, required PlayerStateModel player, StartModuleAudio? onStartAudio, VoidCallback? onToggleAudio, void Function(int delta)? onSeekAudio, VoidCallback? onSpeed, }) { final openDetail = module.hasDetailPage ? () => Navigator.of(context).push( MaterialPageRoute( builder: (_) => ModuleDetailPage( reportId: report.id, module: module, report: report, dataSource: dataSource, registry: this, ), ), ) : null; return AppCard( onTap: openDetail, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _ModuleHeader(module: module), const SizedBox(height: WiseSpacing.x4), _contentFor( context, type: module.type, payload: module.renderMode == 'inline' ? module.content : module.preview, report: report, player: player, onStartAudio: onStartAudio, onToggleAudio: onToggleAudio, onSeekAudio: onSeekAudio, onSpeed: onSpeed, compact: module.renderMode != 'inline', ), if (module.hasDetailPage) ...[ const SizedBox(height: WiseSpacing.x4), Align( alignment: Alignment.centerLeft, child: TextButton.icon( onPressed: openDetail, icon: const Icon(Icons.open_in_new), label: const Text('查看详情'), ), ), ], ], ), ); } Widget page( BuildContext context, String type, JsonMap payload, { ReportDetail? report, }) { return _contentFor( context, type: type, payload: payload, report: report, player: const PlayerStateModel(), compact: false, ); } Widget _contentFor( BuildContext context, { required String type, required JsonMap payload, required PlayerStateModel player, ReportDetail? report, StartModuleAudio? onStartAudio, VoidCallback? onToggleAudio, void Function(int delta)? onSeekAudio, VoidCallback? onSpeed, bool compact = false, }) { return switch (type) { 'basic_info' => _BasicInfo(payload: payload, report: report), 'core_insights' => _CoreInsights(payload: payload), 'source_compliance' => _SourceCompliance( payload: payload, report: report, ), 'audio' => _AudioModule( payload: payload, report: report, player: player, onStartAudio: onStartAudio, onToggleAudio: onToggleAudio, onSeekAudio: onSeekAudio, onSpeed: onSpeed, ), 'institution' => _InstitutionModule(payload: payload, report: report), 'executive_overview' => _SectionsModule( payload: payload, compact: compact, ), 'key_data' => _KeyDataModule(payload: payload, compact: compact), 'timeline' => _TimelineModule(payload: payload, compact: compact), 'study_guide' => _StudyGuideModule(payload: payload, compact: compact), 'structure_graph' => _StructureGraphModule( payload: payload, compact: compact, ), 'related_sources' => _RelatedSourcesModule( payload: payload, compact: compact, ), 'differentiated_view' => _DifferentiatedViewModule( payload: payload, compact: compact, ), 'weaknesses' => _WeaknessesModule(payload: payload, compact: compact), 'infographic' => _FallbackModule(type: '信息图', payload: payload), 'research_discovery' => _FallbackModule(type: '延伸研究', payload: payload), _ => _FallbackModule(type: type, payload: payload), }; } } class ModuleDetailPage extends StatefulWidget { const ModuleDetailPage({ required this.reportId, required this.module, required this.report, required this.dataSource, required this.registry, super.key, }); final String reportId; final DisplayModule module; final ReportDetail report; final ReportDataSource dataSource; final ModuleRendererRegistry registry; @override State createState() => _ModuleDetailPageState(); } class _ModuleDetailPageState extends State { late Future future = widget.dataSource.moduleDetail( widget.reportId, widget.module.id, ); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text(widget.module.titleCn)), body: FutureBuilder( future: future, builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { return Center( child: Text( snapshot.error.toString(), textAlign: TextAlign.center, ), ); } final detail = snapshot.data!; return ListView( padding: const EdgeInsets.all(WiseSpacing.x4), children: [ AppCard( child: widget.registry.page( context, detail.type, detail.content, report: widget.report, ), ), const SizedBox(height: WiseSpacing.x3), Text( '缓存版本 ${detail.cacheVersion}', style: Theme.of(context).textTheme.bodySmall, ), ], ); }, ), ); } } class _ModuleHeader extends StatelessWidget { const _ModuleHeader({required this.module}); final DisplayModule module; @override Widget build(BuildContext context) { return Row( children: [ Expanded( child: Text( module.titleCn, style: Theme.of(context).textTheme.titleMedium, ), ), if (module.layer.isNotEmpty) AppBadge(text: module.layer.toUpperCase(), kind: BadgeKind.brand), ], ); } } class _BasicInfo extends StatelessWidget { const _BasicInfo({required this.payload, required this.report}); final JsonMap payload; final ReportDetail? report; @override Widget build(BuildContext context) { final topics = asStringList(payload['topics']).isEmpty ? report?.topics ?? const [] : asStringList(payload['topics']); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( asString( payload['summary_cn'], asString(payload['scope_cn'], report?.oneLiner ?? ''), ), style: Theme.of(context).textTheme.bodyMedium, ), const SizedBox(height: WiseSpacing.x2), Wrap( spacing: WiseSpacing.x2, runSpacing: WiseSpacing.x2, children: [ for (final topic in topics) AppBadge(text: topic), if (report?.releasedAt != null) AppBadge(text: formatDate(report!.releasedAt)), ], ), ], ); } } class _CoreInsights extends StatelessWidget { const _CoreInsights({required this.payload}); final JsonMap payload; @override Widget build(BuildContext context) { final points = asMapList(payload['points']); if (points.isEmpty) return _TextLines(payload: payload); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final point in points) Padding( padding: const EdgeInsets.only(bottom: WiseSpacing.x3), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ AppBadge( text: _kindLabel(asString(point['kind'])), kind: _kindBadge(asString(point['kind'])), ), const SizedBox(height: WiseSpacing.x1), Text( asString(point['text']), style: Theme.of(context).textTheme.bodyMedium, ), ], ), ), ], ); } } class _SourceCompliance extends StatelessWidget { const _SourceCompliance({required this.payload, required this.report}); final JsonMap payload; final ReportDetail? report; @override Widget build(BuildContext context) { final institution = report?.institution; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (asString(payload['source_note']).isNotEmpty) Text( asString(payload['source_note']), style: Theme.of(context).textTheme.bodyMedium, ), if (institution != null) ...[ const SizedBox(height: WiseSpacing.x4), Text('发布机构', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: WiseSpacing.x2), _InfoLine(label: '机构名称', value: institution.nameCn), if (institution.nameEn.isNotEmpty) _InfoLine(label: '英文名称', value: institution.nameEn), if (institution.institutionType.isNotEmpty) _InfoLine( label: '机构类型', value: _institutionTypeLabel(institution.institutionType), ), if (institution.sourceTier.isNotEmpty) _InfoLine(label: '来源层级', value: institution.sourceTier), if (institution.reportCount > 0) _InfoLine(label: '收录报告', value: '${institution.reportCount} 份'), if (institution.coveredTopics.isNotEmpty) _InfoLine( label: '覆盖主题', value: institution.coveredTopics.join('、'), ), if (institution.websiteUrl.isNotEmpty) _InfoLine(label: '官网', value: institution.websiteUrl), if (institution.introCn.isNotEmpty) _InfoLine(label: '说明', value: institution.introCn), ], if (asString(payload['copyright_cn']).isNotEmpty) ...[ const SizedBox(height: WiseSpacing.x4), Text( asString(payload['copyright_cn']), style: Theme.of(context).textTheme.bodySmall, ), ], const SizedBox(height: WiseSpacing.x3), DecoratedBox( decoration: BoxDecoration( color: const Color(0x109A6500), borderRadius: BorderRadius.circular(WiseRadius.sm), ), child: Padding( padding: const EdgeInsets.all(WiseSpacing.x3), child: Text( asString(payload['disclaimer'], '本内容为公开/授权研报的结构化解读,不构成投资建议。'), style: Theme.of( context, ).textTheme.bodySmall?.copyWith(color: WiseColors.warning), ), ), ), ], ); } } class _InfoLine extends StatelessWidget { const _InfoLine({required this.label, required this.value}); final String label; final String value; @override Widget build(BuildContext context) { if (value.isEmpty) return const SizedBox.shrink(); return Padding( padding: const EdgeInsets.only(bottom: WiseSpacing.x2), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( label, style: Theme.of( context, ).textTheme.labelSmall?.copyWith(color: WiseColors.ink700), ), const SizedBox(height: WiseSpacing.x1), Text(value, style: Theme.of(context).textTheme.bodyMedium), ], ), ); } } class _AudioModule extends StatelessWidget { const _AudioModule({ required this.payload, required this.report, required this.player, this.onStartAudio, this.onToggleAudio, this.onSeekAudio, this.onSpeed, }); final JsonMap payload; final ReportDetail? report; final PlayerStateModel player; final StartModuleAudio? onStartAudio; final VoidCallback? onToggleAudio; final void Function(int delta)? onSeekAudio; final VoidCallback? onSpeed; @override Widget build(BuildContext context) { final title = asString(payload['title_cn'], report?.titleCn ?? '音频解读'); final audioId = asString( payload['audio_id'], 'local_${report?.id ?? title.hashCode}', ); final duration = asInt(payload['duration_sec'], 180); return PlayerCard( title: title, durationSec: duration, player: player, onStart: () => onStartAudio?.call(audioId, report?.id ?? '', title, duration), onToggle: onToggleAudio ?? () {}, onSeek: onSeekAudio ?? (_) {}, onSpeed: onSpeed ?? () {}, ); } } class _InstitutionModule extends StatelessWidget { const _InstitutionModule({required this.payload, required this.report}); final JsonMap payload; final ReportDetail? report; @override Widget build(BuildContext context) { final name = asString(payload['name_cn'], report?.institution.nameCn ?? ''); return Row( children: [ const Icon(Icons.account_balance_outlined, color: WiseColors.primary), const SizedBox(width: WiseSpacing.x2), Expanded( child: Text(name, style: Theme.of(context).textTheme.bodyMedium), ), Text( '${asInt(payload['report_count'], report?.institution.reportCount ?? 0)} 份', style: Theme.of(context).textTheme.bodySmall, ), ], ); } } class _SectionsModule extends StatelessWidget { const _SectionsModule({required this.payload, required this.compact}); final JsonMap payload; final bool compact; @override Widget build(BuildContext context) { final summary = asString( payload['preview_summary'], asString(payload['intro_cn']), ); final sections = asMapList(payload['sections']); if (compact) return _Preview(payload: payload); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (summary.isNotEmpty) Text(summary, style: Theme.of(context).textTheme.bodyMedium), for (final section in sections) ...[ const SizedBox(height: WiseSpacing.x3), Text( asString(section['heading']), style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: WiseSpacing.x1), Text( asString(section['body']), style: Theme.of(context).textTheme.bodyMedium, ), ], ], ); } } class _KeyDataModule extends StatelessWidget { const _KeyDataModule({required this.payload, required this.compact}); final JsonMap payload; final bool compact; @override Widget build(BuildContext context) { if (compact) return _Preview(payload: payload); final rows = asMapList(payload['rows']); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final row in rows) Padding( padding: const EdgeInsets.only(bottom: WiseSpacing.x4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( asString(row['metric']), style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: WiseSpacing.x1), Text( _valueWithUnit(row), style: Theme.of(context).textTheme.bodyLarge, ), if (asString( row['judgment'], asString(row['importance']), ).isNotEmpty) ...[ const SizedBox(height: WiseSpacing.x1), Text( asString(row['judgment'], asString(row['importance'])), style: Theme.of(context).textTheme.bodyMedium, ), ], if (asString(row['importance']).isNotEmpty && asString(row['importance']) != asString(row['judgment'])) ...[ const SizedBox(height: WiseSpacing.x1), Text( asString(row['importance']), style: Theme.of(context).textTheme.bodySmall, ), ], ], ), ), ], ); } } class _TimelineModule extends StatelessWidget { const _TimelineModule({required this.payload, required this.compact}); final JsonMap payload; final bool compact; @override Widget build(BuildContext context) { if (compact) return _Preview(payload: payload); final events = asMapList(payload['events']); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final event in events) Padding( padding: const EdgeInsets.only(bottom: WiseSpacing.x4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (asString(event['date']).isNotEmpty) Text( asString(event['date']), style: Theme.of( context, ).textTheme.labelSmall?.copyWith(color: WiseColors.primary), ), const SizedBox(height: WiseSpacing.x1), Text( asString(event['event']), style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: WiseSpacing.x1), Text( asString(event['impact']), style: Theme.of(context).textTheme.bodyMedium, ), ], ), ), ], ); } } class _StudyGuideModule extends StatelessWidget { const _StudyGuideModule({required this.payload, required this.compact}); final JsonMap payload; final bool compact; @override Widget build(BuildContext context) { if (compact) return _Preview(payload: payload); final faqs = asMapList(payload['faq_items']); final glossary = asMapList(payload['glossary']); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (asString(payload['intro_cn']).isNotEmpty) Text( asString(payload['intro_cn']), style: Theme.of(context).textTheme.bodyMedium, ), for (final item in faqs) ExpansionTile( tilePadding: EdgeInsets.zero, title: Text(asString(item['question'])), children: [ Align( alignment: Alignment.centerLeft, child: Text( asString(item['answer']), style: Theme.of(context).textTheme.bodyMedium, ), ), ], ), if (glossary.isNotEmpty) ...[ const SizedBox(height: WiseSpacing.x3), Wrap( spacing: WiseSpacing.x2, runSpacing: WiseSpacing.x2, children: [ for (final item in glossary) AppBadge( text: '${asString(item['term'])}: ${asString(item['definition'])}', ), ], ), ], ], ); } } class _StructureGraphModule extends StatelessWidget { const _StructureGraphModule({required this.payload, required this.compact}); final JsonMap payload; final bool compact; @override Widget build(BuildContext context) { if (compact) return _Preview(payload: payload); final nodes = asMapList(payload['nodes']); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( asString(payload['root']), style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: WiseSpacing.x3), for (final node in nodes) Padding( padding: const EdgeInsets.only(bottom: WiseSpacing.x4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( asString(node['label']), style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: WiseSpacing.x1), for (final child in asStringList(node['children'])) Padding( padding: const EdgeInsets.only(bottom: WiseSpacing.x1), child: Text( child, style: Theme.of(context).textTheme.bodyMedium, ), ), ], ), ), ], ); } } class _RelatedSourcesModule extends StatelessWidget { const _RelatedSourcesModule({required this.payload, required this.compact}); final JsonMap payload; final bool compact; @override Widget build(BuildContext context) { final items = asMapList(payload['items'] ?? payload['sources']); if (items.isEmpty) return _Preview(payload: payload); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final item in items) Padding( padding: const EdgeInsets.only(bottom: WiseSpacing.x4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( asString(item['title']), style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: WiseSpacing.x1), Text( asString(item['summary_cn'], asString(item['source_name'])), style: Theme.of(context).textTheme.bodyMedium, ), ], ), ), ], ); } } class _DifferentiatedViewModule extends StatelessWidget { const _DifferentiatedViewModule({ required this.payload, required this.compact, }); final JsonMap payload; final bool compact; @override Widget build(BuildContext context) { if (compact) return _Preview(payload: payload); final items = asMapList(payload['divergences']); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final item in items) Padding( padding: const EdgeInsets.only(bottom: WiseSpacing.x4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( asString(item['topic']), style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: WiseSpacing.x2), if (asString(item['consensus_view']).isNotEmpty) ...[ Text( '常见观点', style: Theme.of( context, ).textTheme.labelSmall?.copyWith(color: WiseColors.ink700), ), const SizedBox(height: WiseSpacing.x1), Text( asString(item['consensus_view']), style: Theme.of(context).textTheme.bodyMedium, ), const SizedBox(height: WiseSpacing.x2), ], if (asString(item['report_position']).isNotEmpty) ...[ Text( '报告观点', style: Theme.of( context, ).textTheme.labelSmall?.copyWith(color: WiseColors.primary), ), const SizedBox(height: WiseSpacing.x1), Text( asString(item['report_position']), style: Theme.of(context).textTheme.bodyMedium, ), ], ], ), ), ], ); } } class _WeaknessesModule extends StatelessWidget { const _WeaknessesModule({required this.payload, required this.compact}); final JsonMap payload; final bool compact; @override Widget build(BuildContext context) { if (compact) return _Preview(payload: payload); final items = asMapList(payload['items']); final verificationNotes = asStringList(payload['verification_notes']); final counterEvidence = { for (final item in items) if (asString(item['counter_evidence']).isNotEmpty) asString(item['counter_evidence']), }.toList(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (asString(payload['disclaimer_cn']).isNotEmpty) Text( asString(payload['disclaimer_cn']), style: Theme.of(context).textTheme.bodySmall, ), for (final item in items) Padding( padding: const EdgeInsets.only( top: WiseSpacing.x3, bottom: WiseSpacing.x2, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( asString(item['topic']), style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: WiseSpacing.x1), Text( asString(item['weakness']), style: Theme.of(context).textTheme.bodyMedium, ), ], ), ), if (verificationNotes.isNotEmpty || counterEvidence.isNotEmpty) ...[ const SizedBox(height: WiseSpacing.x2), DecoratedBox( decoration: BoxDecoration( color: const Color(0x109A6500), borderRadius: BorderRadius.circular(WiseRadius.sm), ), child: Padding( padding: const EdgeInsets.all(WiseSpacing.x3), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '需要继续验证', style: Theme.of( context, ).textTheme.labelSmall?.copyWith(color: WiseColors.warning), ), const SizedBox(height: WiseSpacing.x1), for (final note in verificationNotes.isNotEmpty ? verificationNotes : counterEvidence) Padding( padding: const EdgeInsets.only(bottom: WiseSpacing.x1), child: Text( note, style: Theme.of(context).textTheme.bodySmall, ), ), ], ), ), ), ], ], ); } } class _Preview extends StatelessWidget { const _Preview({required this.payload}); final JsonMap payload; @override Widget build(BuildContext context) { final headline = asString( payload['preview_headline'], asString(payload['preview_summary'], asString(payload['root'])), ); final highlights = asStringList(payload['highlights']); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (headline.isNotEmpty) Text(headline, style: Theme.of(context).textTheme.bodyMedium), for (final item in highlights.take(3)) Padding( padding: const EdgeInsets.only(top: WiseSpacing.x1), child: Text( '• $item', style: Theme.of(context).textTheme.bodySmall, ), ), ], ); } } class _TextLines extends StatelessWidget { const _TextLines({required this.payload}); final JsonMap payload; @override Widget build(BuildContext context) { final values = payload.entries .where( (entry) => entry.value != null && entry.value.toString().isNotEmpty, ) .map((entry) => '${entry.key}: ${entry.value}') .take(5) .join('\n'); return Text( values.isEmpty ? '该模块暂无可展示内容。' : values, style: Theme.of(context).textTheme.bodyMedium, ); } } class _FallbackModule extends StatelessWidget { const _FallbackModule({required this.type, required this.payload}); final String type; final JsonMap payload; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ AppBadge(text: '未知模块:$type', kind: BadgeKind.warning), const SizedBox(height: WiseSpacing.x2), _Preview(payload: payload), ], ); } } String _kindLabel(String kind) => switch (kind) { 'view' => '观点', 'number' => '数字', 'risk' => '风险', _ => '要点', }; BadgeKind _kindBadge(String kind) => switch (kind) { 'risk' => BadgeKind.warning, 'number' => BadgeKind.audio, _ => BadgeKind.brand, }; String _valueWithUnit(JsonMap row) { final value = asString(row['value']); final unit = asString(row['unit']); if (unit.isEmpty) return value; return '$value $unit'; } String _institutionTypeLabel(String value) => switch (value) { 'international_org' => '国际组织', 'official' => '官方机构', 'industry_org' => '行业组织', 'asset_manager' => '资管机构', 'bank_research' => '银行研究', 'partner' => '合作机构', _ => value, };