fix:优化使用常用技术框架
This commit is contained in:
+12
-121
@@ -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(() {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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(() {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
import 'app.dart';
|
||||||
|
|
||||||
|
Future<Widget> bootstrap() async {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
return const ReportNotebooklmApp();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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}',
|
||||||
@@ -121,4 +140,3 @@ class _FeedPageState extends State<FeedPage> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
loading: () => const LoadingState(),
|
||||||
|
error: (error, _) => ErrorState(
|
||||||
|
message: error.toString(),
|
||||||
|
onRetry: () => ref.invalidate(institutionsProvider),
|
||||||
|
),
|
||||||
|
data: (items) {
|
||||||
|
final sorted = [...items]
|
||||||
|
..sort((a, b) => b.reportCount.compareTo(a.reportCount));
|
||||||
|
if (sorted.isEmpty) {
|
||||||
|
return const EmptyState(
|
||||||
|
title: '暂无机构信息',
|
||||||
|
message: '稍后再试',
|
||||||
|
icon: Icons.account_balance_outlined,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _InstitutionsPageState extends State<InstitutionsPage> {
|
|
||||||
late Future<List<Institution>> future = widget.dataSource.institutions();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return FutureBuilder<List<Institution>>(
|
|
||||||
future: future,
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
if (snapshot.connectionState != ConnectionState.done) return const LoadingState();
|
|
||||||
if (snapshot.hasError) return ErrorState(message: snapshot.error.toString(), onRetry: () => setState(() => future = widget.dataSource.institutions()));
|
|
||||||
final items = [...snapshot.data ?? const <Institution>[]]..sort((a, b) => b.reportCount.compareTo(a.reportCount));
|
|
||||||
if (items.isEmpty) return const EmptyState(title: '暂无机构信息', message: '稍后再试', icon: Icons.account_balance_outlined);
|
|
||||||
return ListView(
|
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),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
loading: () => const LoadingState(label: '正在加载听单'),
|
||||||
|
error: (error, _) => ErrorState(
|
||||||
|
message: error.toString(),
|
||||||
|
onRetry: () => ref.invalidate(listenProvider),
|
||||||
|
),
|
||||||
|
data: (items) {
|
||||||
|
if (items.isEmpty) {
|
||||||
|
return const EmptyState(
|
||||||
|
title: '暂无音频研报',
|
||||||
|
message: '先去研报页看看图文解读',
|
||||||
|
icon: Icons.headphones_outlined,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ListenPageState extends State<ListenPage> {
|
|
||||||
late Future<List<AudioItem>> future = widget.dataSource.listen();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return FutureBuilder<List<AudioItem>>(
|
|
||||||
future: future,
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
if (snapshot.connectionState != ConnectionState.done) return const LoadingState(label: '正在加载听单');
|
|
||||||
if (snapshot.hasError) return ErrorState(message: snapshot.error.toString(), onRetry: () => setState(() => future = widget.dataSource.listen()));
|
|
||||||
final items = snapshot.data ?? const [];
|
|
||||||
if (items.isEmpty) return const EmptyState(title: '暂无音频研报', message: '先去研报页看看图文解读', icon: Icons.headphones_outlined);
|
|
||||||
return ListView(
|
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,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,10 +152,18 @@ class _ReportsPageState extends State<ReportsPage> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
List<ReportCardModel> applyFilters(List<ReportCardModel> items) {
|
List<ReportCardModel> _applyFilters(
|
||||||
|
List<ReportCardModel> items, {
|
||||||
|
required String query,
|
||||||
|
required String topic,
|
||||||
|
required bool hasAudio,
|
||||||
|
}) {
|
||||||
return items.where((item) {
|
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;
|
||||||
@@ -118,14 +171,18 @@ class _ReportsPageState extends State<ReportsPage> {
|
|||||||
}).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
@@ -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
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export 'app_theme.dart';
|
||||||
|
export 'wise_tokens.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';
|
||||||
+83
-1
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user