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
+12 -121
View File
@@ -1,8 +1,13 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'app/app.dart';
import 'data/api/report_data_source.dart';
import 'features/shell_page.dart';
import 'theme/app_theme.dart';
import 'data/providers.dart';
export 'app/app.dart';
export 'data/api/report_data_source.dart';
export 'data/models/models.dart';
class MyApp extends StatelessWidget {
const MyApp({required this.dataSource, super.key});
@@ -11,125 +16,11 @@ class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '研听',
debugShowCheckedModeBanner: false,
theme: buildAppTheme(),
scrollBehavior: const WhitespaceStretchScrollBehavior(),
home: ShellPage(dataSource: dataSource),
return ProviderScope(
overrides: [
reportDataSourceProvider.overrideWithValue(dataSource),
],
child: const ReportNotebooklmApp(),
);
}
}
class WhitespaceStretchScrollBehavior extends MaterialScrollBehavior {
const WhitespaceStretchScrollBehavior();
@override
Widget buildOverscrollIndicator(
BuildContext context,
Widget child,
ScrollableDetails details,
) {
return _WhitespaceStretchIndicator(child: child);
}
}
class _WhitespaceStretchIndicator extends StatefulWidget {
const _WhitespaceStretchIndicator({required this.child});
final Widget child;
@override
State<_WhitespaceStretchIndicator> createState() =>
_WhitespaceStretchIndicatorState();
}
class _WhitespaceStretchIndicatorState
extends State<_WhitespaceStretchIndicator>
with SingleTickerProviderStateMixin {
static const double _maxStretch = 64;
static const double _dragResistance = 0.38;
late final AnimationController _offsetController =
AnimationController.unbounded(vsync: this)..addListener(_onTick);
@override
Widget build(BuildContext context) {
return NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: ClipRect(
child: Transform.translate(
offset: Offset(0, _offsetController.value),
child: widget.child,
),
),
);
}
@override
void dispose() {
_offsetController.dispose();
super.dispose();
}
bool _handleScrollNotification(ScrollNotification notification) {
if (notification.metrics.axis != Axis.vertical) {
return false;
}
if (notification is OverscrollNotification) {
final overscroll = notification.overscroll;
final atTop =
notification.metrics.pixels <= notification.metrics.minScrollExtent;
final atBottom =
notification.metrics.pixels >= notification.metrics.maxScrollExtent;
if (atTop && overscroll < 0) {
_setOffset(
(_offsetController.value - overscroll * _dragResistance).clamp(
0,
_maxStretch,
),
);
} else if (atBottom && overscroll > 0) {
_setOffset(
(_offsetController.value - overscroll * _dragResistance).clamp(
-_maxStretch,
0,
),
);
}
}
if (notification is ScrollUpdateNotification &&
notification.dragDetails == null) {
_releaseOffset();
}
if (notification is ScrollEndNotification) {
_releaseOffset();
}
return false;
}
void _setOffset(num next) {
if (next == _offsetController.value) {
return;
}
_offsetController.stop();
_offsetController.value = next.toDouble();
}
void _releaseOffset() {
if (_offsetController.value == 0) {
return;
}
_offsetController.animateTo(
0,
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
);
}
void _onTick() {
if (mounted) {
setState(() {});
}
}
}
+134
View File
@@ -0,0 +1,134 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../routing/app_router.dart';
import '../theme/app_theme.dart';
class ReportNotebooklmApp extends ConsumerWidget {
const ReportNotebooklmApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(routerProvider);
return MaterialApp.router(
title: '研听',
debugShowCheckedModeBanner: false,
theme: buildAppTheme(),
scrollBehavior: const WhitespaceStretchScrollBehavior(),
routerConfig: router,
);
}
}
class WhitespaceStretchScrollBehavior extends MaterialScrollBehavior {
const WhitespaceStretchScrollBehavior();
@override
Widget buildOverscrollIndicator(
BuildContext context,
Widget child,
ScrollableDetails details,
) {
return _WhitespaceStretchIndicator(child: child);
}
}
class _WhitespaceStretchIndicator extends StatefulWidget {
const _WhitespaceStretchIndicator({required this.child});
final Widget child;
@override
State<_WhitespaceStretchIndicator> createState() =>
_WhitespaceStretchIndicatorState();
}
class _WhitespaceStretchIndicatorState
extends State<_WhitespaceStretchIndicator>
with SingleTickerProviderStateMixin {
static const double _maxStretch = 64;
static const double _dragResistance = 0.38;
late final AnimationController _offsetController =
AnimationController.unbounded(vsync: this)..addListener(_onTick);
@override
Widget build(BuildContext context) {
return NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: ClipRect(
child: Transform.translate(
offset: Offset(0, _offsetController.value),
child: widget.child,
),
),
);
}
@override
void dispose() {
_offsetController.dispose();
super.dispose();
}
bool _handleScrollNotification(ScrollNotification notification) {
if (notification.metrics.axis != Axis.vertical) {
return false;
}
if (notification is OverscrollNotification) {
final overscroll = notification.overscroll;
final atTop =
notification.metrics.pixels <= notification.metrics.minScrollExtent;
final atBottom =
notification.metrics.pixels >= notification.metrics.maxScrollExtent;
if (atTop && overscroll < 0) {
_setOffset(
(_offsetController.value - overscroll * _dragResistance).clamp(
0,
_maxStretch,
),
);
} else if (atBottom && overscroll > 0) {
_setOffset(
(_offsetController.value - overscroll * _dragResistance).clamp(
-_maxStretch,
0,
),
);
}
}
if (notification is ScrollUpdateNotification &&
notification.dragDetails == null) {
_releaseOffset();
}
if (notification is ScrollEndNotification) {
_releaseOffset();
}
return false;
}
void _setOffset(num next) {
if (next == _offsetController.value) {
return;
}
_offsetController.stop();
_offsetController.value = next.toDouble();
}
void _releaseOffset() {
if (_offsetController.value == 0) {
return;
}
_offsetController.animateTo(
0,
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
);
}
void _onTick() {
if (mounted) {
setState(() {});
}
}
}
+8
View File
@@ -0,0 +1,8 @@
import 'package:flutter/widgets.dart';
import 'app.dart';
Future<Widget> bootstrap() async {
WidgetsFlutterBinding.ensureInitialized();
return const ReportNotebooklmApp();
}
+96
View File
@@ -0,0 +1,96 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'models/models.dart';
import '../widgets/mini_player.dart';
class AudioPlayerController extends StateNotifier<PlayerStateModel> {
AudioPlayerController() : super(const PlayerStateModel());
Timer? _timer;
void startAudio({
required String audioId,
required String reportId,
required String title,
required int durationSec,
}) {
_timer?.cancel();
state = PlayerStateModel(
audioId: audioId,
reportId: reportId,
title: title,
durationSec: durationSec,
playing: true,
speed: state.speed,
);
_timer = Timer.periodic(const Duration(seconds: 1), (_) => _tick());
}
void startFromItem(AudioItem item) {
startAudio(
audioId: item.audioId,
reportId: item.reportId,
title: item.titleCn,
durationSec: item.durationSec,
);
}
void startModuleAudio(
String audioId,
String reportId,
String title,
int durationSec,
) {
startAudio(
audioId: audioId,
reportId: reportId,
title: title,
durationSec: durationSec,
);
}
void toggleAudio() {
if (!state.hasAudio) return;
state = state.copyWith(playing: !state.playing);
if (state.playing && _timer == null) {
_timer = Timer.periodic(const Duration(seconds: 1), (_) => _tick());
}
}
void seekAudio(int delta) {
if (!state.hasAudio) return;
state = state.copyWith(
positionSec: (state.positionSec + delta).clamp(0, state.durationSec),
);
}
void cycleSpeed() {
const speeds = [1.0, 1.25, 1.5, 2.0];
final current = speeds.indexOf(state.speed);
state = state.copyWith(speed: speeds[(current + 1) % speeds.length]);
}
void _tick() {
if (!state.playing) return;
final step = state.speed.round().clamp(1, 2);
final next = state.positionSec + step;
if (next >= state.durationSec) {
state = state.copyWith(
positionSec: state.durationSec,
playing: false,
);
_timer?.cancel();
_timer = null;
return;
}
state = state.copyWith(positionSec: next);
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
}
+27
View File
@@ -0,0 +1,27 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'models/models.dart';
import 'providers.dart';
final recommendedReportsProvider =
FutureProvider.autoDispose<List<ReportCardModel>>((ref) async {
final dataSource = ref.watch(reportDataSourceProvider);
return dataSource.recommended();
});
final reportsProvider =
FutureProvider.autoDispose<List<ReportCardModel>>((ref) async {
final dataSource = ref.watch(reportDataSourceProvider);
return dataSource.reports();
});
final institutionsProvider =
FutureProvider.autoDispose<List<Institution>>((ref) async {
final dataSource = ref.watch(reportDataSourceProvider);
return dataSource.institutions();
});
final listenProvider = FutureProvider.autoDispose<List<AudioItem>>((ref) async {
final dataSource = ref.watch(reportDataSourceProvider);
return dataSource.listen();
});
+14
View File
@@ -0,0 +1,14 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'api/report_data_source.dart';
import 'audio_player_controller.dart';
import '../widgets/mini_player.dart';
final reportDataSourceProvider = Provider<ReportDataSource>((ref) {
return RnbApiDataSource();
});
final audioPlayerControllerProvider =
StateNotifierProvider<AudioPlayerController, PlayerStateModel>((ref) {
return AudioPlayerController();
});
@@ -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,43 +170,59 @@ class ModuleDetailPage extends StatefulWidget {
final ModuleRendererRegistry registry;
@override
State<ModuleDetailPage> createState() => _ModuleDetailPageState();
}
class _ModuleDetailPageState extends State<ModuleDetailPage> {
late Future<ModuleDetail> future = widget.dataSource.moduleDetail(
widget.reportId,
widget.module.id,
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);
@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(
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,
),
);
}
final detail = snapshot.data!;
}
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 ListView(
padding: const EdgeInsets.all(WiseSpacing.x4),
children: [
AppCard(
child: widget.registry.page(
child: registry.page(
context,
detail.type,
detail.content,
report: widget.report,
report: report,
),
),
const SizedBox(height: WiseSpacing.x3),
@@ -214,9 +232,6 @@ class _ModuleDetailPageState extends State<ModuleDetailPage> {
),
],
);
},
),
);
}
}
+64 -31
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,34 +40,67 @@ class ReportDetailPage extends StatefulWidget {
final VoidCallback? onSpeed;
@override
State<ReportDetailPage> createState() => _ReportDetailPageState();
}
class _ReportDetailPageState extends State<ReportDetailPage> {
static const registry = ModuleRendererRegistry();
late Future<ReportDetail> future = widget.dataSource.reportDetail(
widget.reportId,
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();
@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(
body: snapshot.connectionState != ConnectionState.done
? const LoadingState()
: snapshot.hasError
? ErrorState(
message: snapshot.error.toString(),
onRetry: () => setState(
() => future = widget.dataSource.reportDetail(widget.reportId),
onRetry: () => retryCount.value++,
)
: _ReportDetailContent(
detail: snapshot.data!,
dataSource: dataSource,
player: player,
onStartAudio: onStartAudio,
onToggleAudio: onToggleAudio,
onSeekAudio: onSeekAudio,
onSpeed: onSpeed,
registry: registry,
),
);
}
final detail = snapshot.data!;
}
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 ListView(
padding: const EdgeInsets.all(WiseSpacing.x4),
children: [
@@ -127,20 +162,17 @@ class _ReportDetailPageState extends State<ReportDetailPage> {
context: context,
module: module,
report: detail,
dataSource: widget.dataSource,
player: widget.player,
onStartAudio: widget.onStartAudio,
onToggleAudio: widget.onToggleAudio,
onSeekAudio: widget.onSeekAudio,
onSpeed: widget.onSpeed,
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),
+53 -35
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,8 +123,12 @@ class _FeedPageState extends State<FeedPage> {
},
);
}
}
void playFromReport(void Function(AudioItem item) onPlay, ReportCardModel report) {
void _playFromReport(
void Function(AudioItem item) onPlay,
ReportCardModel report,
) {
onPlay(
AudioItem(
audioId: 'local_${report.id}',
@@ -121,4 +140,3 @@ class _FeedPageState extends State<FeedPage> {
),
);
}
}
@@ -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,29 +13,53 @@ 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);
return Scaffold(
appBar: AppBar(title: const Text('机构主页')),
body: snapshot.connectionState != ConnectionState.done
? const LoadingState()
: snapshot.hasError
? ErrorState(
message: snapshot.error.toString(),
onRetry: () => retryCount.value++,
)
: _InstitutionDetailContent(
item: snapshot.data!,
dataSource: dataSource,
),
);
}
}
class _InstitutionDetailPageState extends State<InstitutionDetailPage> {
late Future<Institution> future = widget.dataSource.institutionDetail(widget.institutionId);
class _InstitutionDetailContent extends StatelessWidget {
const _InstitutionDetailContent({
required this.item,
required this.dataSource,
});
final Institution item;
final ReportDataSource dataSource;
@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: [
@@ -43,14 +69,22 @@ class _InstitutionDetailPageState extends State<InstitutionDetailPage> {
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),
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),
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),
],
),
@@ -59,7 +93,9 @@ class _InstitutionDetailPageState extends State<InstitutionDetailPage> {
),
const SizedBox(height: WiseSpacing.x3),
if (item.introCn.isNotEmpty)
AppCard(child: Text(item.introCn, style: Theme.of(context).textTheme.bodyMedium)),
AppCard(
child: Text(item.introCn, style: Theme.of(context).textTheme.bodyMedium),
),
const SizedBox(height: WiseSpacing.x3),
if (item.credibilityNote.isNotEmpty)
AppCard(
@@ -68,7 +104,12 @@ class _InstitutionDetailPageState extends State<InstitutionDetailPage> {
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)),
Expanded(
child: Text(
item.credibilityNote,
style: Theme.of(context).textTheme.bodyMedium,
),
),
],
),
),
@@ -76,12 +117,16 @@ class _InstitutionDetailPageState extends State<InstitutionDetailPage> {
Text('最新研报', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: WiseSpacing.x3),
if (item.recentReports.isEmpty)
const EmptyState(title: '机构暂无研报', message: '稍后再试', icon: Icons.article_outlined)
const EmptyState(
title: '机构暂无研报',
message: '稍后再试',
icon: Icons.article_outlined,
)
else
for (final report in item.recentReports) ...[
ReportCardWidget(
report: report,
onTap: () => openReportDetail(context, widget.dataSource, report),
onTap: () => openReportDetail(context, dataSource, report),
),
const SizedBox(height: WiseSpacing.x3),
],
@@ -94,8 +139,5 @@ class _InstitutionDetailPageState extends State<InstitutionDetailPage> {
),
],
);
},
),
);
}
}
@@ -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();
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,
);
}
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);
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),
],
),
+44 -22
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();
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,
);
}
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);
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,
),
],
),
),
+118 -54
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,10 +152,18 @@ class _ReportsPageState extends State<ReportsPage> {
},
);
}
}
List<ReportCardModel> applyFilters(List<ReportCardModel> items) {
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();
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;
@@ -118,14 +171,18 @@ class _ReportsPageState extends State<ReportsPage> {
}).toList();
}
void openFilterSheet() {
widget.dataSource.reports().then((items) {
if (!mounted) return;
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))),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(WiseRadius.lg)),
),
builder: (context) => Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 28),
child: Column(
@@ -138,20 +195,27 @@ class _ReportsPageState extends State<ReportsPage> {
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 = '',
),
for (final t in topics)
AppChip(
label: t,
selected: topic.value == t,
onTap: () => topic.value = t,
),
],
),
const SizedBox(height: WiseSpacing.x4),
AppButton(label: '完成', expand: true, onPressed: () => Navigator.pop(context)),
AppButton(
label: '完成',
expand: true,
onPressed: () => Navigator.pop(context),
),
],
),
),
);
});
}
void selectTopic(String value) {
setState(() => topic = value);
}
}
+92 -123
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(
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: 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: '我的'),
],
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),
];
+5 -4
View File
@@ -1,12 +1,13 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'app.dart';
import 'data/api/report_data_source.dart';
import 'app/bootstrap.dart';
export 'app.dart';
export 'data/api/report_data_source.dart';
export 'data/models/models.dart';
void main() {
runApp(MyApp(dataSource: RnbApiDataSource()));
Future<void> main() async {
final app = await bootstrap();
runApp(ProviderScope(child: app));
}
+147
View File
@@ -0,0 +1,147 @@
import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../data/providers.dart';
import '../features/detail/report_detail_page.dart';
import '../features/feed/feed_page.dart';
import '../features/institutions/institution_detail_page.dart';
import '../features/institutions/institutions_page.dart';
import '../features/listen/listen_page.dart';
import '../features/profile/profile_page.dart';
import '../features/reports/reports_page.dart';
import '../features/shell_page.dart';
import '../theme/wise_tokens.dart';
import 'app_routes.dart';
final routerProvider = Provider<GoRouter>((ref) {
final dataSource = ref.read(reportDataSourceProvider);
return GoRouter(
initialLocation: AppRoutes.home,
routes: [
ShellRoute(
builder: (context, state, child) =>
ShellPage(currentPath: state.matchedLocation, child: child),
routes: [
GoRoute(
path: AppRoutes.home,
builder: (context, state) => _TabSurface(
child: Consumer(
builder: (context, ref, _) {
final player = ref.watch(audioPlayerControllerProvider);
final controller = ref.read(
audioPlayerControllerProvider.notifier,
);
return FeedPage(
dataSource: dataSource,
onPlay: controller.startFromItem,
player: player,
onStartModuleAudio: controller.startModuleAudio,
onToggleAudio: controller.toggleAudio,
onSeekAudio: controller.seekAudio,
onSpeed: controller.cycleSpeed,
);
},
),
),
),
GoRoute(
path: AppRoutes.reports,
builder: (context, state) => _TabSurface(
child: Consumer(
builder: (context, ref, _) {
final player = ref.watch(audioPlayerControllerProvider);
final controller = ref.read(
audioPlayerControllerProvider.notifier,
);
return ReportsPage(
dataSource: dataSource,
onPlay: controller.startFromItem,
player: player,
onStartModuleAudio: controller.startModuleAudio,
onToggleAudio: controller.toggleAudio,
onSeekAudio: controller.seekAudio,
onSpeed: controller.cycleSpeed,
);
},
),
),
),
GoRoute(
path: AppRoutes.institutions,
builder: (context, state) =>
_TabSurface(child: InstitutionsPage(dataSource: dataSource)),
),
GoRoute(
path: AppRoutes.listen,
builder: (context, state) => _TabSurface(
child: Consumer(
builder: (context, ref, _) {
final controller = ref.read(
audioPlayerControllerProvider.notifier,
);
return ListenPage(
dataSource: dataSource,
onPlay: controller.startFromItem,
);
},
),
),
),
GoRoute(
path: AppRoutes.profile,
builder: (context, state) =>
_TabSurface(child: ProfilePage(dataSource: dataSource)),
),
],
),
GoRoute(
path: AppRoutes.reportDetail,
builder: (context, state) {
final id = state.pathParameters['id'] ?? '';
final args = state.extra as ReportDetailRouteArgs?;
return Consumer(
builder: (context, ref, _) {
final player = ref.watch(audioPlayerControllerProvider);
final controller = ref.read(
audioPlayerControllerProvider.notifier,
);
return ReportDetailPage(
reportId: id,
dataSource: args?.dataSource ?? dataSource,
player: player,
onStartAudio: args?.onStartAudio ?? controller.startModuleAudio,
onToggleAudio: args?.onToggleAudio ?? controller.toggleAudio,
onSeekAudio: args?.onSeekAudio ?? controller.seekAudio,
onSpeed: args?.onSpeed ?? controller.cycleSpeed,
);
},
);
},
),
GoRoute(
path: AppRoutes.institutionDetail,
builder: (context, state) {
final id = state.pathParameters['id'] ?? '';
final args = state.extra as InstitutionDetailRouteArgs?;
return InstitutionDetailPage(
institutionId: id,
dataSource: args?.dataSource ?? dataSource,
);
},
),
],
);
});
class _TabSurface extends StatelessWidget {
const _TabSurface({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return ColoredBox(color: WiseColors.canvas, child: child);
}
}
+57 -15
View File
@@ -1,25 +1,72 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../data/api/report_data_source.dart';
import '../data/models/models.dart';
import '../features/detail/report_detail_page.dart';
import '../features/institutions/institution_detail_page.dart';
import '../widgets/mini_player.dart';
abstract final class AppRoutes {
static const home = '/';
static const reports = '/reports';
static const institutions = '/institutions';
static const listen = '/listen';
static const profile = '/profile';
static const reportDetail = '/reports/:id';
static const institutionDetail = '/institutions/:id';
static String reportDetailPath(String id) => '/reports/$id';
static String institutionDetailPath(String id) => '/institutions/$id';
}
class ReportDetailRouteArgs {
const ReportDetailRouteArgs({
required this.dataSource,
required this.player,
required this.onStartAudio,
required this.onToggleAudio,
required this.onSeekAudio,
required this.onSpeed,
});
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;
}
class InstitutionDetailRouteArgs {
const InstitutionDetailRouteArgs({required this.dataSource});
final ReportDataSource dataSource;
}
void openReportDetail(
BuildContext context,
ReportDataSource dataSource,
ReportCardModel report, {
PlayerStateModel player = const PlayerStateModel(),
void Function(String audioId, String reportId, String title, int durationSec)? onStartAudio,
void Function(
String audioId,
String reportId,
String title,
int durationSec,
)?
onStartAudio,
VoidCallback? onToggleAudio,
void Function(int delta)? onSeekAudio,
VoidCallback? onSpeed,
}) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ReportDetailPage(
reportId: report.id,
context.push(
AppRoutes.reportDetailPath(report.id),
extra: ReportDetailRouteArgs(
dataSource: dataSource,
player: player,
onStartAudio: onStartAudio,
@@ -27,7 +74,6 @@ void openReportDetail(
onSeekAudio: onSeekAudio,
onSpeed: onSpeed,
),
),
);
}
@@ -36,12 +82,8 @@ void openInstitutionDetail(
ReportDataSource dataSource,
String institutionId,
) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => InstitutionDetailPage(
institutionId: institutionId,
dataSource: dataSource,
),
),
context.push(
AppRoutes.institutionDetailPath(institutionId),
extra: InstitutionDetailRouteArgs(dataSource: dataSource),
);
}
+10
View File
@@ -0,0 +1,10 @@
import 'package:flutter/widgets.dart';
import 'package:phosphor_flutter/phosphor_flutter.dart';
abstract final class AppIcons {
static const IconData sparkle = PhosphorIconsRegular.sparkle;
static const IconData article = PhosphorIconsRegular.article;
static const IconData bank = PhosphorIconsRegular.bank;
static const IconData headphones = PhosphorIconsRegular.headphones;
static const IconData user = PhosphorIconsRegular.user;
}
+2
View File
@@ -0,0 +1,2 @@
export 'app_theme.dart';
export 'wise_tokens.dart';
+6
View File
@@ -0,0 +1,6 @@
export 'app_buttons.dart';
export 'app_card.dart';
export 'badges.dart';
export 'mini_player.dart';
export 'sheets.dart';
export 'states.dart';
+83 -1
View File
@@ -62,6 +62,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_hooks:
dependency: "direct main"
description:
name: flutter_hooks
sha256: "8ae1f090e5f4ef5cfa6670ce1ab5dddadd33f3533a7f9ba19d9f958aa2a89f42"
url: "https://pub.dev"
source: hosted
version: "0.21.3+1"
flutter_lints:
dependency: "direct dev"
description:
@@ -70,11 +78,45 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_localizations:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_riverpod:
dependency: transitive
description:
name: flutter_riverpod
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
go_router:
dependency: "direct main"
description:
name: go_router
sha256: d8f590a69729f719177ea68eb1e598295e8dbc41bbc247fed78b2c8a25660d7c
url: "https://pub.dev"
source: hosted
version: "16.3.0"
hooks_riverpod:
dependency: "direct main"
description:
name: hooks_riverpod
sha256: "70bba33cfc5670c84b796e6929c54b8bc5be7d0fe15bb28c2560500b9ad06966"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
http:
dependency: "direct main"
description:
@@ -91,6 +133,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
intl:
dependency: transitive
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.20.2"
leak_tracker:
dependency: transitive
description:
@@ -123,6 +173,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
@@ -155,6 +213,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
phosphor_flutter:
dependency: "direct main"
description:
name: phosphor_flutter
sha256: "8a14f238f28a0b54842c5a4dc20676598dd4811fcba284ed828bd5a262c11fde"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
riverpod:
dependency: transitive
description:
name: riverpod
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
sky_engine:
dependency: transitive
description: flutter
@@ -176,6 +250,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.12.1"
state_notifier:
dependency: transitive
description:
name: state_notifier
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
url: "https://pub.dev"
source: hosted
version: "1.0.0"
stream_channel:
dependency: transitive
description:
@@ -242,4 +324,4 @@ packages:
version: "1.1.1"
sdks:
dart: ">=3.9.0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"
flutter: ">=3.32.0"
+6
View File
@@ -29,11 +29,17 @@ environment:
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
http: ^1.6.0
flutter_hooks: ^0.21.3+1
go_router: ^16.2.4
hooks_riverpod: ^2.6.1
phosphor_flutter: ^2.1.0
dev_dependencies:
flutter_test: