fix:优化使用常用技术框架

This commit is contained in:
jingyun
2026-06-03 16:29:53 +08:00
parent e93356e849
commit e2554edfab
22 changed files with 1319 additions and 661 deletions
@@ -1,4 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../../data/api/report_data_source.dart';
import '../../../data/models/models.dart';
@@ -151,7 +153,7 @@ class ModuleRendererRegistry {
}
}
class ModuleDetailPage extends StatefulWidget {
class ModuleDetailPage extends HookConsumerWidget {
const ModuleDetailPage({
required this.reportId,
required this.module,
@@ -168,54 +170,67 @@ class ModuleDetailPage extends StatefulWidget {
final ModuleRendererRegistry registry;
@override
State<ModuleDetailPage> createState() => _ModuleDetailPageState();
Widget build(BuildContext context, WidgetRef ref) {
final retryCount = useState(0);
final future = useMemoized(
() => dataSource.moduleDetail(reportId, module.id),
[dataSource, reportId, module.id, retryCount.value],
);
final snapshot = useFuture(future);
return Scaffold(
appBar: AppBar(title: Text(module.titleCn)),
body: snapshot.connectionState != ConnectionState.done
? const Center(child: CircularProgressIndicator())
: snapshot.hasError
? Center(
child: TextButton(
onPressed: () => retryCount.value++,
child: Text(
snapshot.error.toString(),
textAlign: TextAlign.center,
),
),
)
: _ModuleDetailContent(
detail: snapshot.data!,
report: report,
registry: registry,
),
);
}
}
class _ModuleDetailPageState extends State<ModuleDetailPage> {
late Future<ModuleDetail> future = widget.dataSource.moduleDetail(
widget.reportId,
widget.module.id,
);
class _ModuleDetailContent extends StatelessWidget {
const _ModuleDetailContent({
required this.detail,
required this.report,
required this.registry,
});
final ModuleDetail detail;
final ReportDetail report;
final ModuleRendererRegistry registry;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.module.titleCn)),
body: FutureBuilder<ModuleDetail>(
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,
),
],
);
},
),
return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4),
children: [
AppCard(
child: registry.page(
context,
detail.type,
detail.content,
report: report,
),
),
const SizedBox(height: WiseSpacing.x3),
Text(
'缓存版本 ${detail.cacheVersion}',
style: Theme.of(context).textTheme.bodySmall,
),
],
);
}
}
+126 -93
View File
@@ -1,4 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../data/api/report_data_source.dart';
import '../../data/models/models.dart';
@@ -11,7 +13,7 @@ import '../../widgets/sheets.dart';
import '../../widgets/states.dart';
import 'modules/renderer_registry.dart';
class ReportDetailPage extends StatefulWidget {
class ReportDetailPage extends HookConsumerWidget {
const ReportDetailPage({
required this.reportId,
required this.dataSource,
@@ -38,108 +40,138 @@ class ReportDetailPage extends StatefulWidget {
final VoidCallback? onSpeed;
@override
State<ReportDetailPage> createState() => _ReportDetailPageState();
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();
return Scaffold(
appBar: AppBar(title: const Text('研报详情')),
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 _ReportDetailPageState extends State<ReportDetailPage> {
static const registry = ModuleRendererRegistry();
late Future<ReportDetail> future = widget.dataSource.reportDetail(
widget.reportId,
);
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) {
return Scaffold(
appBar: AppBar(title: const Text('研报详情')),
body: FutureBuilder<ReportDetail>(
future: future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const LoadingState();
}
if (snapshot.hasError) {
return ErrorState(
message: snapshot.error.toString(),
onRetry: () => setState(
() => future = widget.dataSource.reportDetail(widget.reportId),
),
);
}
final detail = snapshot.data!;
return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4),
return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4),
children: [
AppCard(
color: WiseColors.secondary200,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppCard(
color: WiseColors.secondary200,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: WiseSpacing.x2,
runSpacing: WiseSpacing.x2,
children: [
AppBadge(
text: detail.interpretationLabel,
kind: BadgeKind.brand,
),
if (detail.hasAudio)
const AppBadge(
text: '音频',
icon: Icons.graphic_eq,
kind: BadgeKind.audio,
),
AppBadge(
text: asString(detail.source['source_tier']),
icon: Icons.verified_outlined,
kind: BadgeKind.tier,
),
],
Wrap(
spacing: WiseSpacing.x2,
runSpacing: WiseSpacing.x2,
children: [
AppBadge(
text: detail.interpretationLabel,
kind: BadgeKind.brand,
),
if (detail.hasAudio)
const AppBadge(
text: '音频',
icon: Icons.graphic_eq,
kind: BadgeKind.audio,
),
const SizedBox(height: WiseSpacing.x3),
Text(
detail.titleCn,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.headlineSmall,
),
if (detail.oneLiner.isNotEmpty) ...[
const SizedBox(height: WiseSpacing.x2),
Text(
detail.oneLiner,
style: Theme.of(context).textTheme.bodyMedium,
),
],
const SizedBox(height: WiseSpacing.x3),
Text(
'${detail.institution.nameCn} · ${formatDate(detail.releasedAt)}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
AppBadge(
text: asString(detail.source['source_tier']),
icon: Icons.verified_outlined,
kind: BadgeKind.tier,
),
],
),
const SizedBox(height: WiseSpacing.x4),
_ActionBar(detail: detail),
const SizedBox(height: WiseSpacing.x4),
_Toc(modules: detail.modules),
const SizedBox(height: WiseSpacing.x4),
for (final module in detail.modules) ...[
registry.card(
context: context,
module: module,
report: detail,
dataSource: widget.dataSource,
player: widget.player,
onStartAudio: widget.onStartAudio,
onToggleAudio: widget.onToggleAudio,
onSeekAudio: widget.onSeekAudio,
onSpeed: widget.onSpeed,
const SizedBox(height: WiseSpacing.x3),
Text(
detail.titleCn,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.headlineSmall,
),
if (detail.oneLiner.isNotEmpty) ...[
const SizedBox(height: WiseSpacing.x2),
Text(
detail.oneLiner,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: WiseSpacing.x4),
],
const SizedBox(height: WiseSpacing.x3),
Text(
'${detail.institution.nameCn} · ${formatDate(detail.releasedAt)}',
style: Theme.of(context).textTheme.bodySmall,
),
],
);
},
),
),
),
const SizedBox(height: WiseSpacing.x4),
_ActionBar(detail: detail),
const SizedBox(height: WiseSpacing.x4),
_Toc(modules: detail.modules),
const SizedBox(height: WiseSpacing.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: WiseSpacing.x4),
],
],
);
}
}
@@ -158,7 +190,8 @@ class _ActionBar extends StatelessWidget {
label: '收藏',
icon: Icons.favorite_border,
kind: AppButtonKind.ghost,
onPressed: () => showLoginSheet(context, reason: '登录后保存到你的收藏'),
onPressed: () =>
showLoginSheet(context, reason: '登录后保存到你的收藏'),
),
),
const SizedBox(width: WiseSpacing.x2),
+64 -46
View File
@@ -1,6 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../data/api/report_data_source.dart';
import '../../data/content_providers.dart';
import '../../data/models/models.dart';
import '../../routing/app_routes.dart';
import '../../theme/wise_tokens.dart';
@@ -9,7 +12,7 @@ import '../../widgets/mini_player.dart';
import '../../widgets/states.dart';
import '../shared/report_card_widget.dart';
class FeedPage extends StatefulWidget {
class FeedPage extends HookConsumerWidget {
const FeedPage({
required this.dataSource,
required this.onPlay,
@@ -30,24 +33,28 @@ class FeedPage extends StatefulWidget {
final VoidCallback? onSpeed;
@override
State<FeedPage> createState() => _FeedPageState();
}
Widget build(BuildContext context, WidgetRef ref) {
final topic = useState('全部');
final snapshot = ref.watch(recommendedReportsProvider);
class _FeedPageState extends State<FeedPage> {
String topic = '全部';
late Future<List<ReportCardModel>> future = widget.dataSource.recommended();
@override
Widget build(BuildContext context) {
return FutureBuilder<List<ReportCardModel>>(
future: future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) return const LoadingState();
if (snapshot.hasError) return ErrorState(message: snapshot.error.toString(), onRetry: () => setState(() => future = widget.dataSource.recommended()));
final items = snapshot.data ?? const [];
return snapshot.when(
loading: () => const LoadingState(),
error: (error, _) => ErrorState(
message: error.toString(),
onRetry: () => ref.invalidate(recommendedReportsProvider),
),
data: (items) {
final currentTopic = topic.value;
final topics = ['全部', ...{for (final item in items) ...item.topics}];
final visible = topic == '全部' ? items : items.where((item) => item.topics.contains(topic)).toList();
if (items.isEmpty) return const EmptyState(title: '暂无可推荐的研报解读', message: '稍后再来看看最新内容');
final visible = currentTopic == '全部'
? items
: items.where((item) => item.topics.contains(currentTopic)).toList();
if (items.isEmpty) {
return const EmptyState(
title: '暂无可推荐的研报解读',
message: '稍后再来看看最新内容',
);
}
return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4),
children: [
@@ -58,29 +65,37 @@ class _FeedPageState extends State<FeedPage> {
for (final t in topics)
Padding(
padding: const EdgeInsets.only(right: WiseSpacing.x2),
child: AppChip(label: t, selected: t == topic, onTap: () => setState(() => topic = t)),
child: AppChip(
label: t,
selected: t == currentTopic,
onTap: () => topic.value = t,
),
),
],
),
),
const SizedBox(height: WiseSpacing.x3),
if (visible.isEmpty)
EmptyState(title: '暂无可推荐的研报解读', message: '换个主题,或去研报页看看全部内容', icon: Icons.filter_alt_off)
const EmptyState(
title: '暂无可推荐的研报解读',
message: '换个主题,或去研报页看看全部内容',
icon: Icons.filter_alt_off,
)
else ...[
ReportCardWidget(
report: visible.first,
hero: true,
onTap: () => openReportDetail(
context,
widget.dataSource,
dataSource,
visible.first,
player: widget.player,
onStartAudio: widget.onStartModuleAudio,
onToggleAudio: widget.onToggleAudio,
onSeekAudio: widget.onSeekAudio,
onSpeed: widget.onSpeed,
player: player,
onStartAudio: onStartModuleAudio,
onToggleAudio: onToggleAudio,
onSeekAudio: onSeekAudio,
onSpeed: onSpeed,
),
onPlayTap: () => playFromReport(widget.onPlay, visible.first),
onPlayTap: () => _playFromReport(onPlay, visible.first),
),
const SizedBox(height: WiseSpacing.x5),
Text('最新解读', style: Theme.of(context).textTheme.titleMedium),
@@ -90,15 +105,15 @@ class _FeedPageState extends State<FeedPage> {
report: report,
onTap: () => openReportDetail(
context,
widget.dataSource,
dataSource,
report,
player: widget.player,
onStartAudio: widget.onStartModuleAudio,
onToggleAudio: widget.onToggleAudio,
onSeekAudio: widget.onSeekAudio,
onSpeed: widget.onSpeed,
player: player,
onStartAudio: onStartModuleAudio,
onToggleAudio: onToggleAudio,
onSeekAudio: onSeekAudio,
onSpeed: onSpeed,
),
onPlayTap: () => playFromReport(widget.onPlay, report),
onPlayTap: () => _playFromReport(onPlay, report),
),
const SizedBox(height: WiseSpacing.x3),
],
@@ -108,17 +123,20 @@ class _FeedPageState extends State<FeedPage> {
},
);
}
void playFromReport(void Function(AudioItem item) onPlay, ReportCardModel report) {
onPlay(
AudioItem(
audioId: 'local_${report.id}',
reportId: report.id,
titleCn: report.titleCn,
reportTitleCn: report.titleCn,
durationSec: 180,
institution: report.institution,
),
);
}
}
void _playFromReport(
void Function(AudioItem item) onPlay,
ReportCardModel report,
) {
onPlay(
AudioItem(
audioId: 'local_${report.id}',
reportId: report.id,
titleCn: report.titleCn,
reportTitleCn: report.titleCn,
durationSec: 180,
institution: report.institution,
),
);
}
@@ -1,4 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../data/api/report_data_source.dart';
import '../../data/models/models.dart';
@@ -11,91 +13,131 @@ import '../../widgets/sheets.dart';
import '../../widgets/states.dart';
import '../shared/report_card_widget.dart';
class InstitutionDetailPage extends StatefulWidget {
const InstitutionDetailPage({required this.institutionId, required this.dataSource, super.key});
class InstitutionDetailPage extends HookConsumerWidget {
const InstitutionDetailPage({
required this.institutionId,
required this.dataSource,
super.key,
});
final String institutionId;
final ReportDataSource dataSource;
@override
State<InstitutionDetailPage> createState() => _InstitutionDetailPageState();
}
Widget build(BuildContext context, WidgetRef ref) {
final retryCount = useState(0);
final future = useMemoized(
() => dataSource.institutionDetail(institutionId),
[dataSource, institutionId, retryCount.value],
);
final snapshot = useFuture(future);
class _InstitutionDetailPageState extends State<InstitutionDetailPage> {
late Future<Institution> future = widget.dataSource.institutionDetail(widget.institutionId);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('机构主页')),
body: FutureBuilder<Institution>(
future: future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) return const LoadingState();
if (snapshot.hasError) return ErrorState(message: snapshot.error.toString(), onRetry: () => setState(() => future = widget.dataSource.institutionDetail(widget.institutionId)));
final item = snapshot.data!;
return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4),
children: [
AppCard(
color: WiseColors.secondary200,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.nameCn, style: Theme.of(context).textTheme.headlineSmall),
if (item.nameEn.isNotEmpty) Text(item.nameEn, style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: WiseSpacing.x3),
Wrap(
spacing: WiseSpacing.x2,
runSpacing: WiseSpacing.x2,
children: [
AppBadge(text: item.sourceTier, icon: Icons.verified_outlined, kind: BadgeKind.tier),
AppBadge(text: '${item.reportCount} 份研报', kind: BadgeKind.brand),
for (final topic in item.coveredTopics) AppBadge(text: topic),
],
),
],
body: snapshot.connectionState != ConnectionState.done
? const LoadingState()
: snapshot.hasError
? ErrorState(
message: snapshot.error.toString(),
onRetry: () => retryCount.value++,
)
: _InstitutionDetailContent(
item: snapshot.data!,
dataSource: dataSource,
),
),
const SizedBox(height: WiseSpacing.x3),
if (item.introCn.isNotEmpty)
AppCard(child: Text(item.introCn, style: Theme.of(context).textTheme.bodyMedium)),
const SizedBox(height: WiseSpacing.x3),
if (item.credibilityNote.isNotEmpty)
AppCard(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.verified_user_outlined, color: WiseColors.positive),
const SizedBox(width: WiseSpacing.x2),
Expanded(child: Text(item.credibilityNote, style: Theme.of(context).textTheme.bodyMedium)),
],
),
),
const SizedBox(height: WiseSpacing.x5),
Text('最新研报', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: WiseSpacing.x3),
if (item.recentReports.isEmpty)
const EmptyState(title: '机构暂无研报', message: '稍后再试', icon: Icons.article_outlined)
else
for (final report in item.recentReports) ...[
ReportCardWidget(
report: report,
onTap: () => openReportDetail(context, widget.dataSource, report),
),
const SizedBox(height: WiseSpacing.x3),
],
AppButton(
label: '了解相关服务',
icon: Icons.open_in_new,
kind: AppButtonKind.ghost,
expand: true,
onPressed: () => showOutboundSheet(context, title: item.nameCn),
),
],
);
},
),
);
}
}
class _InstitutionDetailContent extends StatelessWidget {
const _InstitutionDetailContent({
required this.item,
required this.dataSource,
});
final Institution item;
final ReportDataSource dataSource;
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4),
children: [
AppCard(
color: WiseColors.secondary200,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.nameCn, style: Theme.of(context).textTheme.headlineSmall),
if (item.nameEn.isNotEmpty)
Text(item.nameEn, style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: WiseSpacing.x3),
Wrap(
spacing: WiseSpacing.x2,
runSpacing: WiseSpacing.x2,
children: [
AppBadge(
text: item.sourceTier,
icon: Icons.verified_outlined,
kind: BadgeKind.tier,
),
AppBadge(
text: '${item.reportCount} 份研报',
kind: BadgeKind.brand,
),
for (final topic in item.coveredTopics) AppBadge(text: topic),
],
),
],
),
),
const SizedBox(height: WiseSpacing.x3),
if (item.introCn.isNotEmpty)
AppCard(
child: Text(item.introCn, style: Theme.of(context).textTheme.bodyMedium),
),
const SizedBox(height: WiseSpacing.x3),
if (item.credibilityNote.isNotEmpty)
AppCard(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.verified_user_outlined, color: WiseColors.positive),
const SizedBox(width: WiseSpacing.x2),
Expanded(
child: Text(
item.credibilityNote,
style: Theme.of(context).textTheme.bodyMedium,
),
),
],
),
),
const SizedBox(height: WiseSpacing.x5),
Text('最新研报', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: WiseSpacing.x3),
if (item.recentReports.isEmpty)
const EmptyState(
title: '机构暂无研报',
message: '稍后再试',
icon: Icons.article_outlined,
)
else
for (final report in item.recentReports) ...[
ReportCardWidget(
report: report,
onTap: () => openReportDetail(context, dataSource, report),
),
const SizedBox(height: WiseSpacing.x3),
],
AppButton(
label: '了解相关服务',
icon: Icons.open_in_new,
kind: AppButtonKind.ghost,
expand: true,
onPressed: () => showOutboundSheet(context, title: item.nameCn),
),
],
);
}
}
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../data/api/report_data_source.dart';
import '../../data/content_providers.dart';
import '../../data/models/models.dart';
import '../../routing/app_routes.dart';
import '../../theme/wise_tokens.dart';
@@ -8,36 +10,43 @@ import '../../widgets/app_card.dart';
import '../../widgets/badges.dart';
import '../../widgets/states.dart';
class InstitutionsPage extends StatefulWidget {
class InstitutionsPage extends HookConsumerWidget {
const InstitutionsPage({required this.dataSource, super.key});
final ReportDataSource dataSource;
@override
State<InstitutionsPage> createState() => _InstitutionsPageState();
}
class _InstitutionsPageState extends State<InstitutionsPage> {
late Future<List<Institution>> future = widget.dataSource.institutions();
@override
Widget build(BuildContext context) {
return FutureBuilder<List<Institution>>(
future: future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) return const LoadingState();
if (snapshot.hasError) return ErrorState(message: snapshot.error.toString(), onRetry: () => setState(() => future = widget.dataSource.institutions()));
final items = [...snapshot.data ?? const <Institution>[]]..sort((a, b) => b.reportCount.compareTo(a.reportCount));
if (items.isEmpty) return const EmptyState(title: '暂无机构信息', message: '稍后再试', icon: Icons.account_balance_outlined);
Widget build(BuildContext context, WidgetRef ref) {
final snapshot = ref.watch(institutionsProvider);
return snapshot.when(
loading: () => const LoadingState(),
error: (error, _) => ErrorState(
message: error.toString(),
onRetry: () => ref.invalidate(institutionsProvider),
),
data: (items) {
final sorted = [...items]
..sort((a, b) => b.reportCount.compareTo(a.reportCount));
if (sorted.isEmpty) {
return const EmptyState(
title: '暂无机构信息',
message: '稍后再试',
icon: Icons.account_balance_outlined,
);
}
return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4),
children: [
Text('研报来源机构', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: WiseSpacing.x3),
for (final item in items) ...[
for (final item in sorted) ...[
InstitutionCard(
institution: item,
onTap: () => openInstitutionDetail(context, widget.dataSource, item.id),
onTap: () => openInstitutionDetail(
context,
dataSource,
item.id,
),
),
const SizedBox(height: WiseSpacing.x3),
],
@@ -56,7 +65,9 @@ class InstitutionCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final initials = institution.nameCn.isEmpty ? '' : institution.nameCn.characters.take(2).toString();
final initials = institution.nameCn.isEmpty
? ''
: institution.nameCn.characters.take(2).toString();
return AppCard(
onTap: onTap,
child: Row(
@@ -66,23 +77,36 @@ class InstitutionCard extends StatelessWidget {
radius: 25,
backgroundColor: WiseColors.secondary200,
foregroundColor: WiseColors.primary,
child: Text(initials, style: const TextStyle(fontWeight: FontWeight.w800)),
child: Text(
initials,
style: const TextStyle(fontWeight: FontWeight.w800),
),
),
const SizedBox(width: WiseSpacing.x3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(institution.nameCn, style: Theme.of(context).textTheme.titleMedium),
Text(
institution.nameCn,
style: Theme.of(context).textTheme.titleMedium,
),
if (institution.nameEn.isNotEmpty)
Text(institution.nameEn, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall),
Text(
institution.nameEn,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: WiseSpacing.x2),
Wrap(
spacing: WiseSpacing.x2,
runSpacing: WiseSpacing.x2,
children: [
if (institution.institutionType.isNotEmpty) AppBadge(text: institution.institutionType),
for (final topic in institution.coveredTopics.take(3)) AppBadge(text: topic, kind: BadgeKind.brand),
if (institution.institutionType.isNotEmpty)
AppBadge(text: institution.institutionType),
for (final topic in institution.coveredTopics.take(3))
AppBadge(text: topic, kind: BadgeKind.brand),
],
),
],
@@ -91,7 +115,12 @@ class InstitutionCard extends StatelessWidget {
const SizedBox(width: WiseSpacing.x2),
Column(
children: [
Text('${institution.reportCount}', style: Theme.of(context).textTheme.titleMedium?.copyWith(color: WiseColors.primary)),
Text(
'${institution.reportCount}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: WiseColors.primary,
),
),
Text('份研报', style: Theme.of(context).textTheme.bodySmall),
],
),
+45 -23
View File
@@ -1,59 +1,81 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../data/api/report_data_source.dart';
import '../../data/content_providers.dart';
import '../../data/models/models.dart';
import '../../theme/wise_tokens.dart';
import '../../widgets/app_card.dart';
import '../../widgets/states.dart';
class ListenPage extends StatefulWidget {
class ListenPage extends HookConsumerWidget {
const ListenPage({required this.dataSource, required this.onPlay, super.key});
final ReportDataSource dataSource;
final void Function(AudioItem item) onPlay;
@override
State<ListenPage> createState() => _ListenPageState();
}
class _ListenPageState extends State<ListenPage> {
late Future<List<AudioItem>> future = widget.dataSource.listen();
@override
Widget build(BuildContext context) {
return FutureBuilder<List<AudioItem>>(
future: future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) return const LoadingState(label: '正在加载听单');
if (snapshot.hasError) return ErrorState(message: snapshot.error.toString(), onRetry: () => setState(() => future = widget.dataSource.listen()));
final items = snapshot.data ?? const [];
if (items.isEmpty) return const EmptyState(title: '暂无音频研报', message: '先去研报页看看图文解读', icon: Icons.headphones_outlined);
Widget build(BuildContext context, WidgetRef ref) {
final snapshot = ref.watch(listenProvider);
return snapshot.when(
loading: () => const LoadingState(label: '正在加载听单'),
error: (error, _) => ErrorState(
message: error.toString(),
onRetry: () => ref.invalidate(listenProvider),
),
data: (items) {
if (items.isEmpty) {
return const EmptyState(
title: '暂无音频研报',
message: '先去研报页看看图文解读',
icon: Icons.headphones_outlined,
);
}
return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4),
children: [
Text('全站音频解读', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: WiseSpacing.x2),
Text('游客可完整收听;真实音频流待后端接入。', style: Theme.of(context).textTheme.bodyMedium),
Text(
'游客可完整收听;真实音频流待后端接入。',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: WiseSpacing.x4),
for (final item in items) ...[
AppCard(
onTap: () => widget.onPlay(item),
onTap: () => onPlay(item),
child: Row(
children: [
IconButton.filled(
onPressed: () => widget.onPlay(item),
onPressed: () => onPlay(item),
icon: const Icon(Icons.play_arrow),
style: IconButton.styleFrom(backgroundColor: WiseColors.primary, foregroundColor: Colors.white),
style: IconButton.styleFrom(
backgroundColor: WiseColors.primary,
foregroundColor: Colors.white,
),
),
const SizedBox(width: WiseSpacing.x3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.reportTitleCn, maxLines: 2, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleMedium),
Text('${item.institution.nameCn} · ${formatDuration(item.durationSec)}', style: Theme.of(context).textTheme.bodySmall),
Text(
item.reportTitleCn,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium,
),
Text(
'${item.institution.nameCn} · ${formatDuration(item.durationSec)}',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: WiseSpacing.x2),
LinearProgressIndicator(value: 0, minHeight: 4, color: WiseColors.accent, backgroundColor: WiseColors.border),
LinearProgressIndicator(
value: 0,
minHeight: 4,
color: WiseColors.accent,
backgroundColor: WiseColors.border,
),
],
),
),
+144 -80
View File
@@ -1,6 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../data/api/report_data_source.dart';
import '../../data/content_providers.dart';
import '../../data/models/models.dart';
import '../../routing/app_routes.dart';
import '../../theme/wise_tokens.dart';
@@ -10,7 +13,7 @@ import '../../widgets/mini_player.dart';
import '../../widgets/states.dart';
import '../shared/report_card_widget.dart';
class ReportsPage extends StatefulWidget {
class ReportsPage extends HookConsumerWidget {
const ReportsPage({
required this.dataSource,
required this.onPlay,
@@ -25,29 +28,36 @@ class ReportsPage extends StatefulWidget {
final ReportDataSource dataSource;
final void Function(AudioItem item) onPlay;
final PlayerStateModel player;
final void Function(String audioId, String reportId, String title, int durationSec)? onStartModuleAudio;
final void Function(String audioId, String reportId, String title, int durationSec)?
onStartModuleAudio;
final VoidCallback? onToggleAudio;
final void Function(int delta)? onSeekAudio;
final VoidCallback? onSpeed;
@override
State<ReportsPage> createState() => _ReportsPageState();
}
Widget build(BuildContext context, WidgetRef ref) {
final query = useState('');
final topic = useState('');
final hasAudio = useState(false);
final snapshot = ref.watch(reportsProvider);
class _ReportsPageState extends State<ReportsPage> {
late Future<List<ReportCardModel>> future = widget.dataSource.reports();
String query = '';
String topic = '';
bool hasAudio = false;
return snapshot.when(
loading: () => const LoadingState(label: '正在搜索研报'),
error: (error, _) => ErrorState(
message: error.toString(),
onRetry: () => ref.invalidate(reportsProvider),
),
data: (items) {
final currentQuery = query.value;
final currentTopic = topic.value;
final currentHasAudio = hasAudio.value;
final filtered = _applyFilters(
items,
query: currentQuery,
topic: currentTopic,
hasAudio: currentHasAudio,
);
@override
Widget build(BuildContext context) {
return FutureBuilder<List<ReportCardModel>>(
future: future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) return const LoadingState(label: '正在搜索研报');
if (snapshot.hasError) return ErrorState(message: snapshot.error.toString(), onRetry: () => setState(() => future = widget.dataSource.reports()));
final items = applyFilters(snapshot.data ?? const []);
return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4),
children: [
@@ -55,50 +65,85 @@ class _ReportsPageState extends State<ReportsPage> {
decoration: InputDecoration(
hintText: '搜索标题、机构或主题',
prefixIcon: const Icon(Icons.search),
suffixIcon: query.isEmpty ? null : IconButton(onPressed: () => setState(() => query = ''), icon: const Icon(Icons.close)),
suffixIcon: currentQuery.isEmpty
? null
: IconButton(
onPressed: () => query.value = '',
icon: const Icon(Icons.close),
),
filled: true,
fillColor: WiseColors.surface,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(WiseRadius.pill), borderSide: BorderSide.none),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(WiseRadius.pill),
borderSide: BorderSide.none,
),
),
onChanged: (value) => setState(() => query = value.trim()),
onChanged: (value) => query.value = value.trim(),
),
const SizedBox(height: WiseSpacing.x3),
Row(
children: [
AppButton(label: '筛选', icon: Icons.tune, kind: AppButtonKind.ghost, onPressed: openFilterSheet),
AppButton(
label: '筛选',
icon: Icons.tune,
kind: AppButtonKind.ghost,
onPressed: items.isEmpty
? null
: () => _openFilterSheet(
context,
items: items,
topic: topic,
),
),
const SizedBox(width: WiseSpacing.x2),
AppChip(label: '有音频', selected: hasAudio, onTap: () => setState(() => hasAudio = !hasAudio)),
AppChip(
label: '有音频',
selected: currentHasAudio,
onTap: () => hasAudio.value = !currentHasAudio,
),
],
),
const SizedBox(height: WiseSpacing.x3),
Text('${items.length} 篇研报解读${query.isNotEmpty || topic.isNotEmpty || hasAudio ? '(已筛选)' : ''}', style: Theme.of(context).textTheme.bodySmall),
Text(
'${filtered.length} 篇研报解读${currentQuery.isNotEmpty || currentTopic.isNotEmpty || currentHasAudio ? '(已筛选)' : ''}',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: WiseSpacing.x3),
if (items.isEmpty)
if (filtered.isEmpty)
EmptyState(
title: query.isNotEmpty ? '未找到相关研报' : '当前筛选下暂无研报',
message: query.isNotEmpty ? '换个关键词试试' : '调整筛选条件后再试',
title: currentQuery.isNotEmpty ? '未找到相关研报' : '当前筛选下暂无研报',
message: currentQuery.isNotEmpty ? '换个关键词试试' : '调整筛选条件后再试',
actionLabel: '清除筛选',
onAction: () => setState(() {
query = '';
topic = '';
hasAudio = false;
}),
onAction: () {
query.value = '';
topic.value = '';
hasAudio.value = false;
},
)
else
for (final report in items) ...[
for (final report in filtered) ...[
ReportCardWidget(
report: report,
onTap: () => openReportDetail(
context,
widget.dataSource,
dataSource,
report,
player: widget.player,
onStartAudio: widget.onStartModuleAudio,
onToggleAudio: widget.onToggleAudio,
onSeekAudio: widget.onSeekAudio,
onSpeed: widget.onSpeed,
player: player,
onStartAudio: onStartModuleAudio,
onToggleAudio: onToggleAudio,
onSeekAudio: onSeekAudio,
onSpeed: onSpeed,
),
onPlayTap: () => onPlay(
AudioItem(
audioId: 'local_${report.id}',
reportId: report.id,
titleCn: report.titleCn,
reportTitleCn: report.titleCn,
durationSec: 180,
institution: report.institution,
),
),
onPlayTap: () => widget.onPlay(AudioItem(audioId: 'local_${report.id}', reportId: report.id, titleCn: report.titleCn, reportTitleCn: report.titleCn, durationSec: 180, institution: report.institution)),
),
const SizedBox(height: WiseSpacing.x3),
],
@@ -107,51 +152,70 @@ class _ReportsPageState extends State<ReportsPage> {
},
);
}
}
List<ReportCardModel> applyFilters(List<ReportCardModel> items) {
return items.where((item) {
final hay = '${item.titleCn} ${item.institution.nameCn} ${item.topics.join(' ')}'.toLowerCase();
if (query.isNotEmpty && !hay.contains(query.toLowerCase())) return false;
if (topic.isNotEmpty && !item.topics.contains(topic)) return false;
if (hasAudio && !item.hasAudio) return false;
return true;
}).toList();
}
List<ReportCardModel> _applyFilters(
List<ReportCardModel> items, {
required String query,
required String topic,
required bool hasAudio,
}) {
return items.where((item) {
final hay =
'${item.titleCn} ${item.institution.nameCn} ${item.topics.join(' ')}'
.toLowerCase();
if (query.isNotEmpty && !hay.contains(query.toLowerCase())) return false;
if (topic.isNotEmpty && !item.topics.contains(topic)) return false;
if (hasAudio && !item.hasAudio) return false;
return true;
}).toList();
}
void openFilterSheet() {
widget.dataSource.reports().then((items) {
if (!mounted) return;
final topics = {for (final item in items) ...item.topics}.toList();
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(WiseRadius.lg))),
builder: (context) => Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 28),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
void _openFilterSheet(
BuildContext context, {
required List<ReportCardModel> items,
required ValueNotifier<String> topic,
}) {
final topics = {for (final item in items) ...item.topics}.toList();
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(WiseRadius.lg)),
),
builder: (context) => Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 28),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('筛选研报', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: WiseSpacing.x3),
Wrap(
spacing: WiseSpacing.x2,
runSpacing: WiseSpacing.x2,
children: [
Text('筛选研报', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: WiseSpacing.x3),
Wrap(
spacing: WiseSpacing.x2,
runSpacing: WiseSpacing.x2,
children: [
AppChip(label: '全部主题', selected: topic.isEmpty, onTap: () => selectTopic('')),
for (final t in topics) AppChip(label: t, selected: topic == t, onTap: () => selectTopic(t)),
],
AppChip(
label: '全部主题',
selected: topic.value.isEmpty,
onTap: () => topic.value = '',
),
const SizedBox(height: WiseSpacing.x4),
AppButton(label: '完成', expand: true, onPressed: () => Navigator.pop(context)),
for (final t in topics)
AppChip(
label: t,
selected: topic.value == t,
onTap: () => topic.value = t,
),
],
),
),
);
});
}
void selectTopic(String value) {
setState(() => topic = value);
}
const SizedBox(height: WiseSpacing.x4),
AppButton(
label: '完成',
expand: true,
onPressed: () => Navigator.pop(context),
),
],
),
),
);
}
+96 -127
View File
@@ -1,121 +1,26 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../data/api/report_data_source.dart';
import '../data/models/models.dart';
import '../routing/app_routes.dart';
import '../data/providers.dart';
import '../theme/app_icons.dart';
import '../theme/wise_tokens.dart';
import '../widgets/mini_player.dart';
import 'feed/feed_page.dart';
import 'institutions/institutions_page.dart';
import 'listen/listen_page.dart';
import 'profile/profile_page.dart';
import 'reports/reports_page.dart';
class ShellPage extends StatefulWidget {
const ShellPage({required this.dataSource, super.key});
class ShellPage extends ConsumerWidget {
const ShellPage({required this.child, required this.currentPath, super.key});
final ReportDataSource dataSource;
final Widget child;
final String currentPath;
@override
State<ShellPage> createState() => _ShellPageState();
}
Widget build(BuildContext context, WidgetRef ref) {
final player = ref.watch(audioPlayerControllerProvider);
final controller = ref.read(audioPlayerControllerProvider.notifier);
final selectedIndex = _tabs.indexWhere((tab) => tab.path == currentPath);
final safeIndex = selectedIndex < 0 ? 0 : selectedIndex;
class _ShellPageState extends State<ShellPage> {
int index = 0;
PlayerStateModel player = const PlayerStateModel();
Timer? timer;
@override
void dispose() {
timer?.cancel();
super.dispose();
}
void startAudio(AudioItem item) {
timer?.cancel();
setState(() {
player = PlayerStateModel(
audioId: item.audioId,
reportId: item.reportId,
title: item.titleCn,
durationSec: item.durationSec,
playing: true,
speed: player.speed,
);
});
timer = Timer.periodic(const Duration(seconds: 1), (_) => tick());
}
void startModuleAudio(String audioId, String reportId, String title, int durationSec) {
startAudio(
AudioItem(
audioId: audioId,
reportId: reportId,
titleCn: title,
reportTitleCn: title,
durationSec: durationSec,
institution: const Institution(id: '', nameCn: ''),
),
);
}
void tick() {
if (!player.playing) return;
final next = player.positionSec + player.speed.round().clamp(1, 2);
setState(() {
player = player.copyWith(
positionSec: next >= player.durationSec ? player.durationSec : next,
playing: next < player.durationSec,
);
});
}
void toggleAudio() {
if (!player.hasAudio) return;
setState(() => player = player.copyWith(playing: !player.playing));
}
void seekAudio(int delta) {
if (!player.hasAudio) return;
setState(() {
player = player.copyWith(
positionSec: (player.positionSec + delta).clamp(0, player.durationSec),
);
});
}
void cycleSpeed() {
const speeds = [1.0, 1.25, 1.5, 2.0];
final current = speeds.indexOf(player.speed);
setState(() => player = player.copyWith(speed: speeds[(current + 1) % speeds.length]));
}
@override
Widget build(BuildContext context) {
final pages = [
FeedPage(
dataSource: widget.dataSource,
onPlay: startAudio,
player: player,
onStartModuleAudio: startModuleAudio,
onToggleAudio: toggleAudio,
onSeekAudio: seekAudio,
onSpeed: cycleSpeed,
),
ReportsPage(
dataSource: widget.dataSource,
onPlay: startAudio,
player: player,
onStartModuleAudio: startModuleAudio,
onToggleAudio: toggleAudio,
onSeekAudio: seekAudio,
onSpeed: cycleSpeed,
),
InstitutionsPage(dataSource: widget.dataSource),
ListenPage(dataSource: widget.dataSource, onPlay: startAudio),
ProfilePage(dataSource: widget.dataSource),
];
return Scaffold(
appBar: AppBar(
title: const Column(
@@ -124,29 +29,93 @@ class _ShellPageState extends State<ShellPage> {
Text('研听'),
Text(
'全球机构研报中文解读',
style: TextStyle(fontSize: 12, color: WiseColors.textSecondary, fontWeight: FontWeight.w500),
style: TextStyle(
fontSize: 12,
color: WiseColors.textSecondary,
fontWeight: FontWeight.w500,
),
),
],
),
),
body: pages[index],
bottomNavigationBar: Column(
mainAxisSize: MainAxisSize.min,
children: [
MiniPlayer(player: player, onToggle: toggleAudio),
NavigationBar(
selectedIndex: index,
onDestinationSelected: (value) => setState(() => index = value),
destinations: const [
NavigationDestination(icon: Icon(Icons.auto_awesome_outlined), selectedIcon: Icon(Icons.auto_awesome), label: '推荐'),
NavigationDestination(icon: Icon(Icons.article_outlined), selectedIcon: Icon(Icons.article), label: '研报'),
NavigationDestination(icon: Icon(Icons.account_balance_outlined), selectedIcon: Icon(Icons.account_balance), label: '机构'),
NavigationDestination(icon: Icon(Icons.headphones_outlined), selectedIcon: Icon(Icons.headphones), label: '听单'),
NavigationDestination(icon: Icon(Icons.person_outline), selectedIcon: Icon(Icons.person), label: '我的'),
],
),
],
body: ColoredBox(
color: WiseColors.canvas,
child: Stack(children: [Positioned.fill(child: child)]),
),
bottomNavigationBar: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
MiniPlayer(player: player, onToggle: controller.toggleAudio),
Container(
height: 64,
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
decoration: const BoxDecoration(
color: WiseColors.canvas,
border: Border(
top: BorderSide(color: Color(0x11000000), width: 0.5),
),
),
child: Row(
children: List.generate(_tabs.length, (index) {
final tab = _tabs[index];
final active = index == safeIndex;
return Expanded(
child: InkWell(
onTap: () => context.go(tab.path),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
tab.icon,
size: 20,
color: active
? WiseColors.ink
: WiseColors.textTertiary,
),
const SizedBox(height: 4),
Text(
tab.label,
style:
(Theme.of(context).textTheme.labelLarge ??
const TextStyle())
.copyWith(
color: active
? WiseColors.ink
: WiseColors.textTertiary,
fontFamily: 'Inter',
fontSize: 12,
letterSpacing: 0.72,
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}),
),
),
],
),
),
);
}
}
class _TabItem {
const _TabItem({required this.label, required this.path, required this.icon});
final String label;
final String path;
final IconData icon;
}
const List<_TabItem> _tabs = [
_TabItem(label: '推荐', path: AppRoutes.home, icon: AppIcons.sparkle),
_TabItem(label: '研报', path: AppRoutes.reports, icon: AppIcons.article),
_TabItem(label: '机构', path: AppRoutes.institutions, icon: AppIcons.bank),
_TabItem(label: '听单', path: AppRoutes.listen, icon: AppIcons.headphones),
_TabItem(label: '我的', path: AppRoutes.profile, icon: AppIcons.user),
];