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:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'app/app.dart';
import 'data/api/report_data_source.dart'; import 'data/api/report_data_source.dart';
import 'features/shell_page.dart'; import 'data/providers.dart';
import 'theme/app_theme.dart';
export 'app/app.dart';
export 'data/api/report_data_source.dart';
export 'data/models/models.dart';
class MyApp extends StatelessWidget { class MyApp extends StatelessWidget {
const MyApp({required this.dataSource, super.key}); const MyApp({required this.dataSource, super.key});
@@ -11,125 +16,11 @@ class MyApp extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return ProviderScope(
title: '研听', overrides: [
debugShowCheckedModeBanner: false, reportDataSourceProvider.overrideWithValue(dataSource),
theme: buildAppTheme(), ],
scrollBehavior: const WhitespaceStretchScrollBehavior(), child: const ReportNotebooklmApp(),
home: ShellPage(dataSource: dataSource),
); );
} }
} }
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/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/api/report_data_source.dart';
import '../../../data/models/models.dart'; import '../../../data/models/models.dart';
@@ -151,7 +153,7 @@ class ModuleRendererRegistry {
} }
} }
class ModuleDetailPage extends StatefulWidget { class ModuleDetailPage extends HookConsumerWidget {
const ModuleDetailPage({ const ModuleDetailPage({
required this.reportId, required this.reportId,
required this.module, required this.module,
@@ -168,43 +170,59 @@ class ModuleDetailPage extends StatefulWidget {
final ModuleRendererRegistry registry; final ModuleRendererRegistry registry;
@override @override
State<ModuleDetailPage> createState() => _ModuleDetailPageState(); Widget build(BuildContext context, WidgetRef ref) {
} final retryCount = useState(0);
final future = useMemoized(
class _ModuleDetailPageState extends State<ModuleDetailPage> { () => dataSource.moduleDetail(reportId, module.id),
late Future<ModuleDetail> future = widget.dataSource.moduleDetail( [dataSource, reportId, module.id, retryCount.value],
widget.reportId,
widget.module.id,
); );
final snapshot = useFuture(future);
@override
Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(widget.module.titleCn)), appBar: AppBar(title: Text(module.titleCn)),
body: FutureBuilder<ModuleDetail>( body: snapshot.connectionState != ConnectionState.done
future: future, ? const Center(child: CircularProgressIndicator())
builder: (context, snapshot) { : snapshot.hasError
if (snapshot.connectionState != ConnectionState.done) { ? Center(
return const Center(child: CircularProgressIndicator()); child: TextButton(
} onPressed: () => retryCount.value++,
if (snapshot.hasError) {
return Center(
child: Text( child: Text(
snapshot.error.toString(), snapshot.error.toString(),
textAlign: TextAlign.center, 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( return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4), padding: const EdgeInsets.all(WiseSpacing.x4),
children: [ children: [
AppCard( AppCard(
child: widget.registry.page( child: registry.page(
context, context,
detail.type, detail.type,
detail.content, detail.content,
report: widget.report, report: report,
), ),
), ),
const SizedBox(height: WiseSpacing.x3), 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/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/api/report_data_source.dart';
import '../../data/models/models.dart'; import '../../data/models/models.dart';
@@ -11,7 +13,7 @@ import '../../widgets/sheets.dart';
import '../../widgets/states.dart'; import '../../widgets/states.dart';
import 'modules/renderer_registry.dart'; import 'modules/renderer_registry.dart';
class ReportDetailPage extends StatefulWidget { class ReportDetailPage extends HookConsumerWidget {
const ReportDetailPage({ const ReportDetailPage({
required this.reportId, required this.reportId,
required this.dataSource, required this.dataSource,
@@ -38,34 +40,67 @@ class ReportDetailPage extends StatefulWidget {
final VoidCallback? onSpeed; final VoidCallback? onSpeed;
@override @override
State<ReportDetailPage> createState() => _ReportDetailPageState(); Widget build(BuildContext context, WidgetRef ref) {
} final retryCount = useState(0);
final detailFuture = useMemoized(
class _ReportDetailPageState extends State<ReportDetailPage> { () => dataSource.reportDetail(reportId),
static const registry = ModuleRendererRegistry(); [dataSource, reportId, retryCount.value],
late Future<ReportDetail> future = widget.dataSource.reportDetail(
widget.reportId,
); );
final snapshot = useFuture(detailFuture);
const registry = ModuleRendererRegistry();
@override
Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('研报详情')), appBar: AppBar(title: const Text('研报详情')),
body: FutureBuilder<ReportDetail>( body: snapshot.connectionState != ConnectionState.done
future: future, ? const LoadingState()
builder: (context, snapshot) { : snapshot.hasError
if (snapshot.connectionState != ConnectionState.done) { ? ErrorState(
return const LoadingState();
}
if (snapshot.hasError) {
return ErrorState(
message: snapshot.error.toString(), message: snapshot.error.toString(),
onRetry: () => setState( onRetry: () => retryCount.value++,
() => future = widget.dataSource.reportDetail(widget.reportId), )
: _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( return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4), padding: const EdgeInsets.all(WiseSpacing.x4),
children: [ children: [
@@ -127,20 +162,17 @@ class _ReportDetailPageState extends State<ReportDetailPage> {
context: context, context: context,
module: module, module: module,
report: detail, report: detail,
dataSource: widget.dataSource, dataSource: dataSource,
player: widget.player, player: player,
onStartAudio: widget.onStartAudio, onStartAudio: onStartAudio,
onToggleAudio: widget.onToggleAudio, onToggleAudio: onToggleAudio,
onSeekAudio: widget.onSeekAudio, onSeekAudio: onSeekAudio,
onSpeed: widget.onSpeed, onSpeed: onSpeed,
), ),
const SizedBox(height: WiseSpacing.x4), const SizedBox(height: WiseSpacing.x4),
], ],
], ],
); );
},
),
);
} }
} }
@@ -158,7 +190,8 @@ class _ActionBar extends StatelessWidget {
label: '收藏', label: '收藏',
icon: Icons.favorite_border, icon: Icons.favorite_border,
kind: AppButtonKind.ghost, kind: AppButtonKind.ghost,
onPressed: () => showLoginSheet(context, reason: '登录后保存到你的收藏'), onPressed: () =>
showLoginSheet(context, reason: '登录后保存到你的收藏'),
), ),
), ),
const SizedBox(width: WiseSpacing.x2), const SizedBox(width: WiseSpacing.x2),
+53 -35
View File
@@ -1,6 +1,9 @@
import 'package:flutter/material.dart'; 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/api/report_data_source.dart';
import '../../data/content_providers.dart';
import '../../data/models/models.dart'; import '../../data/models/models.dart';
import '../../routing/app_routes.dart'; import '../../routing/app_routes.dart';
import '../../theme/wise_tokens.dart'; import '../../theme/wise_tokens.dart';
@@ -9,7 +12,7 @@ import '../../widgets/mini_player.dart';
import '../../widgets/states.dart'; import '../../widgets/states.dart';
import '../shared/report_card_widget.dart'; import '../shared/report_card_widget.dart';
class FeedPage extends StatefulWidget { class FeedPage extends HookConsumerWidget {
const FeedPage({ const FeedPage({
required this.dataSource, required this.dataSource,
required this.onPlay, required this.onPlay,
@@ -30,24 +33,28 @@ class FeedPage extends StatefulWidget {
final VoidCallback? onSpeed; final VoidCallback? onSpeed;
@override @override
State<FeedPage> createState() => _FeedPageState(); Widget build(BuildContext context, WidgetRef ref) {
} final topic = useState('全部');
final snapshot = ref.watch(recommendedReportsProvider);
class _FeedPageState extends State<FeedPage> { return snapshot.when(
String topic = '全部'; loading: () => const LoadingState(),
late Future<List<ReportCardModel>> future = widget.dataSource.recommended(); error: (error, _) => ErrorState(
message: error.toString(),
@override onRetry: () => ref.invalidate(recommendedReportsProvider),
Widget build(BuildContext context) { ),
return FutureBuilder<List<ReportCardModel>>( data: (items) {
future: future, final currentTopic = topic.value;
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 [];
final topics = ['全部', ...{for (final item in items) ...item.topics}]; final topics = ['全部', ...{for (final item in items) ...item.topics}];
final visible = topic == '全部' ? items : items.where((item) => item.topics.contains(topic)).toList(); final visible = currentTopic == '全部'
if (items.isEmpty) return const EmptyState(title: '暂无可推荐的研报解读', message: '稍后再来看看最新内容'); ? items
: items.where((item) => item.topics.contains(currentTopic)).toList();
if (items.isEmpty) {
return const EmptyState(
title: '暂无可推荐的研报解读',
message: '稍后再来看看最新内容',
);
}
return ListView( return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4), padding: const EdgeInsets.all(WiseSpacing.x4),
children: [ children: [
@@ -58,29 +65,37 @@ class _FeedPageState extends State<FeedPage> {
for (final t in topics) for (final t in topics)
Padding( Padding(
padding: const EdgeInsets.only(right: WiseSpacing.x2), 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), const SizedBox(height: WiseSpacing.x3),
if (visible.isEmpty) if (visible.isEmpty)
EmptyState(title: '暂无可推荐的研报解读', message: '换个主题,或去研报页看看全部内容', icon: Icons.filter_alt_off) const EmptyState(
title: '暂无可推荐的研报解读',
message: '换个主题,或去研报页看看全部内容',
icon: Icons.filter_alt_off,
)
else ...[ else ...[
ReportCardWidget( ReportCardWidget(
report: visible.first, report: visible.first,
hero: true, hero: true,
onTap: () => openReportDetail( onTap: () => openReportDetail(
context, context,
widget.dataSource, dataSource,
visible.first, visible.first,
player: widget.player, player: player,
onStartAudio: widget.onStartModuleAudio, onStartAudio: onStartModuleAudio,
onToggleAudio: widget.onToggleAudio, onToggleAudio: onToggleAudio,
onSeekAudio: widget.onSeekAudio, onSeekAudio: onSeekAudio,
onSpeed: widget.onSpeed, onSpeed: onSpeed,
), ),
onPlayTap: () => playFromReport(widget.onPlay, visible.first), onPlayTap: () => _playFromReport(onPlay, visible.first),
), ),
const SizedBox(height: WiseSpacing.x5), const SizedBox(height: WiseSpacing.x5),
Text('最新解读', style: Theme.of(context).textTheme.titleMedium), Text('最新解读', style: Theme.of(context).textTheme.titleMedium),
@@ -90,15 +105,15 @@ class _FeedPageState extends State<FeedPage> {
report: report, report: report,
onTap: () => openReportDetail( onTap: () => openReportDetail(
context, context,
widget.dataSource, dataSource,
report, report,
player: widget.player, player: player,
onStartAudio: widget.onStartModuleAudio, onStartAudio: onStartModuleAudio,
onToggleAudio: widget.onToggleAudio, onToggleAudio: onToggleAudio,
onSeekAudio: widget.onSeekAudio, onSeekAudio: onSeekAudio,
onSpeed: widget.onSpeed, onSpeed: onSpeed,
), ),
onPlayTap: () => playFromReport(widget.onPlay, report), onPlayTap: () => _playFromReport(onPlay, report),
), ),
const SizedBox(height: WiseSpacing.x3), 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( onPlay(
AudioItem( AudioItem(
audioId: 'local_${report.id}', audioId: 'local_${report.id}',
@@ -120,5 +139,4 @@ class _FeedPageState extends State<FeedPage> {
institution: report.institution, institution: report.institution,
), ),
); );
}
} }
@@ -1,4 +1,6 @@
import 'package:flutter/material.dart'; 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/api/report_data_source.dart';
import '../../data/models/models.dart'; import '../../data/models/models.dart';
@@ -11,29 +13,53 @@ import '../../widgets/sheets.dart';
import '../../widgets/states.dart'; import '../../widgets/states.dart';
import '../shared/report_card_widget.dart'; import '../shared/report_card_widget.dart';
class InstitutionDetailPage extends StatefulWidget { class InstitutionDetailPage extends HookConsumerWidget {
const InstitutionDetailPage({required this.institutionId, required this.dataSource, super.key}); const InstitutionDetailPage({
required this.institutionId,
required this.dataSource,
super.key,
});
final String institutionId; final String institutionId;
final ReportDataSource dataSource; final ReportDataSource dataSource;
@override @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> { class _InstitutionDetailContent extends StatelessWidget {
late Future<Institution> future = widget.dataSource.institutionDetail(widget.institutionId); const _InstitutionDetailContent({
required this.item,
required this.dataSource,
});
final Institution item;
final ReportDataSource dataSource;
@override @override
Widget build(BuildContext context) { 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( return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4), padding: const EdgeInsets.all(WiseSpacing.x4),
children: [ children: [
@@ -43,14 +69,22 @@ class _InstitutionDetailPageState extends State<InstitutionDetailPage> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(item.nameCn, style: Theme.of(context).textTheme.headlineSmall), 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), const SizedBox(height: WiseSpacing.x3),
Wrap( Wrap(
spacing: WiseSpacing.x2, spacing: WiseSpacing.x2,
runSpacing: WiseSpacing.x2, runSpacing: WiseSpacing.x2,
children: [ children: [
AppBadge(text: item.sourceTier, icon: Icons.verified_outlined, kind: BadgeKind.tier), AppBadge(
AppBadge(text: '${item.reportCount} 份研报', kind: BadgeKind.brand), 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), for (final topic in item.coveredTopics) AppBadge(text: topic),
], ],
), ),
@@ -59,7 +93,9 @@ class _InstitutionDetailPageState extends State<InstitutionDetailPage> {
), ),
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: WiseSpacing.x3),
if (item.introCn.isNotEmpty) 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), const SizedBox(height: WiseSpacing.x3),
if (item.credibilityNote.isNotEmpty) if (item.credibilityNote.isNotEmpty)
AppCard( AppCard(
@@ -68,7 +104,12 @@ class _InstitutionDetailPageState extends State<InstitutionDetailPage> {
children: [ children: [
const Icon(Icons.verified_user_outlined, color: WiseColors.positive), const Icon(Icons.verified_user_outlined, color: WiseColors.positive),
const SizedBox(width: WiseSpacing.x2), 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), Text('最新研报', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: WiseSpacing.x3),
if (item.recentReports.isEmpty) if (item.recentReports.isEmpty)
const EmptyState(title: '机构暂无研报', message: '稍后再试', icon: Icons.article_outlined) const EmptyState(
title: '机构暂无研报',
message: '稍后再试',
icon: Icons.article_outlined,
)
else else
for (final report in item.recentReports) ...[ for (final report in item.recentReports) ...[
ReportCardWidget( ReportCardWidget(
report: report, report: report,
onTap: () => openReportDetail(context, widget.dataSource, report), onTap: () => openReportDetail(context, dataSource, report),
), ),
const SizedBox(height: WiseSpacing.x3), 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:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../data/api/report_data_source.dart'; import '../../data/api/report_data_source.dart';
import '../../data/content_providers.dart';
import '../../data/models/models.dart'; import '../../data/models/models.dart';
import '../../routing/app_routes.dart'; import '../../routing/app_routes.dart';
import '../../theme/wise_tokens.dart'; import '../../theme/wise_tokens.dart';
@@ -8,36 +10,43 @@ import '../../widgets/app_card.dart';
import '../../widgets/badges.dart'; import '../../widgets/badges.dart';
import '../../widgets/states.dart'; import '../../widgets/states.dart';
class InstitutionsPage extends StatefulWidget { class InstitutionsPage extends HookConsumerWidget {
const InstitutionsPage({required this.dataSource, super.key}); const InstitutionsPage({required this.dataSource, super.key});
final ReportDataSource dataSource; final ReportDataSource dataSource;
@override @override
State<InstitutionsPage> createState() => _InstitutionsPageState(); Widget build(BuildContext context, WidgetRef ref) {
} final snapshot = ref.watch(institutionsProvider);
return snapshot.when(
class _InstitutionsPageState extends State<InstitutionsPage> { loading: () => const LoadingState(),
late Future<List<Institution>> future = widget.dataSource.institutions(); error: (error, _) => ErrorState(
message: error.toString(),
@override onRetry: () => ref.invalidate(institutionsProvider),
Widget build(BuildContext context) { ),
return FutureBuilder<List<Institution>>( data: (items) {
future: future, final sorted = [...items]
builder: (context, snapshot) { ..sort((a, b) => b.reportCount.compareTo(a.reportCount));
if (snapshot.connectionState != ConnectionState.done) return const LoadingState(); if (sorted.isEmpty) {
if (snapshot.hasError) return ErrorState(message: snapshot.error.toString(), onRetry: () => setState(() => future = widget.dataSource.institutions())); return const EmptyState(
final items = [...snapshot.data ?? const <Institution>[]]..sort((a, b) => b.reportCount.compareTo(a.reportCount)); title: '暂无机构信息',
if (items.isEmpty) return const EmptyState(title: '暂无机构信息', message: '稍后再试', icon: Icons.account_balance_outlined); message: '稍后再试',
icon: Icons.account_balance_outlined,
);
}
return ListView( return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4), padding: const EdgeInsets.all(WiseSpacing.x4),
children: [ children: [
Text('研报来源机构', style: Theme.of(context).textTheme.titleLarge), Text('研报来源机构', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: WiseSpacing.x3),
for (final item in items) ...[ for (final item in sorted) ...[
InstitutionCard( InstitutionCard(
institution: item, institution: item,
onTap: () => openInstitutionDetail(context, widget.dataSource, item.id), onTap: () => openInstitutionDetail(
context,
dataSource,
item.id,
),
), ),
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: WiseSpacing.x3),
], ],
@@ -56,7 +65,9 @@ class InstitutionCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { 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( return AppCard(
onTap: onTap, onTap: onTap,
child: Row( child: Row(
@@ -66,23 +77,36 @@ class InstitutionCard extends StatelessWidget {
radius: 25, radius: 25,
backgroundColor: WiseColors.secondary200, backgroundColor: WiseColors.secondary200,
foregroundColor: WiseColors.primary, 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), const SizedBox(width: WiseSpacing.x3),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(institution.nameCn, style: Theme.of(context).textTheme.titleMedium), Text(
institution.nameCn,
style: Theme.of(context).textTheme.titleMedium,
),
if (institution.nameEn.isNotEmpty) 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), const SizedBox(height: WiseSpacing.x2),
Wrap( Wrap(
spacing: WiseSpacing.x2, spacing: WiseSpacing.x2,
runSpacing: WiseSpacing.x2, runSpacing: WiseSpacing.x2,
children: [ children: [
if (institution.institutionType.isNotEmpty) AppBadge(text: institution.institutionType), if (institution.institutionType.isNotEmpty)
for (final topic in institution.coveredTopics.take(3)) AppBadge(text: topic, kind: BadgeKind.brand), 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), const SizedBox(width: WiseSpacing.x2),
Column( Column(
children: [ 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), Text('份研报', style: Theme.of(context).textTheme.bodySmall),
], ],
), ),
+45 -23
View File
@@ -1,59 +1,81 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../data/api/report_data_source.dart'; import '../../data/api/report_data_source.dart';
import '../../data/content_providers.dart';
import '../../data/models/models.dart'; import '../../data/models/models.dart';
import '../../theme/wise_tokens.dart'; import '../../theme/wise_tokens.dart';
import '../../widgets/app_card.dart'; import '../../widgets/app_card.dart';
import '../../widgets/states.dart'; import '../../widgets/states.dart';
class ListenPage extends StatefulWidget { class ListenPage extends HookConsumerWidget {
const ListenPage({required this.dataSource, required this.onPlay, super.key}); const ListenPage({required this.dataSource, required this.onPlay, super.key});
final ReportDataSource dataSource; final ReportDataSource dataSource;
final void Function(AudioItem item) onPlay; final void Function(AudioItem item) onPlay;
@override @override
State<ListenPage> createState() => _ListenPageState(); Widget build(BuildContext context, WidgetRef ref) {
} final snapshot = ref.watch(listenProvider);
return snapshot.when(
class _ListenPageState extends State<ListenPage> { loading: () => const LoadingState(label: '正在加载听单'),
late Future<List<AudioItem>> future = widget.dataSource.listen(); error: (error, _) => ErrorState(
message: error.toString(),
@override onRetry: () => ref.invalidate(listenProvider),
Widget build(BuildContext context) { ),
return FutureBuilder<List<AudioItem>>( data: (items) {
future: future, if (items.isEmpty) {
builder: (context, snapshot) { return const EmptyState(
if (snapshot.connectionState != ConnectionState.done) return const LoadingState(label: '正在加载听单'); title: '暂无音频研报',
if (snapshot.hasError) return ErrorState(message: snapshot.error.toString(), onRetry: () => setState(() => future = widget.dataSource.listen())); message: '先去研报页看看图文解读',
final items = snapshot.data ?? const []; icon: Icons.headphones_outlined,
if (items.isEmpty) return const EmptyState(title: '暂无音频研报', message: '先去研报页看看图文解读', icon: Icons.headphones_outlined); );
}
return ListView( return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4), padding: const EdgeInsets.all(WiseSpacing.x4),
children: [ children: [
Text('全站音频解读', style: Theme.of(context).textTheme.titleLarge), Text('全站音频解读', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: WiseSpacing.x2), const SizedBox(height: WiseSpacing.x2),
Text('游客可完整收听;真实音频流待后端接入。', style: Theme.of(context).textTheme.bodyMedium), Text(
'游客可完整收听;真实音频流待后端接入。',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: WiseSpacing.x4), const SizedBox(height: WiseSpacing.x4),
for (final item in items) ...[ for (final item in items) ...[
AppCard( AppCard(
onTap: () => widget.onPlay(item), onTap: () => onPlay(item),
child: Row( child: Row(
children: [ children: [
IconButton.filled( IconButton.filled(
onPressed: () => widget.onPlay(item), onPressed: () => onPlay(item),
icon: const Icon(Icons.play_arrow), 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), const SizedBox(width: WiseSpacing.x3),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(item.reportTitleCn, maxLines: 2, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleMedium), Text(
Text('${item.institution.nameCn} · ${formatDuration(item.durationSec)}', style: Theme.of(context).textTheme.bodySmall), 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), 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,
),
], ],
), ),
), ),
+119 -55
View File
@@ -1,6 +1,9 @@
import 'package:flutter/material.dart'; 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/api/report_data_source.dart';
import '../../data/content_providers.dart';
import '../../data/models/models.dart'; import '../../data/models/models.dart';
import '../../routing/app_routes.dart'; import '../../routing/app_routes.dart';
import '../../theme/wise_tokens.dart'; import '../../theme/wise_tokens.dart';
@@ -10,7 +13,7 @@ import '../../widgets/mini_player.dart';
import '../../widgets/states.dart'; import '../../widgets/states.dart';
import '../shared/report_card_widget.dart'; import '../shared/report_card_widget.dart';
class ReportsPage extends StatefulWidget { class ReportsPage extends HookConsumerWidget {
const ReportsPage({ const ReportsPage({
required this.dataSource, required this.dataSource,
required this.onPlay, required this.onPlay,
@@ -25,29 +28,36 @@ class ReportsPage extends StatefulWidget {
final ReportDataSource dataSource; final ReportDataSource dataSource;
final void Function(AudioItem item) onPlay; final void Function(AudioItem item) onPlay;
final PlayerStateModel player; 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 VoidCallback? onToggleAudio;
final void Function(int delta)? onSeekAudio; final void Function(int delta)? onSeekAudio;
final VoidCallback? onSpeed; final VoidCallback? onSpeed;
@override @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> { return snapshot.when(
late Future<List<ReportCardModel>> future = widget.dataSource.reports(); loading: () => const LoadingState(label: '正在搜索研报'),
String query = ''; error: (error, _) => ErrorState(
String topic = ''; message: error.toString(),
bool hasAudio = false; 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( return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4), padding: const EdgeInsets.all(WiseSpacing.x4),
children: [ children: [
@@ -55,50 +65,85 @@ class _ReportsPageState extends State<ReportsPage> {
decoration: InputDecoration( decoration: InputDecoration(
hintText: '搜索标题、机构或主题', hintText: '搜索标题、机构或主题',
prefixIcon: const Icon(Icons.search), 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, filled: true,
fillColor: WiseColors.surface, 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), const SizedBox(height: WiseSpacing.x3),
Row( Row(
children: [ 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), 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), 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), const SizedBox(height: WiseSpacing.x3),
if (items.isEmpty) if (filtered.isEmpty)
EmptyState( EmptyState(
title: query.isNotEmpty ? '未找到相关研报' : '当前筛选下暂无研报', title: currentQuery.isNotEmpty ? '未找到相关研报' : '当前筛选下暂无研报',
message: query.isNotEmpty ? '换个关键词试试' : '调整筛选条件后再试', message: currentQuery.isNotEmpty ? '换个关键词试试' : '调整筛选条件后再试',
actionLabel: '清除筛选', actionLabel: '清除筛选',
onAction: () => setState(() { onAction: () {
query = ''; query.value = '';
topic = ''; topic.value = '';
hasAudio = false; hasAudio.value = false;
}), },
) )
else else
for (final report in items) ...[ for (final report in filtered) ...[
ReportCardWidget( ReportCardWidget(
report: report, report: report,
onTap: () => openReportDetail( onTap: () => openReportDetail(
context, context,
widget.dataSource, dataSource,
report, report,
player: widget.player, player: player,
onStartAudio: widget.onStartModuleAudio, onStartAudio: onStartModuleAudio,
onToggleAudio: widget.onToggleAudio, onToggleAudio: onToggleAudio,
onSeekAudio: widget.onSeekAudio, onSeekAudio: onSeekAudio,
onSpeed: widget.onSpeed, 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), const SizedBox(height: WiseSpacing.x3),
], ],
@@ -107,25 +152,37 @@ 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) { 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 (query.isNotEmpty && !hay.contains(query.toLowerCase())) return false;
if (topic.isNotEmpty && !item.topics.contains(topic)) return false; if (topic.isNotEmpty && !item.topics.contains(topic)) return false;
if (hasAudio && !item.hasAudio) return false; if (hasAudio && !item.hasAudio) return false;
return true; return true;
}).toList(); }).toList();
} }
void openFilterSheet() { void _openFilterSheet(
widget.dataSource.reports().then((items) { BuildContext context, {
if (!mounted) return; required List<ReportCardModel> items,
required ValueNotifier<String> topic,
}) {
final topics = {for (final item in items) ...item.topics}.toList(); final topics = {for (final item in items) ...item.topics}.toList();
showModalBottomSheet<void>( showModalBottomSheet<void>(
context: context, context: context,
showDragHandle: true, 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( builder: (context) => Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 28), padding: const EdgeInsets.fromLTRB(20, 4, 20, 28),
child: Column( child: Column(
@@ -138,20 +195,27 @@ class _ReportsPageState extends State<ReportsPage> {
spacing: WiseSpacing.x2, spacing: WiseSpacing.x2,
runSpacing: WiseSpacing.x2, runSpacing: WiseSpacing.x2,
children: [ children: [
AppChip(label: '全部主题', selected: topic.isEmpty, onTap: () => selectTopic('')), AppChip(
for (final t in topics) AppChip(label: t, selected: topic == t, onTap: () => selectTopic(t)), 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), 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: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 '../routing/app_routes.dart';
import '../data/models/models.dart'; import '../data/providers.dart';
import '../theme/app_icons.dart';
import '../theme/wise_tokens.dart'; import '../theme/wise_tokens.dart';
import '../widgets/mini_player.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 { class ShellPage extends ConsumerWidget {
const ShellPage({required this.dataSource, super.key}); const ShellPage({required this.child, required this.currentPath, super.key});
final ReportDataSource dataSource; final Widget child;
final String currentPath;
@override @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( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Column( title: const Column(
@@ -124,29 +29,93 @@ class _ShellPageState extends State<ShellPage> {
Text('研听'), Text('研听'),
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], body: ColoredBox(
bottomNavigationBar: Column( color: WiseColors.canvas,
child: Stack(children: [Positioned.fill(child: child)]),
),
bottomNavigationBar: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
MiniPlayer(player: player, onToggle: toggleAudio), MiniPlayer(player: player, onToggle: controller.toggleAudio),
NavigationBar( Container(
selectedIndex: index, height: 64,
onDestinationSelected: (value) => setState(() => index = value), padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
destinations: const [ decoration: const BoxDecoration(
NavigationDestination(icon: Icon(Icons.auto_awesome_outlined), selectedIcon: Icon(Icons.auto_awesome), label: '推荐'), color: WiseColors.canvas,
NavigationDestination(icon: Icon(Icons.article_outlined), selectedIcon: Icon(Icons.article), label: '研报'), border: Border(
NavigationDestination(icon: Icon(Icons.account_balance_outlined), selectedIcon: Icon(Icons.account_balance), label: '机构'), top: BorderSide(color: Color(0x11000000), width: 0.5),
NavigationDestination(icon: Icon(Icons.headphones_outlined), selectedIcon: Icon(Icons.headphones), label: '听单'), ),
NavigationDestination(icon: Icon(Icons.person_outline), selectedIcon: Icon(Icons.person), label: '我的'), ),
], 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:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'app.dart'; import 'app/bootstrap.dart';
import 'data/api/report_data_source.dart';
export 'app.dart'; export 'app.dart';
export 'data/api/report_data_source.dart'; export 'data/api/report_data_source.dart';
export 'data/models/models.dart'; export 'data/models/models.dart';
void main() { Future<void> main() async {
runApp(MyApp(dataSource: RnbApiDataSource())); 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:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../data/api/report_data_source.dart'; import '../data/api/report_data_source.dart';
import '../data/models/models.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'; 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( void openReportDetail(
BuildContext context, BuildContext context,
ReportDataSource dataSource, ReportDataSource dataSource,
ReportCardModel report, { ReportCardModel report, {
PlayerStateModel player = const PlayerStateModel(), 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, VoidCallback? onToggleAudio,
void Function(int delta)? onSeekAudio, void Function(int delta)? onSeekAudio,
VoidCallback? onSpeed, VoidCallback? onSpeed,
}) { }) {
Navigator.of(context).push( context.push(
MaterialPageRoute( AppRoutes.reportDetailPath(report.id),
builder: (_) => ReportDetailPage( extra: ReportDetailRouteArgs(
reportId: report.id,
dataSource: dataSource, dataSource: dataSource,
player: player, player: player,
onStartAudio: onStartAudio, onStartAudio: onStartAudio,
@@ -27,7 +74,6 @@ void openReportDetail(
onSeekAudio: onSeekAudio, onSeekAudio: onSeekAudio,
onSpeed: onSpeed, onSpeed: onSpeed,
), ),
),
); );
} }
@@ -36,12 +82,8 @@ void openInstitutionDetail(
ReportDataSource dataSource, ReportDataSource dataSource,
String institutionId, String institutionId,
) { ) {
Navigator.of(context).push( context.push(
MaterialPageRoute( AppRoutes.institutionDetailPath(institutionId),
builder: (_) => InstitutionDetailPage( extra: InstitutionDetailRouteArgs(dataSource: dataSource),
institutionId: institutionId,
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 description: flutter
source: sdk source: sdk
version: "0.0.0" 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: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -70,11 +78,45 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" 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: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" 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: http:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -91,6 +133,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
intl:
dependency: transitive
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.20.2"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@@ -123,6 +173,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.0" version: "6.1.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
@@ -155,6 +213,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" 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: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -176,6 +250,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.12.1" 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: stream_channel:
dependency: transitive dependency: transitive
description: description:
@@ -242,4 +324,4 @@ packages:
version: "1.1.1" version: "1.1.1"
sdks: sdks:
dart: ">=3.9.0 <4.0.0" 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: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
flutter_localizations:
sdk: flutter
# The following adds the Cupertino Icons font to your application. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
http: ^1.6.0 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: dev_dependencies:
flutter_test: flutter_test: