From e2554edfabef41a32c4f857f1e037e4f6691ebea Mon Sep 17 00:00:00 2001 From: jingyun <> Date: Wed, 3 Jun 2026 16:29:53 +0800 Subject: [PATCH] =?UTF-8?q?fix=EF=BC=9A=E4=BC=98=E5=8C=96=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E5=B8=B8=E7=94=A8=E6=8A=80=E6=9C=AF=E6=A1=86=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/app.dart | 133 +---------- lib/app/app.dart | 134 +++++++++++ lib/app/bootstrap.dart | 8 + lib/data/audio_player_controller.dart | 96 ++++++++ lib/data/content_providers.dart | 27 +++ lib/data/providers.dart | 14 ++ .../detail/modules/renderer_registry.dart | 103 ++++---- lib/features/detail/report_detail_page.dart | 219 +++++++++-------- lib/features/feed/feed_page.dart | 110 +++++---- .../institutions/institution_detail_page.dart | 194 +++++++++------ .../institutions/institutions_page.dart | 79 ++++-- lib/features/listen/listen_page.dart | 68 ++++-- lib/features/reports/reports_page.dart | 224 +++++++++++------- lib/features/shell_page.dart | 223 ++++++++--------- lib/main.dart | 9 +- lib/routing/app_router.dart | 147 ++++++++++++ lib/routing/app_routes.dart | 84 +++++-- lib/theme/app_icons.dart | 10 + lib/theme/theme.dart | 2 + lib/widgets/widgets.dart | 6 + pubspec.lock | 84 ++++++- pubspec.yaml | 6 + 22 files changed, 1319 insertions(+), 661 deletions(-) create mode 100644 lib/app/app.dart create mode 100644 lib/app/bootstrap.dart create mode 100644 lib/data/audio_player_controller.dart create mode 100644 lib/data/content_providers.dart create mode 100644 lib/data/providers.dart create mode 100644 lib/routing/app_router.dart create mode 100644 lib/theme/app_icons.dart create mode 100644 lib/theme/theme.dart create mode 100644 lib/widgets/widgets.dart diff --git a/lib/app.dart b/lib/app.dart index bc3a79c..00a1757 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -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( - 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(() {}); - } - } -} diff --git a/lib/app/app.dart b/lib/app/app.dart new file mode 100644 index 0000000..6e31942 --- /dev/null +++ b/lib/app/app.dart @@ -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( + 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(() {}); + } + } +} diff --git a/lib/app/bootstrap.dart b/lib/app/bootstrap.dart new file mode 100644 index 0000000..3f06ce3 --- /dev/null +++ b/lib/app/bootstrap.dart @@ -0,0 +1,8 @@ +import 'package:flutter/widgets.dart'; + +import 'app.dart'; + +Future bootstrap() async { + WidgetsFlutterBinding.ensureInitialized(); + return const ReportNotebooklmApp(); +} diff --git a/lib/data/audio_player_controller.dart b/lib/data/audio_player_controller.dart new file mode 100644 index 0000000..56e65cc --- /dev/null +++ b/lib/data/audio_player_controller.dart @@ -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 { + 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(); + } +} diff --git a/lib/data/content_providers.dart b/lib/data/content_providers.dart new file mode 100644 index 0000000..7229315 --- /dev/null +++ b/lib/data/content_providers.dart @@ -0,0 +1,27 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'models/models.dart'; +import 'providers.dart'; + +final recommendedReportsProvider = + FutureProvider.autoDispose>((ref) async { + final dataSource = ref.watch(reportDataSourceProvider); + return dataSource.recommended(); +}); + +final reportsProvider = + FutureProvider.autoDispose>((ref) async { + final dataSource = ref.watch(reportDataSourceProvider); + return dataSource.reports(); +}); + +final institutionsProvider = + FutureProvider.autoDispose>((ref) async { + final dataSource = ref.watch(reportDataSourceProvider); + return dataSource.institutions(); +}); + +final listenProvider = FutureProvider.autoDispose>((ref) async { + final dataSource = ref.watch(reportDataSourceProvider); + return dataSource.listen(); +}); diff --git a/lib/data/providers.dart b/lib/data/providers.dart new file mode 100644 index 0000000..a3c3525 --- /dev/null +++ b/lib/data/providers.dart @@ -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((ref) { + return RnbApiDataSource(); +}); + +final audioPlayerControllerProvider = + StateNotifierProvider((ref) { + return AudioPlayerController(); +}); diff --git a/lib/features/detail/modules/renderer_registry.dart b/lib/features/detail/modules/renderer_registry.dart index b988dd1..3b57454 100644 --- a/lib/features/detail/modules/renderer_registry.dart +++ b/lib/features/detail/modules/renderer_registry.dart @@ -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 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 { - late Future 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( - 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, + ), + ], ); } } diff --git a/lib/features/detail/report_detail_page.dart b/lib/features/detail/report_detail_page.dart index f173e2c..1cc9845 100644 --- a/lib/features/detail/report_detail_page.dart +++ b/lib/features/detail/report_detail_page.dart @@ -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 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 { - static const registry = ModuleRendererRegistry(); - late Future 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( - 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), diff --git a/lib/features/feed/feed_page.dart b/lib/features/feed/feed_page.dart index 7a6ea91..88829a5 100644 --- a/lib/features/feed/feed_page.dart +++ b/lib/features/feed/feed_page.dart @@ -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 createState() => _FeedPageState(); -} + Widget build(BuildContext context, WidgetRef ref) { + final topic = useState('全部'); + final snapshot = ref.watch(recommendedReportsProvider); -class _FeedPageState extends State { - String topic = '全部'; - late Future> future = widget.dataSource.recommended(); - - @override - Widget build(BuildContext context) { - return FutureBuilder>( - 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 { 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 { 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 { }, ); } - - 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, + ), + ); } diff --git a/lib/features/institutions/institution_detail_page.dart b/lib/features/institutions/institution_detail_page.dart index fd7ec78..d7ea719 100644 --- a/lib/features/institutions/institution_detail_page.dart +++ b/lib/features/institutions/institution_detail_page.dart @@ -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 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 { - late Future future = widget.dataSource.institutionDetail(widget.institutionId); - - @override - Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('机构主页')), - body: FutureBuilder( - 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), + ), + ], ); } } diff --git a/lib/features/institutions/institutions_page.dart b/lib/features/institutions/institutions_page.dart index 9d160a3..7a6b439 100644 --- a/lib/features/institutions/institutions_page.dart +++ b/lib/features/institutions/institutions_page.dart @@ -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 createState() => _InstitutionsPageState(); -} - -class _InstitutionsPageState extends State { - late Future> future = widget.dataSource.institutions(); - - @override - Widget build(BuildContext context) { - return FutureBuilder>( - 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 []]..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), ], ), diff --git a/lib/features/listen/listen_page.dart b/lib/features/listen/listen_page.dart index 62ffbbf..4d89a4e 100644 --- a/lib/features/listen/listen_page.dart +++ b/lib/features/listen/listen_page.dart @@ -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 createState() => _ListenPageState(); -} - -class _ListenPageState extends State { - late Future> future = widget.dataSource.listen(); - - @override - Widget build(BuildContext context) { - return FutureBuilder>( - 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, + ), ], ), ), diff --git a/lib/features/reports/reports_page.dart b/lib/features/reports/reports_page.dart index c8e0b89..3a7df9f 100644 --- a/lib/features/reports/reports_page.dart +++ b/lib/features/reports/reports_page.dart @@ -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 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 { - late Future> 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>( - 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 { 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 { }, ); } +} - List applyFilters(List 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 _applyFilters( + List 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( - 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 items, + required ValueNotifier topic, +}) { + final topics = {for (final item in items) ...item.topics}.toList(); + showModalBottomSheet( + 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), + ), + ], + ), + ), + ); } diff --git a/lib/features/shell_page.dart b/lib/features/shell_page.dart index 30886b5..6da08b7 100644 --- a/lib/features/shell_page.dart +++ b/lib/features/shell_page.dart @@ -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 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 { - 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 { 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), +]; diff --git a/lib/main.dart b/lib/main.dart index e44cf7d..dcdc57d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 main() async { + final app = await bootstrap(); + runApp(ProviderScope(child: app)); } diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart new file mode 100644 index 0000000..8510090 --- /dev/null +++ b/lib/routing/app_router.dart @@ -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((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); + } +} diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart index b597ea1..b6a3ddb 100644 --- a/lib/routing/app_routes.dart +++ b/lib/routing/app_routes.dart @@ -1,32 +1,78 @@ 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, - dataSource: dataSource, - player: player, - onStartAudio: onStartAudio, - onToggleAudio: onToggleAudio, - onSeekAudio: onSeekAudio, - onSpeed: onSpeed, - ), + context.push( + AppRoutes.reportDetailPath(report.id), + extra: ReportDetailRouteArgs( + dataSource: dataSource, + player: player, + onStartAudio: onStartAudio, + onToggleAudio: onToggleAudio, + 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), ); } diff --git a/lib/theme/app_icons.dart b/lib/theme/app_icons.dart new file mode 100644 index 0000000..c149ebf --- /dev/null +++ b/lib/theme/app_icons.dart @@ -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; +} diff --git a/lib/theme/theme.dart b/lib/theme/theme.dart new file mode 100644 index 0000000..fc76896 --- /dev/null +++ b/lib/theme/theme.dart @@ -0,0 +1,2 @@ +export 'app_theme.dart'; +export 'wise_tokens.dart'; diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart new file mode 100644 index 0000000..c12f4fd --- /dev/null +++ b/lib/widgets/widgets.dart @@ -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'; diff --git a/pubspec.lock b/pubspec.lock index dcfd6a8..914dd91 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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" diff --git a/pubspec.yaml b/pubspec.yaml index 1a2c6ed..e2652bb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: