Compare commits

...

11 Commits

Author SHA1 Message Date
jingyun 1f28a64e4f fix:登录和toast 2026-06-07 12:03:23 +08:00
jingyun 6c943f8394 fix:登录退出页 2026-06-07 11:38:08 +08:00
jingyun 6a7fa5a067 fix:已登录显示 2026-06-07 11:16:38 +08:00
jingyun 544468f207 fix:搜索和我的页面 2026-06-07 11:16:19 +08:00
jingyun ac794ae58a fix:对比原型增加功能交互 2026-06-07 10:58:05 +08:00
jingyun af865b13fb fix;设置和深浅色 2026-06-05 17:54:46 +08:00
jingyun 33d04a5545 fix:导航栏交互和UI 2026-06-05 16:05:32 +08:00
jingyun c5288f397d fix:按照shadcn_ui对着demo_shadcn对齐 2026-06-05 15:04:39 +08:00
jingyun 9727b906c6 fix:按html的假数据demo 2026-06-05 11:12:55 +08:00
jingyun b4272b5ec9 fix:安卓报错版本对齐 2026-06-03 16:38:32 +08:00
jingyun e2554edfab fix:优化使用常用技术框架 2026-06-03 16:29:53 +08:00
58 changed files with 6734 additions and 1401 deletions
+5 -6
View File
@@ -1,5 +1,6 @@
plugins { plugins {
id("com.android.application") id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin") id("dev.flutter.flutter-gradle-plugin")
} }
@@ -14,6 +15,10 @@ android {
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
} }
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.report_notebooklm_app" applicationId = "com.example.report_notebooklm_app"
@@ -34,12 +39,6 @@ android {
} }
} }
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter { flutter {
source = "../.." source = "../.."
} }
+1 -1
View File
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
+2 -2
View File
@@ -19,8 +19,8 @@ pluginManagement {
plugins { plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false id("com.android.application") version "8.9.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false id("org.jetbrains.kotlin.android") version "2.1.0" apply false
} }
include(":app") include(":app")
+1
View File
@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig" #include "Generated.xcconfig"
+1
View File
@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig" #include "Generated.xcconfig"
+43
View File
@@ -0,0 +1,43 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Runner' do
use_frameworks!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
end
end
+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(() {});
}
}
}
+40
View File
@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../routing/app_router.dart';
import '../theme/app_theme.dart';
import '../theme/yanting_text.dart';
import '../theme/yanting_shad_theme.dart';
import '../theme/theme_controller.dart';
class ReportNotebooklmApp extends ConsumerWidget {
const ReportNotebooklmApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(routerProvider);
final themeMode = ref.watch(themeModeProvider);
final dmSansStyle = GoogleFonts.dmSans().copyWith(
fontFamilyFallback: YantingText.fontFallback,
);
return ShadApp.router(
title: '研听',
debugShowCheckedModeBanner: false,
theme: buildYantingShadTheme(),
darkTheme: buildYantingDarkShadTheme(),
themeMode: themeMode,
routerConfig: router,
scrollBehavior: const ShadScrollBehavior(),
materialThemeBuilder: (context, theme) => buildAppTheme(theme.brightness),
builder: (context, child) {
return DefaultTextStyle.merge(
style: TextStyle(fontFamilyFallback: dmSansStyle.fontFamilyFallback),
child: child ?? const SizedBox.shrink(),
);
},
);
}
}
+8
View File
@@ -0,0 +1,8 @@
import 'package:flutter/widgets.dart';
import 'app.dart';
Future<Widget> bootstrap() async {
WidgetsFlutterBinding.ensureInitialized();
return const ReportNotebooklmApp();
}
+493
View File
@@ -0,0 +1,493 @@
import '../models/models.dart';
import 'report_data_source.dart';
class MockReportDataSource extends ReportDataSource {
MockReportDataSource();
static final Institution _wgcSummary = _institutionSummary(
id: 'wgc',
nameCn: '世界黄金协会',
nameEn: 'World Gold Council',
logoUrl: 'https://www.google.com/s2/favicons?domain=www.gold.org&sz=128',
institutionType: 'industry_org',
sourceTier: 'A',
websiteUrl: 'https://www.gold.org',
coveredTopics: ['贵金属', '央行', '跨资产'],
reportCount: 6,
introCn: '世界黄金协会致力于推动黄金市场研究与应用,常发布黄金需求、投资与央行购金相关分析。',
credibilityNote: '公开数据、央行储备与 ETF 流量交叉验证,适合跟踪黄金需求结构变化。',
);
static final Institution _bisSummary = _institutionSummary(
id: 'bis',
nameCn: '国际清算银行 BIS',
nameEn: 'Bank for International Settlements',
logoUrl: 'https://www.google.com/s2/favicons?domain=www.bis.org&sz=128',
institutionType: 'official',
sourceTier: 'A',
websiteUrl: 'https://www.bis.org',
coveredTopics: ['宏观', '货币政策'],
reportCount: 4,
introCn: 'BIS 关注全球金融稳定、资本流动和银行体系结构,是宏观与金融市场观察的重要来源。',
credibilityNote: '跨国监管机构,通常以统计和制度性框架解释市场变化。',
);
static final Institution _ieaSummary = _institutionSummary(
id: 'iea',
nameCn: '国际能源署 IEA',
nameEn: 'International Energy Agency',
logoUrl: 'https://www.google.com/s2/favicons?domain=www.iea.org&sz=128',
institutionType: 'official',
sourceTier: 'A',
websiteUrl: 'https://www.iea.org',
coveredTopics: ['能源', '大宗'],
reportCount: 5,
introCn: 'IEA 主要跟踪全球原油、天然气、电力与能源转型相关供需变化。',
credibilityNote: '统计频率高,适合配合库存、产量与政策节奏观察。',
);
static final Institution _worldBankSummary = _institutionSummary(
id: 'worldbank',
nameCn: '世界银行',
nameEn: 'World Bank',
logoUrl:
'https://www.google.com/s2/favicons?domain=www.worldbank.org&sz=128',
institutionType: 'official',
sourceTier: 'A',
websiteUrl: 'https://www.worldbank.org',
coveredTopics: ['大宗', '全球增长'],
reportCount: 3,
introCn: '世界银行常通过大宗商品展望、全球增长与贫困相关研究提供宏观视角。',
credibilityNote: '跨国公共机构,适合作为全球价格和增长基准参考。',
);
static final Institution _ssgaSummary = _institutionSummary(
id: 'ssga',
nameCn: '道富环球投资管理',
nameEn: 'State Street Global Advisors',
logoUrl: 'https://www.google.com/s2/favicons?domain=www.ssga.com&sz=128',
institutionType: 'asset_manager',
sourceTier: 'A',
websiteUrl: 'https://www.ssga.com',
coveredTopics: ['贵金属', '跨资产'],
reportCount: 4,
introCn: '道富的市场观点常围绕资产配置、黄金投资和 ETF 流向展开。',
credibilityNote: '资产管理机构视角偏市场化,适合观察投资者行为和资金流。',
);
static final ReportDetail _gold = ReportDetail(
id: 'gold-demand-q2-2026',
titleCn: '黄金需求趋势:央行与亚洲买家的双轮驱动',
institution: _wgcSummary,
oneLiner: '央行购金与亚洲投资需求,构成本季黄金需求的双轮。',
source: const {'source_tier': 'A', 'source_name': 'World Gold Council'},
topics: const ['贵金属', '央行', '跨资产'],
hasAudio: true,
releasedAt: '2026-05-30',
modules: [
_module('gold-basic', 'basic_info', '基本信息', {
'summary_cn': '央行持续买入黄金,叠加亚洲投资需求回暖,支撑本季金价中枢。',
'topics': ['贵金属', '央行', '跨资产'],
}),
_module('gold-insights', 'core_insights', '核心洞察', {
'points': [
{'kind': 'view', 'text': '央行购金从战术操作转向结构性配置,已成为黄金需求底层支撑。'},
{'kind': 'number', 'text': '金价重回高位后,ETF 与亚洲实物买盘接续承接,波动被更广泛的资金流吸收。'},
{'kind': 'risk', 'text': '若美元和实际利率同步抬升,黄金的防御属性仍会面临短期回撤。'},
],
}, hasDetailPage: true),
_module('gold-audio', 'audio', '音频解读', {
'audio_id': 'audio_gold_q2_2026',
'title_cn': '黄金需求趋势:央行与亚洲买家的双轮驱动',
'duration_sec': 860,
}),
_module('gold-timeline', 'timeline', '时间线', {
'events': [
{'date': '2026-03', 'event': '央行购金维持高位', 'impact': '储备资产配置继续偏向黄金。'},
{'date': '2026-04', 'event': '亚洲投资需求回升', 'impact': '零售和机构买盘共同抬升需求。'},
{
'date': '2026-05',
'event': '金价刷新阶段高位',
'impact': '高位波动加大,但下方买盘承接仍强。',
},
],
}, hasDetailPage: true),
_module('gold-key-data', 'key_data', '关键数据', {
'rows': [
{
'metric': '央行净买入',
'value': '800+',
'unit': '',
'judgment': '仍处高位,支撑黄金中期结构性需求。',
},
{
'metric': 'ETF 流向',
'value': '净流入',
'unit': '',
'judgment': '投资盘开始重新接力。',
},
{
'metric': '亚洲实物需求',
'value': '回升',
'unit': '',
'judgment': '节庆和资产配置需求叠加。',
},
],
}),
_module('gold-study', 'study_guide', '研读指南', {
'intro_cn': '如果你只想看结论,先读核心洞察;如果你想跟踪驱动因素,继续看时间线与关键数据。',
'faq_items': [
{
'question': '为什么央行购金重要?',
'answer': '央行购金往往具有中长期配置属性,可以给金价提供比短线投机更稳定的需求锚。',
},
{
'question': '亚洲买盘如何影响金价?',
'answer': '当亚洲需求在价格高位仍然保持韧性时,金价上方空间通常更容易被市场重新定价。',
},
],
'glossary': [
{'term': 'ETF', 'definition': '交易型开放式基金,常用于观察机构资金流向。'},
{'term': '央行购金', 'definition': '中央银行增持黄金储备的行为。'},
],
}, hasDetailPage: true),
_module('gold-sources', 'related_sources', '相关来源', {
'items': [
{'title': '世界黄金协会季度需求报告', 'summary_cn': '黄金首饰、央行和投资需求的总览。'},
{'title': '道富黄金配置观点', 'summary_cn': '从资产配置角度看黄金在组合中的角色。'},
],
}),
_module('gold-weaknesses', 'weaknesses', '风险与验证', {
'disclaimer_cn': '以下内容不构成投资建议,需结合自身风险承受能力判断。',
'items': [
{
'topic': '美元波动',
'weakness': '若美元在短期内持续走强,黄金可能承压。',
'counter_evidence': '实际利率仍处下行或中性,黄金下方支撑尚在。',
},
{
'topic': '投机拥挤',
'weakness': '当资金快速涌入时,行情容易出现脉冲式回调。',
'counter_evidence': '央行和长期配置资金可部分对冲短线拥挤。',
},
],
'verification_notes': ['关注央行月度购金变化。', '关注 ETF 净流入是否持续两周以上。'],
}),
_module('gold-compliance', 'source_compliance', '来源与合规', {
'source_note': '来源以公开研报、统计口径和机构原文为准,结构化内容用于中文解读。',
'copyright_cn': '版权归原始发布机构与作者所有。',
'disclaimer': '本内容为公开/授权研报的结构化解读,不构成投资建议。',
}),
_module('gold-institution', 'institution', '机构', {
'name_cn': '世界黄金协会',
'report_count': 6,
}),
],
);
static final ReportDetail _bis = ReportDetail(
id: 'bis-quarterly-2026-q1',
titleCn: 'BIS 季报 2026年3月:市场重新校准',
institution: _bisSummary,
oneLiner: '表面平静之下,全球金融市场正经历深刻的流向切换与重新校准。',
source: const {'source_tier': 'A', 'source_name': 'BIS'},
topics: const ['宏观', '货币政策'],
hasAudio: true,
releasedAt: '2026-03-05',
modules: [
_module('bis-basic', 'basic_info', '基本信息', {
'summary_cn': 'BIS 关注全球宏观流动、资本配置和金融体系的再平衡。',
'topics': ['宏观', '货币政策'],
}),
_module('bis-insights', 'core_insights', '核心洞察', {
'points': [
{'kind': 'view', 'text': '市场定价已从单一利率路径转向对增长、通胀和资本流动的综合校准。'},
{'kind': 'number', 'text': '跨境资本流向的再分配比利率本身更能解释当下的市场分化。'},
],
}, hasDetailPage: true),
_module('bis-audio', 'audio', '音频解读', {
'audio_id': 'audio_bis_q1_2026',
'title_cn': 'BIS 季报 2026年3月:市场重新校准',
'duration_sec': 1040,
}),
_module('bis-compliance', 'source_compliance', '来源与合规', {
'source_note': '该模块用于展示机构原文与结构化解读的对应关系。',
'copyright_cn': '版权归 BIS 原始报告与作者所有。',
'disclaimer': '本内容为公开/授权研报的结构化解读,不构成投资建议。',
}),
_module('bis-institution', 'institution', '机构', {
'name_cn': '国际清算银行 BIS',
'report_count': 4,
}),
],
);
static final ReportDetail _iea = ReportDetail(
id: 'iea-oil-monthly-2026-05',
titleCn: '石油市场月报:需求放缓,供给为何仍偏紧?',
institution: _ieaSummary,
oneLiner: '需求增速放缓,但减产纪律与库存偏低支撑油价下方。',
source: const {'source_tier': 'A', 'source_name': 'IEA'},
topics: const ['能源', '大宗'],
hasAudio: true,
releasedAt: '2026-05-20',
modules: [
_module('iea-basic', 'basic_info', '基本信息', {
'summary_cn': 'IEA 月报聚焦全球原油供需、库存和宏观风险。',
'topics': ['能源', '大宗'],
}),
_module('iea-insights', 'core_insights', '核心洞察', {
'points': [
{'kind': 'view', 'text': '需求放缓并不自动意味着价格回落,关键还是供给纪律和库存水平。'},
{'kind': 'number', 'text': '库存偏低时,价格对中短期扰动的敏感度明显提高。'},
],
}, hasDetailPage: true),
_module('iea-audio', 'audio', '音频解读', {
'audio_id': 'audio_iea_2026_05',
'title_cn': '石油市场月报:需求放缓,供给为何仍偏紧?',
'duration_sec': 1120,
}),
_module('iea-key-data', 'key_data', '关键数据', {
'rows': [
{
'metric': '需求增速',
'value': '放缓',
'unit': '',
'judgment': '不代表价格立刻下行。',
},
{
'metric': '供给纪律',
'value': '偏紧',
'unit': '',
'judgment': '减产执行仍在延续。',
},
{'metric': '库存', 'value': '偏低', 'unit': '', 'judgment': '对价格形成支撑。'},
],
}),
_module('iea-compliance', 'source_compliance', '来源与合规', {
'source_note': 'IEA 原文与统计框架保持一致,中文内容仅用于结构化阅读。',
'copyright_cn': '版权归原始发布机构与作者所有。',
'disclaimer': '本内容为公开/授权研报的结构化解读,不构成投资建议。',
}),
_module('iea-institution', 'institution', '机构', {
'name_cn': '国际能源署',
'report_count': 5,
}),
],
);
static final ReportDetail _worldBank = ReportDetail(
id: 'worldbank-commodities-2026-04',
titleCn: '世界银行大宗商品展望:价格见顶了吗?',
institution: _worldBankSummary,
oneLiner: '全球增长预期放缓,但价格路径仍受供给侧扰动影响。',
source: const {'source_tier': 'A', 'source_name': 'World Bank'},
topics: const ['大宗', '全球增长'],
hasAudio: true,
releasedAt: '2026-04-18',
modules: [
_module('wb-basic', 'basic_info', '基本信息', {
'summary_cn': '世界银行展望通常从全球增长、贸易和大宗商品价格三个层面展开。',
'topics': ['大宗', '全球增长'],
}),
_module('wb-insights', 'core_insights', '核心洞察', {
'points': [
{'kind': 'view', 'text': '价格是否见顶,往往取决于供给扰动能否被需求放缓完全吸收。'},
{'kind': 'risk', 'text': '若全球增长进一步放缓,大宗商品的边际弹性会显著下降。'},
],
}, hasDetailPage: true),
_module('wb-audio', 'audio', '音频解读', {
'audio_id': 'audio_worldbank_2026_04',
'title_cn': '世界银行大宗商品展望:价格见顶了吗?',
'duration_sec': 980,
}),
_module('wb-compliance', 'source_compliance', '来源与合规', {
'source_note': '世界银行报告适合作为全球商品价格的基准视角。',
'copyright_cn': '版权归原始发布机构与作者所有。',
'disclaimer': '本内容为公开/授权研报的结构化解读,不构成投资建议。',
}),
_module('wb-institution', 'institution', '机构', {
'name_cn': '世界银行',
'report_count': 3,
}),
],
);
static final List<ReportDetail> _details = [_gold, _bis, _iea, _worldBank];
static final Map<String, ReportDetail> _detailById = {
for (final detail in _details) detail.id: detail,
};
static final Map<String, Institution> _institutionDetails = {
'wgc': _institutionDetail(
base: _wgcSummary,
recentReports: [_gold.asCard()],
),
'bis': _institutionDetail(
base: _bisSummary,
recentReports: [_bis.asCard()],
),
'iea': _institutionDetail(
base: _ieaSummary,
recentReports: [_iea.asCard()],
),
'worldbank': _institutionDetail(
base: _worldBankSummary,
recentReports: [_worldBank.asCard()],
),
'ssga': _institutionDetail(
base: _ssgaSummary,
recentReports: [_gold.asCard()],
),
};
static final Map<String, ModuleDetail> _moduleDetails = {
'gold-insights': _moduleDetail(_gold.modules[1]),
'gold-timeline': _moduleDetail(_gold.modules[3]),
'gold-study': _moduleDetail(_gold.modules[5]),
'bis-insights': _moduleDetail(_bis.modules[1]),
'iea-insights': _moduleDetail(_iea.modules[1]),
'wb-insights': _moduleDetail(_worldBank.modules[1]),
};
@override
Future<List<ReportCardModel>> recommended() async => [
_gold.asCard(),
_bis.asCard(),
_iea.asCard(),
];
@override
Future<List<ReportCardModel>> reports() async =>
_details.map((d) => d.asCard()).toList();
@override
Future<List<Institution>> institutions() async {
return _institutionDetails.values.toList()
..sort((a, b) => b.reportCount.compareTo(a.reportCount));
}
@override
Future<Institution> institutionDetail(String institutionId) async {
return _institutionDetails[institutionId] ?? _institutionDetails['wgc']!;
}
@override
Future<List<AudioItem>> listen() async {
return [
_audioFromReport(_gold, 860),
_audioFromReport(_bis, 1040),
_audioFromReport(_iea, 1120),
_audioFromReport(_worldBank, 980),
];
}
@override
Future<ReportDetail> reportDetail(String reportId) async {
return _detailById[reportId] ?? _gold;
}
@override
Future<ModuleDetail> moduleDetail(String reportId, String moduleId) async {
return _moduleDetails[moduleId] ??
ModuleDetail(
id: moduleId,
type: 'basic_info',
titleCn: '模块详情',
content: const {'preview_summary': '该模块暂无单独详情数据。'},
);
}
static Institution _institutionSummary({
required String id,
required String nameCn,
required String nameEn,
required String logoUrl,
required String institutionType,
required String sourceTier,
required String websiteUrl,
required List<String> coveredTopics,
required int reportCount,
required String introCn,
required String credibilityNote,
}) {
return Institution(
id: id,
nameCn: nameCn,
nameEn: nameEn,
logoUrl: logoUrl,
institutionType: institutionType,
sourceTier: sourceTier,
websiteUrl: websiteUrl,
coveredTopics: coveredTopics,
reportCount: reportCount,
introCn: introCn,
credibilityNote: credibilityNote,
);
}
static Institution _institutionDetail({
required Institution base,
required List<ReportCardModel> recentReports,
}) {
return Institution(
id: base.id,
nameCn: base.nameCn,
nameEn: base.nameEn,
logoUrl: base.logoUrl,
institutionType: base.institutionType,
sourceTier: base.sourceTier,
websiteUrl: base.websiteUrl,
coveredTopics: base.coveredTopics,
reportCount: base.reportCount,
latestReportAt: base.latestReportAt,
credibilityNote: base.credibilityNote,
introCn: base.introCn,
recentReports: recentReports,
);
}
static DisplayModule _module(
String id,
String type,
String titleCn,
JsonMap content, {
bool hasDetailPage = false,
String renderMode = 'inline',
}) {
return DisplayModule(
id: id,
type: type,
titleCn: titleCn,
renderMode: renderMode,
hasDetailPage: hasDetailPage,
content: content,
preview: content,
);
}
static ModuleDetail _moduleDetail(DisplayModule module) {
return ModuleDetail(
id: module.id,
type: module.type,
titleCn: module.titleCn,
content: module.content,
contentEtag: 'mock',
cacheVersion: 'mock',
);
}
static AudioItem _audioFromReport(ReportDetail report, int duration) {
return AudioItem(
audioId: 'audio_${report.id}',
reportId: report.id,
titleCn: report.titleCn,
reportTitleCn: report.titleCn,
durationSec: duration,
institution: report.institution,
releasedAt: report.releasedAt,
cacheVersion: 'mock',
);
}
}
+64 -3
View File
@@ -3,8 +3,10 @@ import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../models/models.dart'; import '../models/models.dart';
import '../repositories/report_repository.dart';
import '../state/report_query.dart';
abstract class ReportDataSource { abstract class ReportDataSource extends ReportRepository {
Future<List<ReportCardModel>> recommended(); Future<List<ReportCardModel>> recommended();
Future<List<ReportCardModel>> reports(); Future<List<ReportCardModel>> reports();
Future<List<Institution>> institutions(); Future<List<Institution>> institutions();
@@ -12,9 +14,66 @@ abstract class ReportDataSource {
Future<List<AudioItem>> listen(); Future<List<AudioItem>> listen();
Future<ReportDetail> reportDetail(String reportId); Future<ReportDetail> reportDetail(String reportId);
Future<ModuleDetail> moduleDetail(String reportId, String moduleId); Future<ModuleDetail> moduleDetail(String reportId, String moduleId);
@override
Future<List<ReportCardModel>> getRecommended({String? topic}) async {
final items = await recommended();
if (topic == null || topic == '全部') return items;
return items.where((item) => item.topics.contains(topic)).toList();
}
@override
Future<List<ReportCardModel>> getReports(ReportQuery query) async {
final currentSearch = query.search.trim().toLowerCase();
final items = await reports();
final filtered = items.where((item) {
final haystack =
'${item.titleCn} ${item.subtitleCn} ${item.oneLiner} '
'${item.institution.nameCn} ${item.institution.nameEn} '
'${item.topics.join(' ')}'
.toLowerCase();
if (currentSearch.isNotEmpty && !haystack.contains(currentSearch)) {
return false;
}
if (query.topic != null && !item.topics.contains(query.topic)) {
return false;
}
if (query.institutionId != null &&
item.institution.id != query.institutionId) {
return false;
}
if (query.hasAudio && !item.hasAudio) {
return false;
}
return true;
}).toList();
filtered.sort((a, b) {
final result = (b.releasedAt ?? '').compareTo(a.releasedAt ?? '');
return query.sort == ReportSort.oldest ? -result : result;
});
return filtered;
}
@override
Future<ReportDetail> getReportDetail(String reportId) =>
reportDetail(reportId);
@override
Future<List<Institution>> getInstitutions() => institutions();
@override
Future<Institution> getInstitutionDetail(String institutionId) =>
institutionDetail(institutionId);
@override
Future<List<AudioItem>> getListenItems() => listen();
@override
Future<ModuleDetail> getModuleDetail(String reportId, String moduleId) =>
moduleDetail(reportId, moduleId);
} }
class RnbApiDataSource implements ReportDataSource { class RnbApiDataSource extends ReportDataSource {
RnbApiDataSource({ RnbApiDataSource({
http.Client? client, http.Client? client,
this.baseUrl = const String.fromEnvironment('RNB_API_BASE'), this.baseUrl = const String.fromEnvironment('RNB_API_BASE'),
@@ -72,6 +131,8 @@ class RnbApiDataSource implements ReportDataSource {
@override @override
Future<ModuleDetail> moduleDetail(String reportId, String moduleId) async { Future<ModuleDetail> moduleDetail(String reportId, String moduleId) async {
return ModuleDetail.fromJson(await _get('/reports/$reportId/modules/$moduleId')); return ModuleDetail.fromJson(
await _get('/reports/$reportId/modules/$moduleId'),
);
} }
} }
+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();
}
}
+69
View File
@@ -0,0 +1,69 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'models/models.dart';
import 'providers.dart';
import 'state/app_state_controllers.dart';
import 'state/report_query.dart';
final recommendedReportsProvider =
FutureProvider.autoDispose<List<ReportCardModel>>((ref) async {
final dataSource = ref.watch(reportDataSourceProvider);
return dataSource.recommended();
});
final recommendedByTopicProvider =
FutureProvider.autoDispose<List<ReportCardModel>>((ref) async {
final repository = ref.watch(reportRepositoryProvider);
final topic = ref.watch(recommendTopicProvider);
return repository.getRecommended(topic: topic);
});
final reportsProvider = FutureProvider.autoDispose<List<ReportCardModel>>((
ref,
) async {
final dataSource = ref.watch(reportDataSourceProvider);
return dataSource.reports();
});
final filteredReportsProvider =
FutureProvider.autoDispose<List<ReportCardModel>>((ref) async {
final repository = ref.watch(reportRepositoryProvider);
final query = ref.watch(reportFilterProvider);
return repository.getReports(query);
});
final institutionsProvider = FutureProvider.autoDispose<List<Institution>>((
ref,
) async {
final repository = ref.watch(reportRepositoryProvider);
return repository.getInstitutions();
});
final listenProvider = FutureProvider.autoDispose<List<AudioItem>>((ref) async {
final repository = ref.watch(reportRepositoryProvider);
return repository.getListenItems();
});
final profileHistoryReportsProvider =
FutureProvider.autoDispose<List<ReportCardModel>>((ref) async {
final profile = ref.watch(profileControllerProvider);
final repository = ref.watch(reportRepositoryProvider);
final reports = await repository.getReports(const ReportQuery());
return ProfileListBuilder(reports).byIds(profile.history);
});
final profileFavoriteReportsProvider =
FutureProvider.autoDispose<List<ReportCardModel>>((ref) async {
final profile = ref.watch(profileControllerProvider);
final repository = ref.watch(reportRepositoryProvider);
final reports = await repository.getReports(const ReportQuery());
return ProfileListBuilder(reports).byIds(profile.favorites);
});
final profileSavedListenReportsProvider =
FutureProvider.autoDispose<List<ReportCardModel>>((ref) async {
final profile = ref.watch(profileControllerProvider);
final repository = ref.watch(reportRepositoryProvider);
final reports = await repository.getReports(const ReportQuery());
return ProfileListBuilder(reports).byIds(profile.savedListens);
});
+16 -6
View File
@@ -18,12 +18,16 @@ List<String> asStringList(Object? value) {
JsonMap asMap(Object? value) { JsonMap asMap(Object? value) {
if (value is Map<String, dynamic>) return value; if (value is Map<String, dynamic>) return value;
if (value is Map) return value.map((key, val) => MapEntry(key.toString(), val)); if (value is Map) {
return value.map((key, val) => MapEntry(key.toString(), val));
}
return const {}; return const {};
} }
List<JsonMap> asMapList(Object? value) { List<JsonMap> asMapList(Object? value) {
if (value is List) return value.map(asMap).where((item) => item.isNotEmpty).toList(); if (value is List) {
return value.map(asMap).where((item) => item.isNotEmpty).toList();
}
return const []; return const [];
} }
@@ -46,6 +50,7 @@ class Institution {
required this.id, required this.id,
required this.nameCn, required this.nameCn,
this.nameEn = '', this.nameEn = '',
this.logoUrl = '',
this.institutionType = '', this.institutionType = '',
this.sourceTier = '', this.sourceTier = '',
this.websiteUrl = '', this.websiteUrl = '',
@@ -60,6 +65,7 @@ class Institution {
final String id; final String id;
final String nameCn; final String nameCn;
final String nameEn; final String nameEn;
final String logoUrl;
final String institutionType; final String institutionType;
final String sourceTier; final String sourceTier;
final String websiteUrl; final String websiteUrl;
@@ -75,6 +81,7 @@ class Institution {
id: asString(json['institution_id']), id: asString(json['institution_id']),
nameCn: asString(json['name_cn']), nameCn: asString(json['name_cn']),
nameEn: asString(json['name_en']), nameEn: asString(json['name_en']),
logoUrl: asString(json['logo_url']),
institutionType: asString(json['institution_type']), institutionType: asString(json['institution_type']),
sourceTier: asString(json['source_tier']), sourceTier: asString(json['source_tier']),
websiteUrl: asString(json['website_url']), websiteUrl: asString(json['website_url']),
@@ -83,9 +90,9 @@ class Institution {
latestReportAt: json['latest_report_at']?.toString(), latestReportAt: json['latest_report_at']?.toString(),
credibilityNote: asString(json['credibility_note']), credibilityNote: asString(json['credibility_note']),
introCn: asString(json['intro_cn']), introCn: asString(json['intro_cn']),
recentReports: asMapList(json['recent_reports']) recentReports: asMapList(
.map(ReportCardModel.fromJson) json['recent_reports'],
.toList(), ).map(ReportCardModel.fromJson).toList(),
); );
} }
} }
@@ -161,7 +168,10 @@ class AudioItem {
audioId: asString(json['audio_id']), audioId: asString(json['audio_id']),
reportId: asString(json['report_id']), reportId: asString(json['report_id']),
titleCn: asString(json['title_cn']), titleCn: asString(json['title_cn']),
reportTitleCn: asString(json['report_title_cn'], asString(json['title_cn'])), reportTitleCn: asString(
json['report_title_cn'],
asString(json['title_cn']),
),
durationSec: asInt(json['duration_sec']), durationSec: asInt(json['duration_sec']),
institution: Institution.fromJson(asMap(json['institution'])), institution: Institution.fromJson(asMap(json['institution'])),
releasedAt: json['released_at']?.toString(), releasedAt: json['released_at']?.toString(),
+70
View File
@@ -0,0 +1,70 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'api/mock_report_data_source.dart';
import 'api/report_data_source.dart';
import 'audio_player_controller.dart';
import 'repositories/outbound_repository.dart';
import 'repositories/report_repository.dart';
import 'repositories/user_state_repository.dart';
import 'state/app_interaction_state.dart';
import 'state/app_state_controllers.dart';
import 'state/report_query.dart';
import '../widgets/mini_player.dart';
final reportDataSourceProvider = Provider<ReportDataSource>((ref) {
const useMock = bool.fromEnvironment('YANTING_USE_MOCK', defaultValue: true);
if (useMock) {
return MockReportDataSource();
}
return RnbApiDataSource();
});
final audioPlayerControllerProvider =
StateNotifierProvider<AudioPlayerController, PlayerStateModel>((ref) {
return AudioPlayerController();
});
final reportRepositoryProvider = Provider<ReportRepository>((ref) {
return ref.watch(reportDataSourceProvider);
});
final userStateRepositoryProvider = Provider<UserStateRepository>((ref) {
return MemoryUserStateRepository();
});
final outboundRepositoryProvider = Provider<OutboundRepository>((ref) {
return MemoryOutboundRepository();
});
final recommendTopicProvider =
StateNotifierProvider<RecommendTopicController, String>((ref) {
return RecommendTopicController();
});
final reportFilterProvider =
StateNotifierProvider<ReportFilterController, ReportQuery>((ref) {
return ReportFilterController();
});
final authControllerProvider = StateNotifierProvider<AuthController, AuthState>(
(ref) {
return AuthController(ref.watch(userStateRepositoryProvider));
},
);
final profileControllerProvider =
StateNotifierProvider<ProfileController, ProfileState>((ref) {
return ProfileController(ref.watch(userStateRepositoryProvider));
});
final detailNavigationProvider =
StateNotifierProvider<DetailNavigationController, DetailNavigationState>((
ref,
) {
return DetailNavigationController();
});
final sheetControllerProvider =
StateNotifierProvider<SheetController, SheetState>((ref) {
return SheetController();
});
@@ -0,0 +1,16 @@
import '../state/app_interaction_state.dart';
abstract class OutboundRepository {
Future<void> recordOutbound(OutboundEvent event);
}
class MemoryOutboundRepository implements OutboundRepository {
final List<OutboundEvent> _events = [];
List<OutboundEvent> get events => List.unmodifiable(_events);
@override
Future<void> recordOutbound(OutboundEvent event) async {
_events.add(event);
}
}
@@ -0,0 +1,12 @@
import '../models/models.dart';
import '../state/report_query.dart';
abstract class ReportRepository {
Future<List<ReportCardModel>> getRecommended({String? topic});
Future<List<ReportCardModel>> getReports(ReportQuery query);
Future<ReportDetail> getReportDetail(String reportId);
Future<List<Institution>> getInstitutions();
Future<Institution> getInstitutionDetail(String institutionId);
Future<List<AudioItem>> getListenItems();
Future<ModuleDetail> getModuleDetail(String reportId, String moduleId);
}
@@ -0,0 +1,94 @@
import '../state/app_interaction_state.dart';
abstract class UserStateRepository {
Future<bool> isLoggedIn();
Future<void> login(LoginMethod method, {String? phone});
Future<void> logout();
Future<String?> getPhone();
Future<LoginMethod?> getLoginMethod();
Future<Set<String>> getFavorites();
Future<void> toggleFavorite(String reportId);
Future<Set<String>> getSavedListens();
Future<void> toggleSavedListen(String reportId);
Future<List<String>> getHistory();
Future<void> addHistory(String reportId);
Future<Map<String, double>> getAudioProgress();
Future<void> saveAudioProgress(String audioId, double seconds);
}
class MemoryUserStateRepository implements UserStateRepository {
bool _loggedIn = false;
String? _phone;
LoginMethod? _loginMethod;
final Set<String> _favorites = {};
final Set<String> _savedListens = {};
final List<String> _history = [];
final Map<String, double> _audioProgress = {};
@override
Future<bool> isLoggedIn() async => _loggedIn;
@override
Future<void> login(LoginMethod method, {String? phone}) async {
_loggedIn = true;
_loginMethod = method;
_phone = phone;
}
@override
Future<void> logout() async {
_loggedIn = false;
_phone = null;
_loginMethod = null;
}
@override
Future<String?> getPhone() async => _phone;
@override
Future<LoginMethod?> getLoginMethod() async => _loginMethod;
@override
Future<Set<String>> getFavorites() async => {..._favorites};
@override
Future<void> toggleFavorite(String reportId) async {
if (!_favorites.add(reportId)) {
_favorites.remove(reportId);
}
}
@override
Future<Set<String>> getSavedListens() async => {..._savedListens};
@override
Future<void> toggleSavedListen(String reportId) async {
if (!_savedListens.add(reportId)) {
_savedListens.remove(reportId);
}
}
@override
Future<List<String>> getHistory() async => [..._history];
@override
Future<void> addHistory(String reportId) async {
_history.remove(reportId);
_history.insert(0, reportId);
if (_history.length > 40) {
_history.removeRange(40, _history.length);
}
}
@override
Future<Map<String, double>> getAudioProgress() async => {..._audioProgress};
@override
Future<void> saveAudioProgress(String audioId, double seconds) async {
_audioProgress[audioId] = seconds < 0 ? 0 : seconds;
}
}
+176
View File
@@ -0,0 +1,176 @@
import '../models/models.dart';
class AuthState {
const AuthState({
this.loggedIn = false,
this.pendingAction,
this.phone,
this.loginMethod,
});
final bool loggedIn;
final PendingLoginAction? pendingAction;
final String? phone;
final LoginMethod? loginMethod;
AuthState copyWith({
bool? loggedIn,
Object? pendingAction = _sentinel,
Object? phone = _sentinel,
Object? loginMethod = _sentinel,
}) {
return AuthState(
loggedIn: loggedIn ?? this.loggedIn,
pendingAction: identical(pendingAction, _sentinel)
? this.pendingAction
: pendingAction as PendingLoginAction?,
phone: identical(phone, _sentinel) ? this.phone : phone as String?,
loginMethod: identical(loginMethod, _sentinel)
? this.loginMethod
: loginMethod as LoginMethod?,
);
}
}
class PendingLoginAction {
const PendingLoginAction({
required this.action,
required this.reportId,
required this.contextText,
});
final LoginRequiredAction action;
final String reportId;
final String contextText;
}
enum LoginRequiredAction { favorite, saveListen }
enum LoginMethod { phone, wechat, apple }
class ProfileState {
const ProfileState({
this.favorites = const {},
this.savedListens = const {},
this.history = const [],
});
final Set<String> favorites;
final Set<String> savedListens;
final List<String> history;
ProfileState copyWith({
Set<String>? favorites,
Set<String>? savedListens,
List<String>? history,
}) {
return ProfileState(
favorites: favorites ?? this.favorites,
savedListens: savedListens ?? this.savedListens,
history: history ?? this.history,
);
}
}
class DetailNavigationState {
const DetailNavigationState({
this.originTab = AppTab.recommend,
this.stack = const [],
this.tabScroll = const {},
});
final AppTab originTab;
final List<DetailStackEntry> stack;
final Map<AppTab, double> tabScroll;
DetailNavigationState copyWith({
AppTab? originTab,
List<DetailStackEntry>? stack,
Map<AppTab, double>? tabScroll,
}) {
return DetailNavigationState(
originTab: originTab ?? this.originTab,
stack: stack ?? this.stack,
tabScroll: tabScroll ?? this.tabScroll,
);
}
}
class DetailStackEntry {
const DetailStackEntry({
required this.type,
required this.id,
this.scrollTop = 0,
});
final DetailEntryType type;
final String id;
final double scrollTop;
DetailStackEntry copyWith({double? scrollTop}) {
return DetailStackEntry(
type: type,
id: id,
scrollTop: scrollTop ?? this.scrollTop,
);
}
}
enum DetailEntryType { report, institution }
enum AppTab { recommend, reports, institutions, listen, profile }
class SheetState {
const SheetState.hidden() : intent = null;
const SheetState.visible(this.intent);
final SheetIntent? intent;
bool get isVisible => intent != null;
}
sealed class SheetIntent {
const SheetIntent();
}
class LoginSheetIntent extends SheetIntent {
const LoginSheetIntent({required this.contextText});
final String contextText;
}
class FilterSheetIntent extends SheetIntent {
const FilterSheetIntent();
}
class OutboundSheetIntent extends SheetIntent {
const OutboundSheetIntent({required this.scene, this.refId, this.targetUrl});
final String scene;
final String? refId;
final String? targetUrl;
}
class ProfileListSheetIntent extends SheetIntent {
const ProfileListSheetIntent({
required this.kind,
required this.title,
this.reports = const [],
});
final ProfileListKind kind;
final String title;
final List<ReportCardModel> reports;
}
enum ProfileListKind { favorites, history, saved }
class OutboundEvent {
const OutboundEvent({required this.scene, this.refId, this.targetUrl});
final String scene;
final String? refId;
final String? targetUrl;
}
const Object _sentinel = Object();
+168
View File
@@ -0,0 +1,168 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../models/models.dart';
import '../repositories/user_state_repository.dart';
import 'app_interaction_state.dart';
import 'report_query.dart';
class RecommendTopicController extends StateNotifier<String> {
RecommendTopicController() : super('全部');
void select(String topic) {
state = topic;
}
}
class ReportFilterController extends StateNotifier<ReportQuery> {
ReportFilterController() : super(const ReportQuery());
void setSearch(String value) {
state = state.copyWith(search: value);
}
void setTopic(String? topic) {
state = state.copyWith(topic: topic);
}
void setInstitution(String? institutionId) {
state = state.copyWith(institutionId: institutionId);
}
void toggleAudio() {
state = state.copyWith(hasAudio: !state.hasAudio);
}
void setSort(ReportSort sort) {
state = state.copyWith(sort: sort);
}
void reset() {
state = const ReportQuery();
}
}
class AuthController extends StateNotifier<AuthState> {
AuthController(this._repository) : super(const AuthState()) {
_load();
}
final UserStateRepository _repository;
Future<void> _load() async {
state = state.copyWith(
loggedIn: await _repository.isLoggedIn(),
phone: await _repository.getPhone(),
loginMethod: await _repository.getLoginMethod(),
);
}
void requireLogin(PendingLoginAction action) {
if (state.loggedIn) return;
state = state.copyWith(pendingAction: action);
}
Future<PendingLoginAction?> login(LoginMethod method, {String? phone}) async {
final pending = state.pendingAction;
await _repository.login(method, phone: phone);
state = AuthState(
loggedIn: true,
phone: phone ?? await _repository.getPhone(),
loginMethod: method,
);
return pending;
}
Future<void> logout() async {
await _repository.logout();
state = const AuthState();
}
void clearPending() {
state = state.copyWith(pendingAction: null);
}
}
class ProfileController extends StateNotifier<ProfileState> {
ProfileController(this._repository) : super(const ProfileState()) {
refresh();
}
final UserStateRepository _repository;
Future<void> refresh() async {
state = ProfileState(
favorites: await _repository.getFavorites(),
savedListens: await _repository.getSavedListens(),
history: await _repository.getHistory(),
);
}
Future<void> toggleFavorite(String reportId) async {
await _repository.toggleFavorite(reportId);
await refresh();
}
Future<void> toggleSavedListen(String reportId) async {
await _repository.toggleSavedListen(reportId);
await refresh();
}
Future<void> addHistory(String reportId) async {
await _repository.addHistory(reportId);
await refresh();
}
}
class DetailNavigationController extends StateNotifier<DetailNavigationState> {
DetailNavigationController() : super(const DetailNavigationState());
void rememberTabScroll(AppTab tab, double scrollTop) {
state = state.copyWith(tabScroll: {...state.tabScroll, tab: scrollTop});
}
void push(DetailStackEntry entry, {required AppTab originTab}) {
final stack = [...state.stack, entry];
state = state.copyWith(originTab: originTab, stack: stack);
}
void updateCurrentScroll(double scrollTop) {
if (state.stack.isEmpty) return;
final stack = [...state.stack];
stack[stack.length - 1] = stack.last.copyWith(scrollTop: scrollTop);
state = state.copyWith(stack: stack);
}
DetailStackEntry? pop() {
if (state.stack.isEmpty) return null;
final stack = [...state.stack]..removeLast();
state = state.copyWith(stack: stack);
return stack.isEmpty ? null : stack.last;
}
void reset() {
state = const DetailNavigationState();
}
}
class SheetController extends StateNotifier<SheetState> {
SheetController() : super(const SheetState.hidden());
void show(SheetIntent intent) {
state = SheetState.visible(intent);
}
void hide() {
state = const SheetState.hidden();
}
}
class ProfileListBuilder {
const ProfileListBuilder(this.reports);
final List<ReportCardModel> reports;
List<ReportCardModel> byIds(Iterable<String> ids) {
final byId = {for (final report in reports) report.id: report};
return ids.map((id) => byId[id]).whereType<ReportCardModel>().toList();
}
}
+44
View File
@@ -0,0 +1,44 @@
class ReportQuery {
const ReportQuery({
this.search = '',
this.topic,
this.institutionId,
this.hasAudio = false,
this.sort = ReportSort.latest,
});
final String search;
final String? topic;
final String? institutionId;
final bool hasAudio;
final ReportSort sort;
bool get hasActiveFilter =>
search.trim().isNotEmpty ||
topic != null ||
institutionId != null ||
hasAudio ||
sort != ReportSort.latest;
ReportQuery copyWith({
String? search,
Object? topic = _sentinel,
Object? institutionId = _sentinel,
bool? hasAudio,
ReportSort? sort,
}) {
return ReportQuery(
search: search ?? this.search,
topic: identical(topic, _sentinel) ? this.topic : topic as String?,
institutionId: identical(institutionId, _sentinel)
? this.institutionId
: institutionId as String?,
hasAudio: hasAudio ?? this.hasAudio,
sort: sort ?? this.sort,
);
}
}
enum ReportSort { latest, oldest }
const Object _sentinel = Object();
+415
View File
@@ -0,0 +1,415 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../data/providers.dart';
import '../../data/state/app_interaction_state.dart';
import '../../routing/app_routes.dart';
import '../../theme/app_icons.dart';
import '../../theme/yanting_text.dart';
import '../../theme/yanting_tokens.dart';
import '../../widgets/app_buttons.dart';
import '../../widgets/app_card.dart';
import '../../widgets/page_header.dart';
import '../../widgets/states.dart';
class LoginPage extends HookConsumerWidget {
const LoginPage({super.key, this.next});
final String? next;
String _backTargetFromNext() {
final rawNext = next;
if (rawNext == null || rawNext.isEmpty) return AppRoutes.profile;
final decoded = Uri.decodeComponent(rawNext);
final uri = Uri.tryParse(decoded);
if (uri == null) return AppRoutes.profile;
if (!uri.path.startsWith('/')) return AppRoutes.profile;
return decoded;
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final phoneController = useTextEditingController();
final codeController = useTextEditingController();
final codeFocusNode = useFocusNode();
final codeSent = useState(false);
final sendingCode = useState(false);
final verifying = useState(false);
final countdown = useState(0);
final error = useState<String?>(null);
final agreed = useState(false);
final timerRef = useRef<Timer?>(null);
final auth = ref.watch(authControllerProvider);
final theme = ShadTheme.of(context);
useEffect(() {
return () {
timerRef.value?.cancel();
};
}, const []);
Future<void> sendCode() async {
final phone = phoneController.text.trim();
if (phone.length != 11) {
error.value = '请输入正确的手机号';
return;
}
sendingCode.value = true;
error.value = null;
try {
codeSent.value = true;
countdown.value = 60;
timerRef.value?.cancel();
timerRef.value = Timer.periodic(const Duration(seconds: 1), (timer) {
if (countdown.value <= 1) {
timer.cancel();
countdown.value = 0;
} else {
countdown.value = countdown.value - 1;
}
});
codeFocusNode.requestFocus();
} finally {
sendingCode.value = false;
}
}
Future<void> verify() async {
final phone = phoneController.text.trim();
final code = codeController.text.trim();
if (phone.length != 11) {
error.value = '请输入正确的手机号';
return;
}
if (code.length != 6) {
error.value = '请输入 6 位验证码';
return;
}
if (!agreed.value) {
error.value = '请先同意用户协议和隐私政策';
return;
}
verifying.value = true;
error.value = null;
try {
await ref
.read(authControllerProvider.notifier)
.login(LoginMethod.phone, phone: phone);
await ref.read(profileControllerProvider.notifier).refresh();
if (!context.mounted) return;
showAppToast(context, '已登录 ${maskPhone(phone)}');
final nextPath = next?.trim();
if (nextPath != null && nextPath.isNotEmpty) {
context.go(Uri.decodeComponent(nextPath));
} else if (context.canPop()) {
context.pop();
} else {
context.go(AppRoutes.profile);
}
} finally {
verifying.value = false;
}
}
Future<void> submitLogin() async {
final phone = phoneController.text.trim();
final code = codeController.text.trim();
if (phone.length != 11) {
error.value = '请输入正确的手机号';
return;
}
if (code.length != 6) {
error.value = '请输入 6 位验证码';
return;
}
await verify();
}
void showPrivacyCheckDialog(VoidCallback onAgreed) {
showModalBottomSheet<void>(
context: context,
enableDrag: false,
barrierColor: Colors.black.withValues(alpha: 0.75),
builder: (innerContext) => SafeArea(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.fromLTRB(22, 28, 22, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Text(
'请阅读并同意以下条款',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(height: 14),
Center(
child: Text.rich(
textAlign: TextAlign.center,
TextSpan(
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: theme.colorScheme.mutedForeground,
height: 1.7,
fontSize: 12,
),
children: [
const TextSpan(text: '登录前需要先阅读并同意 '),
TextSpan(
text: '《用户协议》',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: theme.colorScheme.primary,
fontSize: 12,
decoration: TextDecoration.underline,
decorationColor: theme.colorScheme.primary,
),
),
const TextSpan(text: ''),
TextSpan(
text: '《隐私政策》',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: theme.colorScheme.primary,
fontSize: 12,
decoration: TextDecoration.underline,
decorationColor: theme.colorScheme.primary,
),
),
],
),
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: AppButton(
label: '同意并发送验证码',
expand: true,
onPressed: () {
Navigator.of(innerContext).pop();
agreed.value = true;
onAgreed();
},
),
),
],
),
),
),
),
);
}
Future<void> onSendCodeTap() async {
if (sendingCode.value || countdown.value > 0) return;
if (!agreed.value) {
showPrivacyCheckDialog(() {
unawaited(sendCode());
});
return;
}
unawaited(sendCode());
}
final clickable = !sendingCode.value && !verifying.value;
return PopScope(
canPop: (next ?? '').isEmpty,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
if (!context.mounted) return;
final nextPath = next;
if (nextPath != null && nextPath.isNotEmpty) {
context.go(_backTargetFromNext());
return;
}
if (context.canPop()) {
context.pop();
return;
}
context.go(AppRoutes.profile);
},
child: Scaffold(
backgroundColor: theme.colorScheme.background,
appBar: AppBar(
leading: IconButton(
icon: const Icon(AppIcons.arrowLeft),
onPressed: () {
final nextPath = next;
if (nextPath != null && nextPath.isNotEmpty) {
context.go(_backTargetFromNext());
return;
}
if (context.canPop()) {
context.pop();
} else {
context.go(AppRoutes.profile);
}
},
),
title: const Text('登录 · Login'),
),
body: ListView(
padding: const EdgeInsets.fromLTRB(
YantingSpacing.screenX,
4,
YantingSpacing.screenX,
20,
),
children: [
const PageHeader(title: '登录研听', subtitle: '先把登录逻辑接通,弹窗入口保持不变'),
if (auth.loggedIn) ...[
AppCard(
color: theme.colorScheme.secondary,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('当前已登录', style: YantingText.cardTitle),
const SizedBox(height: 6),
Text(
auth.phone == null
? '本地登录态已生效'
: '手机号 ${maskPhone(auth.phone!)}',
style: YantingText.body.copyWith(
color: theme.colorScheme.mutedForeground,
),
),
],
),
),
const SizedBox(height: YantingSpacing.x3),
],
AppCard(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('手机号登录', style: YantingText.sectionTitle),
const SizedBox(height: 12),
ShadInput(
controller: phoneController,
placeholder: const Text('请输入手机号'),
keyboardType: TextInputType.phone,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
),
const SizedBox(height: 12),
ShadInput(
controller: codeController,
focusNode: codeFocusNode,
placeholder: const Text('验证码'),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
trailing: Padding(
padding: const EdgeInsets.only(right: 4),
child: AppButton(
label: countdown.value > 0
? '${countdown.value}s'
: '发送验证码',
kind: AppButtonKind.ghost,
compact: true,
onPressed: onSendCodeTap,
),
),
),
const SizedBox(height: 14),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: ShadCheckbox(
value: agreed.value,
onChanged: (value) => agreed.value = value,
),
),
const SizedBox(width: 10),
Expanded(
child: Text.rich(
TextSpan(
style: YantingText.meta.copyWith(
color: theme.colorScheme.mutedForeground,
height: 1.7,
),
children: [
const TextSpan(text: '已阅读并同意 '),
TextSpan(
text: '《用户协议》',
style: YantingText.meta.copyWith(
color: theme.colorScheme.primary,
),
),
const TextSpan(text: ''),
TextSpan(
text: '《隐私政策》',
style: YantingText.meta.copyWith(
color: theme.colorScheme.primary,
),
),
],
),
),
),
],
),
if (error.value != null) ...[
const SizedBox(height: 10),
Text(
error.value!,
style: YantingText.meta.copyWith(
color: theme.colorScheme.destructive,
),
),
],
const SizedBox(height: 18),
AppButton(
label: verifying.value ? '登录中...' : '登录',
expand: true,
onPressed: clickable
? () {
if (!agreed.value) {
showPrivacyCheckDialog(() {
unawaited(submitLogin());
});
return;
}
unawaited(submitLogin());
}
: null,
kind: AppButtonKind.primary,
),
],
),
),
const SizedBox(height: YantingSpacing.x3),
AppCard(
color: theme.colorScheme.secondary,
child: Text(
codeSent.value
? '验证码逻辑已接通,当前先用本地登录态串起页面流转。'
: '当前版本先做本地登录态和页面流转,后端接口接入后再替换为真实校验。',
style: YantingText.meta.copyWith(
color: theme.colorScheme.mutedForeground,
),
),
),
],
),
),
);
}
}
String maskPhone(String phone) {
if (phone.length < 7) return phone;
return '${phone.substring(0, 3)}****${phone.substring(phone.length - 4)}';
}
+261 -204
View File
@@ -1,11 +1,18 @@
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 'package:shadcn_ui/shadcn_ui.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 '../../../theme/wise_tokens.dart'; import '../../../theme/app_icons.dart';
import '../../../theme/yanting_text.dart';
import '../../../theme/yanting_tokens.dart';
import '../../../widgets/app_buttons.dart';
import '../../../widgets/app_card.dart'; import '../../../widgets/app_card.dart';
import '../../../widgets/badges.dart'; import '../../../widgets/badges.dart';
import '../../../widgets/mini_player.dart'; import '../../../widgets/mini_player.dart';
import '../../../widgets/states.dart';
typedef StartModuleAudio = typedef StartModuleAudio =
void Function( void Function(
@@ -48,7 +55,7 @@ class ModuleRendererRegistry {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_ModuleHeader(module: module), _ModuleHeader(module: module),
const SizedBox(height: WiseSpacing.x4), const SizedBox(height: YantingSpacing.x3),
_contentFor( _contentFor(
context, context,
type: module.type, type: module.type,
@@ -64,13 +71,14 @@ class ModuleRendererRegistry {
compact: module.renderMode != 'inline', compact: module.renderMode != 'inline',
), ),
if (module.hasDetailPage) ...[ if (module.hasDetailPage) ...[
const SizedBox(height: WiseSpacing.x4), const SizedBox(height: YantingSpacing.x3),
Align( Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: TextButton.icon( child: AppButton(
onPressed: openDetail, onPressed: openDetail,
icon: const Icon(Icons.open_in_new), icon: AppIcons.externalLink,
label: const Text('查看详情'), kind: AppButtonKind.ghost,
label: '查看详情',
), ),
), ),
], ],
@@ -151,7 +159,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,55 +176,87 @@ 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(
() => dataSource.moduleDetail(reportId, module.id),
[dataSource, reportId, module.id, retryCount.value],
);
final snapshot = useFuture(future);
final theme = ShadTheme.of(context);
return Scaffold(
backgroundColor: theme.colorScheme.background,
appBar: AppBar(
backgroundColor: theme.colorScheme.background,
surfaceTintColor: Colors.transparent,
elevation: 0,
title: Text(module.titleCn),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1),
child: ColoredBox(
color: theme.colorScheme.border,
child: const SizedBox(height: 1, width: double.infinity),
),
),
),
body: snapshot.connectionState != ConnectionState.done
? const LoadingState()
: snapshot.hasError
? Center(
child: AppButton(
onPressed: () => retryCount.value++,
kind: AppButtonKind.ghost,
label: snapshot.error.toString(),
),
)
: _ModuleDetailContent(
detail: snapshot.data!,
report: report,
registry: registry,
),
);
}
} }
class _ModuleDetailPageState extends State<ModuleDetailPage> { class _ModuleDetailContent extends StatelessWidget {
late Future<ModuleDetail> future = widget.dataSource.moduleDetail( const _ModuleDetailContent({
widget.reportId, required this.detail,
widget.module.id, required this.report,
); required this.registry,
});
final ModuleDetail detail;
final ReportDetail report;
final ModuleRendererRegistry registry;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.module.titleCn)),
body: FutureBuilder<ModuleDetail>(
future: future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Text(
snapshot.error.toString(),
textAlign: TextAlign.center,
),
);
}
final detail = snapshot.data!;
return ListView( return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4), padding: const EdgeInsets.fromLTRB(
YantingSpacing.x4,
4,
YantingSpacing.x4,
16,
),
children: [ children: [
Text(
detail.titleCn,
style: YantingText.sectionTitle.copyWith(fontSize: 21),
),
const SizedBox(height: YantingSpacing.x2),
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: YantingSpacing.x3),
Text( Text('缓存版本 ${detail.cacheVersion}', style: YantingText.meta),
'缓存版本 ${detail.cacheVersion}',
style: Theme.of(context).textTheme.bodySmall,
),
], ],
); );
},
),
);
} }
} }
@@ -232,7 +272,7 @@ class _ModuleHeader extends StatelessWidget {
Expanded( Expanded(
child: Text( child: Text(
module.titleCn, module.titleCn,
style: Theme.of(context).textTheme.titleMedium, style: YantingText.cardTitle.copyWith(fontSize: 17),
), ),
), ),
if (module.layer.isNotEmpty) if (module.layer.isNotEmpty)
@@ -261,12 +301,12 @@ class _BasicInfo extends StatelessWidget {
payload['summary_cn'], payload['summary_cn'],
asString(payload['scope_cn'], report?.oneLiner ?? ''), asString(payload['scope_cn'], report?.oneLiner ?? ''),
), ),
style: Theme.of(context).textTheme.bodyMedium, style: YantingText.body,
), ),
const SizedBox(height: WiseSpacing.x2), const SizedBox(height: YantingSpacing.x2),
Wrap( Wrap(
spacing: WiseSpacing.x2, spacing: YantingSpacing.x2,
runSpacing: WiseSpacing.x2, runSpacing: YantingSpacing.x2,
children: [ children: [
for (final topic in topics) AppBadge(text: topic), for (final topic in topics) AppBadge(text: topic),
if (report?.releasedAt != null) if (report?.releasedAt != null)
@@ -285,14 +325,21 @@ class _CoreInsights extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
final points = asMapList(payload['points']); final points = asMapList(payload['points']);
if (points.isEmpty) return _TextLines(payload: payload); if (points.isEmpty) return _TextLines(payload: payload);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
for (final point in points) for (final point in points)
Padding( Container(
padding: const EdgeInsets.only(bottom: WiseSpacing.x3), margin: const EdgeInsets.only(bottom: YantingSpacing.x3),
padding: const EdgeInsets.all(YantingSpacing.x3),
decoration: BoxDecoration(
color: colors.background,
border: Border.all(color: colors.border),
borderRadius: BorderRadius.circular(YantingRadius.md),
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -300,11 +347,8 @@ class _CoreInsights extends StatelessWidget {
text: _kindLabel(asString(point['kind'])), text: _kindLabel(asString(point['kind'])),
kind: _kindBadge(asString(point['kind'])), kind: _kindBadge(asString(point['kind'])),
), ),
const SizedBox(height: WiseSpacing.x1), const SizedBox(height: YantingSpacing.x1),
Text( Text(asString(point['text']), style: YantingText.body),
asString(point['text']),
style: Theme.of(context).textTheme.bodyMedium,
),
], ],
), ),
), ),
@@ -321,19 +365,17 @@ class _SourceCompliance extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
final institution = report?.institution; final institution = report?.institution;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (asString(payload['source_note']).isNotEmpty) if (asString(payload['source_note']).isNotEmpty)
Text( Text(asString(payload['source_note']), style: YantingText.body),
asString(payload['source_note']),
style: Theme.of(context).textTheme.bodyMedium,
),
if (institution != null) ...[ if (institution != null) ...[
const SizedBox(height: WiseSpacing.x4), const SizedBox(height: YantingSpacing.x4),
Text('发布机构', style: Theme.of(context).textTheme.titleMedium), Text('发布机构', style: YantingText.cardTitle.copyWith(fontSize: 17)),
const SizedBox(height: WiseSpacing.x2), const SizedBox(height: YantingSpacing.x2),
_InfoLine(label: '机构名称', value: institution.nameCn), _InfoLine(label: '机构名称', value: institution.nameCn),
if (institution.nameEn.isNotEmpty) if (institution.nameEn.isNotEmpty)
_InfoLine(label: '英文名称', value: institution.nameEn), _InfoLine(label: '英文名称', value: institution.nameEn),
@@ -357,25 +399,21 @@ class _SourceCompliance extends StatelessWidget {
_InfoLine(label: '说明', value: institution.introCn), _InfoLine(label: '说明', value: institution.introCn),
], ],
if (asString(payload['copyright_cn']).isNotEmpty) ...[ if (asString(payload['copyright_cn']).isNotEmpty) ...[
const SizedBox(height: WiseSpacing.x4), const SizedBox(height: YantingSpacing.x4),
Text( Text(asString(payload['copyright_cn']), style: YantingText.meta),
asString(payload['copyright_cn']),
style: Theme.of(context).textTheme.bodySmall,
),
], ],
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: YantingSpacing.x3),
DecoratedBox( DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0x109A6500), color: colors.background,
borderRadius: BorderRadius.circular(WiseRadius.sm), border: Border.all(color: colors.border),
borderRadius: BorderRadius.circular(YantingRadius.md),
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.all(WiseSpacing.x3), padding: const EdgeInsets.all(YantingSpacing.x3),
child: Text( child: Text(
asString(payload['disclaimer'], '本内容为公开/授权研报的结构化解读,不构成投资建议。'), asString(payload['disclaimer'], '本内容为公开/授权研报的结构化解读,不构成投资建议。'),
style: Theme.of( style: YantingText.meta.copyWith(color: colors.warning),
context,
).textTheme.bodySmall?.copyWith(color: WiseColors.warning),
), ),
), ),
), ),
@@ -392,20 +430,21 @@ class _InfoLine extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
if (value.isEmpty) return const SizedBox.shrink(); if (value.isEmpty) return const SizedBox.shrink();
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x2), padding: const EdgeInsets.only(bottom: YantingSpacing.x2),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
label, label,
style: Theme.of( style: YantingText.badge.copyWith(
context, color: colors.mutedForeground,
).textTheme.labelSmall?.copyWith(color: WiseColors.ink700),
), ),
const SizedBox(height: WiseSpacing.x1), ),
Text(value, style: Theme.of(context).textTheme.bodyMedium), const SizedBox(height: YantingSpacing.x1),
Text(value, style: YantingText.body),
], ],
), ),
); );
@@ -460,17 +499,16 @@ class _InstitutionModule extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
final name = asString(payload['name_cn'], report?.institution.nameCn ?? ''); final name = asString(payload['name_cn'], report?.institution.nameCn ?? '');
return Row( return Row(
children: [ children: [
const Icon(Icons.account_balance_outlined, color: WiseColors.primary), Icon(AppIcons.bank, color: colors.foreground),
const SizedBox(width: WiseSpacing.x2), const SizedBox(width: YantingSpacing.x2),
Expanded( Expanded(child: Text(name, style: YantingText.body)),
child: Text(name, style: Theme.of(context).textTheme.bodyMedium),
),
Text( Text(
'${asInt(payload['report_count'], report?.institution.reportCount ?? 0)}', '${asInt(payload['report_count'], report?.institution.reportCount ?? 0)}',
style: Theme.of(context).textTheme.bodySmall, style: YantingText.meta,
), ),
], ],
); );
@@ -494,19 +532,15 @@ class _SectionsModule extends StatelessWidget {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (summary.isNotEmpty) if (summary.isNotEmpty) Text(summary, style: YantingText.body),
Text(summary, style: Theme.of(context).textTheme.bodyMedium),
for (final section in sections) ...[ for (final section in sections) ...[
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: YantingSpacing.x3),
Text( Text(
asString(section['heading']), asString(section['heading']),
style: Theme.of(context).textTheme.titleMedium, style: YantingText.cardTitle.copyWith(fontSize: 17),
),
const SizedBox(height: WiseSpacing.x1),
Text(
asString(section['body']),
style: Theme.of(context).textTheme.bodyMedium,
), ),
const SizedBox(height: YantingSpacing.x1),
Text(asString(section['body']), style: YantingText.body),
], ],
], ],
); );
@@ -521,45 +555,45 @@ class _KeyDataModule extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
if (compact) return _Preview(payload: payload); if (compact) return _Preview(payload: payload);
final rows = asMapList(payload['rows']); final rows = asMapList(payload['rows']);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
for (final row in rows) for (final row in rows)
Padding( Container(
padding: const EdgeInsets.only(bottom: WiseSpacing.x4), margin: const EdgeInsets.only(bottom: YantingSpacing.x3),
padding: const EdgeInsets.all(YantingSpacing.x3),
decoration: BoxDecoration(
color: colors.secondary,
borderRadius: BorderRadius.circular(YantingRadius.md),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(asString(row['metric']), style: YantingText.meta),
asString(row['metric']), const SizedBox(height: 6),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: WiseSpacing.x1),
Text(
_valueWithUnit(row),
style: Theme.of(context).textTheme.bodyLarge,
),
if (asString(
row['judgment'],
asString(row['importance']),
).isNotEmpty) ...[
const SizedBox(height: WiseSpacing.x1),
Text( Text(
asString(row['judgment'], asString(row['importance'])), asString(row['judgment'], asString(row['importance'])),
style: Theme.of(context).textTheme.bodyMedium, style: YantingText.body.copyWith(color: colors.foreground),
), ),
], ],
if (asString(row['importance']).isNotEmpty && ),
asString(row['importance']) != ),
asString(row['judgment'])) ...[ const SizedBox(width: YantingSpacing.x2),
const SizedBox(height: WiseSpacing.x1),
Text( Text(
asString(row['importance']), _valueWithUnit(row),
style: Theme.of(context).textTheme.bodySmall, textAlign: TextAlign.right,
style: YantingText.cardTitle.copyWith(
fontSize: 17,
fontFeatures: YantingTypographyFeatures.tabularNums,
),
), ),
],
], ],
), ),
), ),
@@ -581,33 +615,78 @@ class _TimelineModule extends StatelessWidget {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
for (final event in events) for (var index = 0; index < events.length; index++)
Padding( _TimelineEntry(
padding: const EdgeInsets.only(bottom: WiseSpacing.x4), event: events[index],
isLast: index == events.length - 1,
),
],
);
}
}
class _TimelineEntry extends StatelessWidget {
const _TimelineEntry({required this.event, required this.isLast});
final JsonMap event;
final bool isLast;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
children: [
Container(
width: 9,
height: 9,
margin: const EdgeInsets.only(top: 6),
decoration: BoxDecoration(
color: colors.primary,
shape: BoxShape.circle,
),
),
if (!isLast)
Expanded(
child: Container(
width: 1,
margin: const EdgeInsets.symmetric(vertical: 4),
color: colors.border,
),
),
],
),
const SizedBox(width: YantingSpacing.x2),
Expanded(
child: Padding(
padding: const EdgeInsets.only(bottom: YantingSpacing.x3),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (asString(event['date']).isNotEmpty) if (asString(event['date']).isNotEmpty)
Text( Text(
asString(event['date']), asString(event['date']),
style: Theme.of( style: YantingText.meta.copyWith(
context, color: colors.foreground,
).textTheme.labelSmall?.copyWith(color: WiseColors.primary), fontWeight: FontWeight.w600,
), ),
const SizedBox(height: WiseSpacing.x1), ),
const SizedBox(height: YantingSpacing.x1),
Text( Text(
asString(event['event']), asString(event['event']),
style: Theme.of(context).textTheme.titleMedium, style: YantingText.cardTitle.copyWith(fontSize: 17),
),
const SizedBox(height: WiseSpacing.x1),
Text(
asString(event['impact']),
style: Theme.of(context).textTheme.bodyMedium,
), ),
const SizedBox(height: YantingSpacing.x1),
Text(asString(event['impact']), style: YantingText.body),
], ],
), ),
), ),
),
], ],
),
); );
} }
} }
@@ -627,29 +706,23 @@ class _StudyGuideModule extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (asString(payload['intro_cn']).isNotEmpty) if (asString(payload['intro_cn']).isNotEmpty)
Text( Text(asString(payload['intro_cn']), style: YantingText.body),
asString(payload['intro_cn']),
style: Theme.of(context).textTheme.bodyMedium,
),
for (final item in faqs) for (final item in faqs)
ExpansionTile( ExpansionTile(
tilePadding: EdgeInsets.zero, tilePadding: EdgeInsets.zero,
title: Text(asString(item['question'])), title: Text(asString(item['question']), style: YantingText.body),
children: [ children: [
Align( Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Text( child: Text(asString(item['answer']), style: YantingText.body),
asString(item['answer']),
style: Theme.of(context).textTheme.bodyMedium,
),
), ),
], ],
), ),
if (glossary.isNotEmpty) ...[ if (glossary.isNotEmpty) ...[
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: YantingSpacing.x3),
Wrap( Wrap(
spacing: WiseSpacing.x2, spacing: YantingSpacing.x2,
runSpacing: WiseSpacing.x2, runSpacing: YantingSpacing.x2,
children: [ children: [
for (final item in glossary) for (final item in glossary)
AppBadge( AppBadge(
@@ -679,27 +752,24 @@ class _StructureGraphModule extends StatelessWidget {
children: [ children: [
Text( Text(
asString(payload['root']), asString(payload['root']),
style: Theme.of(context).textTheme.titleMedium, style: YantingText.cardTitle.copyWith(fontSize: 17),
), ),
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: YantingSpacing.x3),
for (final node in nodes) for (final node in nodes)
Padding( Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x4), padding: const EdgeInsets.only(bottom: YantingSpacing.x4),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
asString(node['label']), asString(node['label']),
style: Theme.of(context).textTheme.titleMedium, style: YantingText.cardTitle.copyWith(fontSize: 17),
), ),
const SizedBox(height: WiseSpacing.x1), const SizedBox(height: YantingSpacing.x1),
for (final child in asStringList(node['children'])) for (final child in asStringList(node['children']))
Padding( Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x1), padding: const EdgeInsets.only(bottom: YantingSpacing.x1),
child: Text( child: Text(child, style: YantingText.body),
child,
style: Theme.of(context).textTheme.bodyMedium,
),
), ),
], ],
), ),
@@ -724,18 +794,18 @@ class _RelatedSourcesModule extends StatelessWidget {
children: [ children: [
for (final item in items) for (final item in items)
Padding( Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x4), padding: const EdgeInsets.only(bottom: YantingSpacing.x4),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
asString(item['title']), asString(item['title']),
style: Theme.of(context).textTheme.titleMedium, style: YantingText.cardTitle.copyWith(fontSize: 17),
), ),
const SizedBox(height: WiseSpacing.x1), const SizedBox(height: YantingSpacing.x1),
Text( Text(
asString(item['summary_cn'], asString(item['source_name'])), asString(item['summary_cn'], asString(item['source_name'])),
style: Theme.of(context).textTheme.bodyMedium, style: YantingText.body,
), ),
], ],
), ),
@@ -756,6 +826,7 @@ class _DifferentiatedViewModule extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
if (compact) return _Preview(payload: payload); if (compact) return _Preview(payload: payload);
final items = asMapList(payload['divergences']); final items = asMapList(payload['divergences']);
return Column( return Column(
@@ -763,40 +834,40 @@ class _DifferentiatedViewModule extends StatelessWidget {
children: [ children: [
for (final item in items) for (final item in items)
Padding( Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x4), padding: const EdgeInsets.only(bottom: YantingSpacing.x4),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
asString(item['topic']), asString(item['topic']),
style: Theme.of(context).textTheme.titleMedium, style: YantingText.cardTitle.copyWith(fontSize: 17),
), ),
const SizedBox(height: WiseSpacing.x2), const SizedBox(height: YantingSpacing.x2),
if (asString(item['consensus_view']).isNotEmpty) ...[ if (asString(item['consensus_view']).isNotEmpty) ...[
Text( Text(
'常见观点', '常见观点',
style: Theme.of( style: YantingText.badge.copyWith(
context, color: colors.mutedForeground,
).textTheme.labelSmall?.copyWith(color: WiseColors.ink700),
), ),
const SizedBox(height: WiseSpacing.x1), ),
const SizedBox(height: YantingSpacing.x1),
Text( Text(
asString(item['consensus_view']), asString(item['consensus_view']),
style: Theme.of(context).textTheme.bodyMedium, style: YantingText.body,
), ),
const SizedBox(height: WiseSpacing.x2), const SizedBox(height: YantingSpacing.x2),
], ],
if (asString(item['report_position']).isNotEmpty) ...[ if (asString(item['report_position']).isNotEmpty) ...[
Text( Text(
'报告观点', '报告观点',
style: Theme.of( style: YantingText.badge.copyWith(
context, color: colors.foreground,
).textTheme.labelSmall?.copyWith(color: WiseColors.primary),
), ),
const SizedBox(height: WiseSpacing.x1), ),
const SizedBox(height: YantingSpacing.x1),
Text( Text(
asString(item['report_position']), asString(item['report_position']),
style: Theme.of(context).textTheme.bodyMedium, style: YantingText.body,
), ),
], ],
], ],
@@ -815,6 +886,7 @@ class _WeaknessesModule extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
if (compact) return _Preview(payload: payload); if (compact) return _Preview(payload: payload);
final items = asMapList(payload['items']); final items = asMapList(payload['items']);
final verificationNotes = asStringList(payload['verification_notes']); final verificationNotes = asStringList(payload['verification_notes']);
@@ -827,60 +899,49 @@ class _WeaknessesModule extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (asString(payload['disclaimer_cn']).isNotEmpty) if (asString(payload['disclaimer_cn']).isNotEmpty)
Text( Text(asString(payload['disclaimer_cn']), style: YantingText.meta),
asString(payload['disclaimer_cn']),
style: Theme.of(context).textTheme.bodySmall,
),
for (final item in items) for (final item in items)
Padding( Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
top: WiseSpacing.x3, top: YantingSpacing.x3,
bottom: WiseSpacing.x2, bottom: YantingSpacing.x2,
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
asString(item['topic']), asString(item['topic']),
style: Theme.of(context).textTheme.titleMedium, style: YantingText.cardTitle.copyWith(fontSize: 17),
),
const SizedBox(height: WiseSpacing.x1),
Text(
asString(item['weakness']),
style: Theme.of(context).textTheme.bodyMedium,
), ),
const SizedBox(height: YantingSpacing.x1),
Text(asString(item['weakness']), style: YantingText.body),
], ],
), ),
), ),
if (verificationNotes.isNotEmpty || counterEvidence.isNotEmpty) ...[ if (verificationNotes.isNotEmpty || counterEvidence.isNotEmpty) ...[
const SizedBox(height: WiseSpacing.x2), const SizedBox(height: YantingSpacing.x2),
DecoratedBox( DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0x109A6500), color: colors.warningSoft.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(WiseRadius.sm), borderRadius: BorderRadius.circular(YantingRadius.sm),
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.all(WiseSpacing.x3), padding: const EdgeInsets.all(YantingSpacing.x3),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'需要继续验证', '需要继续验证',
style: Theme.of( style: YantingText.badge.copyWith(color: colors.warning),
context,
).textTheme.labelSmall?.copyWith(color: WiseColors.warning),
), ),
const SizedBox(height: WiseSpacing.x1), const SizedBox(height: YantingSpacing.x1),
for (final note for (final note
in verificationNotes.isNotEmpty in verificationNotes.isNotEmpty
? verificationNotes ? verificationNotes
: counterEvidence) : counterEvidence)
Padding( Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x1), padding: const EdgeInsets.only(bottom: YantingSpacing.x1),
child: Text( child: Text(note, style: YantingText.meta),
note,
style: Theme.of(context).textTheme.bodySmall,
),
), ),
], ],
), ),
@@ -907,15 +968,11 @@ class _Preview extends StatelessWidget {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (headline.isNotEmpty) if (headline.isNotEmpty) Text(headline, style: YantingText.body),
Text(headline, style: Theme.of(context).textTheme.bodyMedium),
for (final item in highlights.take(3)) for (final item in highlights.take(3))
Padding( Padding(
padding: const EdgeInsets.only(top: WiseSpacing.x1), padding: const EdgeInsets.only(top: YantingSpacing.x1),
child: Text( child: Text('$item', style: YantingText.meta),
'$item',
style: Theme.of(context).textTheme.bodySmall,
),
), ),
], ],
); );
@@ -938,7 +995,7 @@ class _TextLines extends StatelessWidget {
.join('\n'); .join('\n');
return Text( return Text(
values.isEmpty ? '该模块暂无可展示内容。' : values, values.isEmpty ? '该模块暂无可展示内容。' : values,
style: Theme.of(context).textTheme.bodyMedium, style: YantingText.body,
); );
} }
} }
@@ -955,7 +1012,7 @@ class _FallbackModule extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
AppBadge(text: '未知模块:$type', kind: BadgeKind.warning), AppBadge(text: '未知模块:$type', kind: BadgeKind.warning),
const SizedBox(height: WiseSpacing.x2), const SizedBox(height: YantingSpacing.x2),
_Preview(payload: payload), _Preview(payload: payload),
], ],
); );
+213 -61
View File
@@ -1,8 +1,15 @@
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 'package:shadcn_ui/shadcn_ui.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 '../../theme/wise_tokens.dart'; import '../../data/providers.dart';
import '../../data/state/app_interaction_state.dart';
import '../../theme/app_icons.dart';
import '../../theme/yanting_text.dart';
import '../../theme/yanting_tokens.dart';
import '../../widgets/app_buttons.dart'; import '../../widgets/app_buttons.dart';
import '../../widgets/app_card.dart'; import '../../widgets/app_card.dart';
import '../../widgets/badges.dart'; import '../../widgets/badges.dart';
@@ -11,7 +18,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,45 +45,100 @@ 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(() => dataSource.reportDetail(reportId), [
dataSource,
reportId,
retryCount.value,
]);
final snapshot = useFuture(detailFuture);
const registry = ModuleRendererRegistry();
final theme = ShadTheme.of(context);
class _ReportDetailPageState extends State<ReportDetailPage> {
static const registry = ModuleRendererRegistry();
late Future<ReportDetail> future = widget.dataSource.reportDetail(
widget.reportId,
);
@override
Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('研报详情')), backgroundColor: theme.colorScheme.background,
body: FutureBuilder<ReportDetail>( appBar: AppBar(
future: future, backgroundColor: theme.colorScheme.background,
builder: (context, snapshot) { surfaceTintColor: Colors.transparent,
if (snapshot.connectionState != ConnectionState.done) { elevation: 0,
return const LoadingState(); title: const Text('研报详情'),
} bottom: PreferredSize(
if (snapshot.hasError) { preferredSize: const Size.fromHeight(1),
return ErrorState( child: ColoredBox(
color: theme.colorScheme.border,
child: const SizedBox(height: 1, width: double.infinity),
),
),
),
body: snapshot.connectionState != ConnectionState.done
? const LoadingState()
: snapshot.hasError
? 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) {
final colors = ShadTheme.of(context).colorScheme;
return ListView( return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4), padding: const EdgeInsets.fromLTRB(
YantingSpacing.x4,
4,
YantingSpacing.x4,
16,
),
children: [ children: [
AppCard( AppCard(
color: WiseColors.secondary200, color: colors.brandSoft,
borderColor: colors.brandSoftBorder,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Wrap( Wrap(
spacing: WiseSpacing.x2, spacing: YantingSpacing.x2,
runSpacing: WiseSpacing.x2, runSpacing: YantingSpacing.x2,
children: [ children: [
AppBadge( AppBadge(
text: detail.interpretationLabel, text: detail.interpretationLabel,
@@ -85,94 +147,184 @@ class _ReportDetailPageState extends State<ReportDetailPage> {
if (detail.hasAudio) if (detail.hasAudio)
const AppBadge( const AppBadge(
text: '音频', text: '音频',
icon: Icons.graphic_eq, icon: AppIcons.playCircle,
kind: BadgeKind.audio, kind: BadgeKind.audio,
), ),
AppBadge( AppBadge(
text: asString(detail.source['source_tier']), text: asString(detail.source['source_tier']),
icon: Icons.verified_outlined,
kind: BadgeKind.tier, kind: BadgeKind.tier,
), ),
], ],
), ),
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: YantingSpacing.x3),
Text( Text(
detail.titleCn, detail.titleCn,
maxLines: 3, maxLines: 3,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.headlineSmall, style: YantingText.sectionTitle.copyWith(fontSize: 21),
), ),
if (detail.oneLiner.isNotEmpty) ...[ if (detail.oneLiner.isNotEmpty) ...[
const SizedBox(height: WiseSpacing.x2), const SizedBox(height: YantingSpacing.x2),
Text( Text(detail.oneLiner, style: YantingText.body),
detail.oneLiner,
style: Theme.of(context).textTheme.bodyMedium,
),
], ],
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: YantingSpacing.x3),
Text( Text(
'${detail.institution.nameCn} · ${formatDate(detail.releasedAt)}', '${detail.institution.nameCn} · ${formatDate(detail.releasedAt)}',
style: Theme.of(context).textTheme.bodySmall, style: YantingText.meta,
), ),
], ],
), ),
), ),
const SizedBox(height: WiseSpacing.x4), const SizedBox(height: YantingSpacing.x4),
_ActionBar(detail: detail), _ActionBar(detail: detail),
const SizedBox(height: WiseSpacing.x4), const SizedBox(height: YantingSpacing.x4),
_Toc(modules: detail.modules), _Toc(modules: detail.modules),
const SizedBox(height: WiseSpacing.x4), const SizedBox(height: YantingSpacing.x4),
for (final module in detail.modules) ...[ for (final module in detail.modules) ...[
registry.card( registry.card(
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: YantingSpacing.x4),
], ],
], ],
); );
},
),
);
} }
} }
class _ActionBar extends StatelessWidget { class _ActionBar extends ConsumerWidget {
const _ActionBar({required this.detail}); const _ActionBar({required this.detail});
final ReportDetail detail; final ReportDetail detail;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final auth = ref.watch(authControllerProvider);
final profile = ref.watch(profileControllerProvider);
final isFavorite = profile.favorites.contains(detail.id);
final isSavedListen = profile.savedListens.contains(detail.id);
return Row( return Row(
children: [ children: [
Expanded( Expanded(
child: AppButton( child: AppButton(
label: '收藏', label: isFavorite ? '已收藏' : '收藏',
icon: Icons.favorite_border, icon: isFavorite ? AppIcons.heartFill : AppIcons.heart,
kind: AppButtonKind.ghost, kind: AppButtonKind.ghost,
onPressed: () => showLoginSheet(context, reason: '登录后保存到你的收藏'), onPressed: () => _runLoginRequiredAction(
context,
ref,
auth,
PendingLoginAction(
action: LoginRequiredAction.favorite,
reportId: detail.id,
contextText: '登录后保存到你的收藏',
), ),
), ),
const SizedBox(width: WiseSpacing.x2), ),
),
const SizedBox(width: YantingSpacing.x2),
if (detail.hasAudio) ...[
Expanded(
child: AppButton(
label: isSavedListen ? '已存听单' : '听单',
icon: isSavedListen
? AppIcons.headphonesFill
: AppIcons.headphones,
kind: AppButtonKind.ghost,
onPressed: () => _runLoginRequiredAction(
context,
ref,
auth,
PendingLoginAction(
action: LoginRequiredAction.saveListen,
reportId: detail.id,
contextText: '登录后保存到你的听单',
),
),
),
),
const SizedBox(width: YantingSpacing.x2),
],
Expanded( Expanded(
child: AppButton( child: AppButton(
label: '原文', label: '原文',
icon: Icons.open_in_new, icon: AppIcons.externalLink,
kind: AppButtonKind.ghost, kind: AppButtonKind.ghost,
onPressed: () => showOutboundSheet(context, title: detail.titleCn), onPressed: () => _showSourceSheet(context, ref),
), ),
), ),
], ],
); );
} }
void _runLoginRequiredAction(
BuildContext context,
WidgetRef ref,
AuthState auth,
PendingLoginAction action,
) {
if (auth.loggedIn) {
_applyPendingAction(ref, action);
return;
}
ref.read(authControllerProvider.notifier).requireLogin(action);
showLoginSheet(
context,
reason: action.contextText,
onPhoneLogin: () => _loginAndApply(ref, LoginMethod.phone),
onSecondaryLogin: () => _loginAndApply(ref, LoginMethod.wechat),
);
}
void _loginAndApply(WidgetRef ref, LoginMethod method) {
ref.read(authControllerProvider.notifier).login(method).then((pending) {
if (pending != null) {
_applyPendingAction(ref, pending);
}
});
}
void _applyPendingAction(WidgetRef ref, PendingLoginAction action) {
final controller = ref.read(profileControllerProvider.notifier);
switch (action.action) {
case LoginRequiredAction.favorite:
controller.toggleFavorite(action.reportId);
case LoginRequiredAction.saveListen:
controller.toggleSavedListen(action.reportId);
}
}
void _showSourceSheet(BuildContext context, WidgetRef ref) {
final targetUrl = asString(
detail.source['url'],
asString(
detail.source['source_url'],
asString(detail.source['original_url']),
),
);
showOutboundSheet(
context,
title: detail.titleCn,
onConfirm: () => ref
.read(outboundRepositoryProvider)
.recordOutbound(
OutboundEvent(
scene: 'report_source',
refId: detail.id,
targetUrl: targetUrl.isEmpty ? null : targetUrl,
),
),
);
}
} }
class _Toc extends StatelessWidget { class _Toc extends StatelessWidget {
@@ -189,7 +341,7 @@ class _Toc extends StatelessWidget {
children: [ children: [
for (final module in modules) for (final module in modules)
Padding( Padding(
padding: const EdgeInsets.only(right: WiseSpacing.x2), padding: const EdgeInsets.only(right: YantingSpacing.x2),
child: AppBadge(text: module.titleCn, kind: BadgeKind.brand), child: AppBadge(text: module.titleCn, kind: BadgeKind.brand),
), ),
], ],
+87 -55
View File
@@ -1,15 +1,19 @@
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 '../../data/providers.dart';
import '../../routing/app_routes.dart'; import '../../routing/app_routes.dart';
import '../../theme/wise_tokens.dart'; import '../../theme/yanting_tokens.dart';
import '../../widgets/badges.dart'; import '../../widgets/badges.dart';
import '../../widgets/mini_player.dart'; import '../../widgets/mini_player.dart';
import '../../widgets/page_header.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,
@@ -24,92 +28,121 @@ class FeedPage 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<FeedPage> createState() => _FeedPageState(); Widget build(BuildContext context, WidgetRef ref) {
} final currentTopic = ref.watch(recommendTopicProvider);
final snapshot = ref.watch(recommendedByTopicProvider);
const topics = ['全部', '宏观', '贵金属', '大宗', '能源', '跨资产', '央行'];
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(recommendedByTopicProvider),
Widget build(BuildContext context) { ),
return FutureBuilder<List<ReportCardModel>>( data: (items) {
future: future, if (items.isEmpty) {
builder: (context, snapshot) { return EmptyState(
if (snapshot.connectionState != ConnectionState.done) return const LoadingState(); title: currentTopic == '全部' ? '暂无可推荐的研报解读' : '当前主题暂无内容',
if (snapshot.hasError) return ErrorState(message: snapshot.error.toString(), onRetry: () => setState(() => future = widget.dataSource.recommended())); message: currentTopic == '全部' ? '稍后再来看看最新内容' : '换个主题,或去研报页看看全部内容',
final items = snapshot.data ?? const []; icon: currentTopic == '全部'
final topics = ['全部', ...{for (final item in items) ...item.topics}]; ? Icons.inbox_outlined
final visible = topic == '全部' ? items : items.where((item) => item.topics.contains(topic)).toList(); : Icons.filter_alt_off,
if (items.isEmpty) return const EmptyState(title: '暂无可推荐的研报解读', message: '稍后再来看看最新内容'); );
}
return ListView( return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4), padding: const EdgeInsets.fromLTRB(
YantingSpacing.screenX,
4,
YantingSpacing.screenX,
16,
),
children: [ children: [
const PageHeader(title: '研听', subtitle: '全球机构研报中文解读'),
SingleChildScrollView( SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: Row( child: Row(
children: [ children: [
for (final t in topics) for (final t in topics)
Padding( Padding(
padding: const EdgeInsets.only(right: WiseSpacing.x2), padding: const EdgeInsets.only(right: YantingSpacing.x2),
child: AppChip(label: t, selected: t == topic, onTap: () => setState(() => topic = t)), child: AppChip(
label: t,
selected: t == currentTopic,
onTap: () =>
ref.read(recommendTopicProvider.notifier).select(t),
),
), ),
], ],
), ),
), ),
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: YantingSpacing.cardGap),
if (visible.isEmpty)
EmptyState(title: '暂无可推荐的研报解读', message: '换个主题,或去研报页看看全部内容', icon: Icons.filter_alt_off)
else ...[
ReportCardWidget( ReportCardWidget(
report: visible.first, report: items.first,
hero: true, hero: true,
onTap: () => openReportDetail( onTap: () {
ref
.read(profileControllerProvider.notifier)
.addHistory(items.first.id);
openReportDetail(
context, context,
widget.dataSource, dataSource,
visible.first, items.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(onPlay, items.first),
), ),
onPlayTap: () => playFromReport(widget.onPlay, visible.first), const SizedBox(height: YantingSpacing.sectionGap),
), const SectionTitle(title: '最新解读', icon: Icons.chevron_right),
const SizedBox(height: WiseSpacing.x5), for (final report in items.skip(1)) ...[
Text('最新解读', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: WiseSpacing.x3),
for (final report in visible.skip(1)) ...[
ReportCardWidget( ReportCardWidget(
report: report, report: report,
onTap: () => openReportDetail( onTap: () {
ref
.read(profileControllerProvider.notifier)
.addHistory(report.id);
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(onPlay, report),
), ),
onPlayTap: () => playFromReport(widget.onPlay, report), const SizedBox(height: YantingSpacing.x3),
),
const SizedBox(height: WiseSpacing.x3),
],
], ],
], ],
); );
}, },
); );
} }
}
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 +153,4 @@ class _FeedPageState extends State<FeedPage> {
institution: report.institution, institution: report.institution,
), ),
); );
}
} }
+221
View File
@@ -0,0 +1,221 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../data/api/report_data_source.dart';
import '../../data/content_providers.dart';
import '../../data/models/models.dart';
import '../../routing/app_routes.dart';
import '../../theme/app_icons.dart';
import '../../theme/yanting_text.dart';
import '../../theme/yanting_tokens.dart';
import '../../widgets/app_card.dart';
import '../../widgets/badges.dart';
import '../../widgets/mini_player.dart';
import '../../widgets/page_header.dart';
import '../../widgets/states.dart';
import '../shared/report_card_widget.dart';
class HomePage extends HookConsumerWidget {
const HomePage({
required this.dataSource,
required this.onPlay,
this.player = const PlayerStateModel(),
this.onStartModuleAudio,
this.onToggleAudio,
this.onSeekAudio,
this.onSpeed,
super.key,
});
final ReportDataSource dataSource;
final void Function(AudioItem item) onPlay;
final PlayerStateModel player;
final void Function(
String audioId,
String reportId,
String title,
int durationSec,
)?
onStartModuleAudio;
final VoidCallback? onToggleAudio;
final void Function(int delta)? onSeekAudio;
final VoidCallback? onSpeed;
@override
Widget build(BuildContext context, WidgetRef ref) {
final snapshot = ref.watch(recommendedReportsProvider);
return snapshot.when(
loading: () => const LoadingState(),
error: (error, _) => ErrorState(
message: error.toString(),
onRetry: () => ref.invalidate(recommendedReportsProvider),
),
data: (items) {
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
YantingSpacing.screenX,
4,
YantingSpacing.screenX,
16,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const PageHeader(title: '研听', subtitle: '全球机构研报中文解读'),
const SectionTitle(title: '推荐'),
if (items.isEmpty)
const EmptyState(title: '暂无可推荐的研报解读', message: '稍后再来看看最新内容')
else
ReportCardWidget(
report: items.first,
hero: true,
onTap: () => openReportDetail(
context,
dataSource,
items.first,
player: player,
onStartAudio: onStartModuleAudio,
onToggleAudio: onToggleAudio,
onSeekAudio: onSeekAudio,
onSpeed: onSpeed,
),
onPlayTap: () => _playFromReport(onPlay, items.first),
),
const SizedBox(height: YantingSpacing.x6),
for (final item in _directoryItems)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _DirectoryCard(item: item),
),
],
),
);
},
);
}
}
class _DirectoryItem {
const _DirectoryItem({
required this.title,
required this.subtitle,
required this.icon,
required this.path,
});
final String title;
final String subtitle;
final IconData icon;
final String path;
}
class _DirectoryCard extends StatelessWidget {
const _DirectoryCard({required this.item});
final _DirectoryItem item;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return AppCard(
onTap: () => context.push(item.path),
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: theme.colorScheme.secondary,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
item.icon,
size: 20,
color: theme.colorScheme.secondaryForeground,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(item.title, style: YantingText.listTitle),
),
if (item.title == '推荐') ...[
const SizedBox(width: 8),
const AppBadge(text: '首页', kind: BadgeKind.tier),
],
],
),
const SizedBox(height: 2),
Text(item.subtitle, style: YantingText.meta),
],
),
),
Icon(
LucideIcons.chevronRight,
size: 16,
color: theme.colorScheme.mutedForeground,
),
],
),
);
}
}
const _directoryItems = [
_DirectoryItem(
title: '推荐',
subtitle: '主题筛选后的重点研报解读',
icon: AppIcons.sparkle,
path: AppRoutes.home,
),
_DirectoryItem(
title: '研报',
subtitle: '搜索、筛选和浏览全部研报',
icon: AppIcons.article,
path: AppRoutes.reports,
),
_DirectoryItem(
title: '机构',
subtitle: '按机构查看来源与覆盖主题',
icon: AppIcons.bank,
path: AppRoutes.institutions,
),
_DirectoryItem(
title: '听单',
subtitle: '继续收听音频解读',
icon: AppIcons.headphones,
path: AppRoutes.listen,
),
_DirectoryItem(
title: '我的',
subtitle: '登录、收藏与合规说明',
icon: AppIcons.user,
path: AppRoutes.profile,
),
];
void _playFromReport(
void Function(AudioItem item) onPlay,
ReportCardModel report,
) {
onPlay(
AudioItem(
audioId: 'local_${report.id}',
reportId: report.id,
titleCn: report.titleCn,
reportTitleCn: report.titleCn,
durationSec: 180,
institution: report.institution,
),
);
}
@@ -1,101 +1,201 @@
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 'package:shadcn_ui/shadcn_ui.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 '../../data/providers.dart';
import '../../data/state/app_interaction_state.dart';
import '../../routing/app_routes.dart'; import '../../routing/app_routes.dart';
import '../../theme/wise_tokens.dart'; import '../../theme/app_icons.dart';
import '../../theme/yanting_text.dart';
import '../../theme/yanting_tokens.dart';
import '../../widgets/app_buttons.dart'; import '../../widgets/app_buttons.dart';
import '../../widgets/app_card.dart'; import '../../widgets/app_card.dart';
import '../../widgets/badges.dart'; import '../../widgets/badges.dart';
import '../../widgets/sheets.dart'; import '../../widgets/sheets.dart';
import '../../widgets/states.dart'; import '../../widgets/states.dart';
import '../../widgets/institution_card.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);
final theme = ShadTheme.of(context);
return Scaffold(
backgroundColor: theme.colorScheme.background,
appBar: AppBar(
backgroundColor: theme.colorScheme.background,
surfaceTintColor: Colors.transparent,
elevation: 0,
title: const Text('机构主页'),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1),
child: ColoredBox(
color: theme.colorScheme.border,
child: const SizedBox(height: 1, width: double.infinity),
),
),
),
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 ConsumerWidget {
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, WidgetRef ref) {
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.fromLTRB(
YantingSpacing.x4,
4,
YantingSpacing.x4,
16,
),
children: [ children: [
AppCard( AppCard(
color: WiseColors.secondary200, color: YantingColors.brandSoft,
borderColor: YantingColors.brandSoftBorder,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(item.nameCn, style: Theme.of(context).textTheme.headlineSmall), Row(
if (item.nameEn.isNotEmpty) Text(item.nameEn, style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: WiseSpacing.x3),
Wrap(
spacing: WiseSpacing.x2,
runSpacing: WiseSpacing.x2,
children: [ children: [
AppBadge(text: item.sourceTier, icon: Icons.verified_outlined, kind: BadgeKind.tier), InstitutionLogo(
AppBadge(text: '${item.reportCount} 份研报', kind: BadgeKind.brand), logoUrl: item.logoUrl,
initials: item.nameCn.isEmpty
? ''
: item.nameCn.characters.take(2).toString(),
size: 48,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.nameCn,
style: YantingText.sectionTitle.copyWith(
fontSize: 21,
),
),
if (item.nameEn.isNotEmpty)
Text(item.nameEn, style: YantingText.meta),
],
),
),
],
),
const SizedBox(height: 14),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
AppBadge(text: item.sourceTier, 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),
], ],
), ),
], ],
), ),
), ),
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: YantingSpacing.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: YantingText.body)),
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: YantingSpacing.x3),
if (item.credibilityNote.isNotEmpty) if (item.credibilityNote.isNotEmpty)
AppCard( AppCard(
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Icon(Icons.verified_user_outlined, color: WiseColors.positive), const Icon(AppIcons.shield, color: YantingColors.chart2),
const SizedBox(width: WiseSpacing.x2), const SizedBox(width: YantingSpacing.x2),
Expanded(child: Text(item.credibilityNote, style: Theme.of(context).textTheme.bodyMedium)), Expanded(
child: Text(item.credibilityNote, style: YantingText.body),
),
], ],
), ),
), ),
const SizedBox(height: WiseSpacing.x5), const SizedBox(height: YantingSpacing.x6),
Text('最新研报', style: Theme.of(context).textTheme.titleMedium), Text('最新研报', style: YantingText.sectionTitle.copyWith(fontSize: 21)),
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: YantingSpacing.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: () {
ref
.read(profileControllerProvider.notifier)
.addHistory(report.id);
openReportDetail(context, dataSource, report);
},
), ),
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: YantingSpacing.x3),
], ],
AppButton( AppButton(
label: '了解相关服务', label: '了解相关服务',
icon: Icons.open_in_new, icon: AppIcons.externalLink,
kind: AppButtonKind.ghost, kind: AppButtonKind.ghost,
expand: true, expand: true,
onPressed: () => showOutboundSheet(context, title: item.nameCn), onPressed: () => showOutboundSheet(
context,
title: item.nameCn,
onConfirm: () => ref
.read(outboundRepositoryProvider)
.recordOutbound(
OutboundEvent(
scene: 'institution_service',
refId: item.id,
targetUrl: item.websiteUrl.isEmpty ? null : item.websiteUrl,
),
),
),
), ),
], ],
); );
},
),
);
} }
} }
@@ -1,45 +1,55 @@
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/models/models.dart'; import '../../data/content_providers.dart';
import '../../routing/app_routes.dart'; import '../../routing/app_routes.dart';
import '../../theme/wise_tokens.dart'; import '../../theme/yanting_tokens.dart';
import '../../widgets/app_card.dart'; import '../../widgets/institution_card.dart';
import '../../widgets/badges.dart'; import '../../widgets/page_header.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.fromLTRB(
YantingSpacing.screenX,
4,
YantingSpacing.screenX,
16,
),
children: [ children: [
Text('研报来源机构', style: Theme.of(context).textTheme.titleLarge), const PageHeader(title: '机构', subtitle: '可获取研报的机构'),
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: YantingSpacing.cardGap),
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: YantingSpacing.x3),
], ],
], ],
); );
@@ -47,56 +57,3 @@ class _InstitutionsPageState extends State<InstitutionsPage> {
); );
} }
} }
class InstitutionCard extends StatelessWidget {
const InstitutionCard({required this.institution, required this.onTap, super.key});
final Institution institution;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final initials = institution.nameCn.isEmpty ? '' : institution.nameCn.characters.take(2).toString();
return AppCard(
onTap: onTap,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 25,
backgroundColor: WiseColors.secondary200,
foregroundColor: WiseColors.primary,
child: Text(initials, style: const TextStyle(fontWeight: FontWeight.w800)),
),
const SizedBox(width: WiseSpacing.x3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(institution.nameCn, style: Theme.of(context).textTheme.titleMedium),
if (institution.nameEn.isNotEmpty)
Text(institution.nameEn, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: WiseSpacing.x2),
Wrap(
spacing: WiseSpacing.x2,
runSpacing: WiseSpacing.x2,
children: [
if (institution.institutionType.isNotEmpty) AppBadge(text: institution.institutionType),
for (final topic in institution.coveredTopics.take(3)) AppBadge(text: topic, kind: BadgeKind.brand),
],
),
],
),
),
const SizedBox(width: WiseSpacing.x2),
Column(
children: [
Text('${institution.reportCount}', style: Theme.of(context).textTheme.titleMedium?.copyWith(color: WiseColors.primary)),
Text('份研报', style: Theme.of(context).textTheme.bodySmall),
],
),
],
),
);
}
}
+219 -46
View File
@@ -1,70 +1,243 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.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 '../../data/providers.dart';
import '../../theme/app_icons.dart';
import '../../theme/yanting_text.dart';
import '../../theme/yanting_tokens.dart';
import '../../widgets/app_card.dart'; import '../../widgets/app_card.dart';
import '../../widgets/badges.dart';
import '../../widgets/page_header.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); );
}
final current = items.first;
return ListView( return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4), padding: const EdgeInsets.fromLTRB(
YantingSpacing.screenX,
4,
YantingSpacing.screenX,
16,
),
children: [ children: [
Text('全站音频解读', style: Theme.of(context).textTheme.titleLarge), const PageHeader(title: '听单', subtitle: '已转音频的研报解读'),
const SizedBox(height: WiseSpacing.x2), const SectionTitle(title: '继续收听'),
Text('游客可完整收听;真实音频流待后端接入。', style: Theme.of(context).textTheme.bodyMedium), _ContinueListeningCard(
const SizedBox(height: WiseSpacing.x4), item: current,
for (final item in items) ...[ onPlay: () => _playAndRemember(ref, current),
AppCard(
onTap: () => widget.onPlay(item),
child: Row(
children: [
IconButton.filled(
onPressed: () => widget.onPlay(item),
icon: const Icon(Icons.play_arrow),
style: IconButton.styleFrom(backgroundColor: WiseColors.primary, foregroundColor: Colors.white),
), ),
const SizedBox(width: WiseSpacing.x3), const SectionTitle(title: '全部音频', icon: AppIcons.arrowRight),
Expanded( const SizedBox(height: YantingSpacing.cardGap),
child: Column( for (final item in items.skip(1)) ...[
crossAxisAlignment: CrossAxisAlignment.start, _AudioListCard(
children: [ item: item,
Text(item.reportTitleCn, maxLines: 2, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleMedium), onPlay: () => _playAndRemember(ref, item),
Text('${item.institution.nameCn} · ${formatDuration(item.durationSec)}', style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: WiseSpacing.x2),
LinearProgressIndicator(value: 0, minHeight: 4, color: WiseColors.accent, backgroundColor: WiseColors.border),
],
), ),
), const SizedBox(height: YantingSpacing.x3),
],
),
),
const SizedBox(height: WiseSpacing.x3),
], ],
], ],
); );
}, },
); );
} }
void _playAndRemember(WidgetRef ref, AudioItem item) {
ref.read(profileControllerProvider.notifier).addHistory(item.reportId);
onPlay(item);
}
}
class _ContinueListeningCard extends StatelessWidget {
const _ContinueListeningCard({required this.item, required this.onPlay});
final AudioItem item;
final VoidCallback onPlay;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
return AppCard(
onTap: onPlay,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Wrap(
spacing: 8,
runSpacing: 8,
children: [
AppBadge(text: '研报解读', kind: BadgeKind.brand),
AppBadge(text: '音频', kind: BadgeKind.audio),
],
),
const SizedBox(height: 14),
Text(
item.reportTitleCn,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: YantingText.cardTitle,
),
const SizedBox(height: 14),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
children: [
Text(
item.institution.nameCn,
style: YantingText.meta.copyWith(
color: colors.foreground,
fontWeight: FontWeight.w500,
),
),
Text('·', style: YantingText.meta),
Text(
'全长 ${formatDuration(item.durationSec)}',
style: YantingText.meta,
),
],
),
const SizedBox(height: 16),
Row(
children: [
_PlayControlButton(onPressed: onPlay, size: 54, iconSize: 22),
const SizedBox(width: 13),
Expanded(
child: Column(
children: [
const ShadProgress(value: 0.42),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'06:01',
style: YantingText.meta.copyWith(fontSize: 12),
),
Text(
'-08:19',
style: YantingText.meta.copyWith(fontSize: 12),
),
],
),
],
),
),
],
),
],
),
);
}
}
class _AudioListCard extends StatelessWidget {
const _AudioListCard({required this.item, required this.onPlay});
final AudioItem item;
final VoidCallback onPlay;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
return AppCard(
padding: const EdgeInsets.all(14),
onTap: onPlay,
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: colors.secondary,
borderRadius: BorderRadius.circular(YantingRadius.xl),
),
child: Icon(
AppIcons.music,
color: colors.mutedForeground,
size: 24,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.reportTitleCn,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: YantingText.listTitle,
),
const SizedBox(height: 6),
Text(
'${item.institution.nameCn} · ${formatDuration(item.durationSec)}',
style: YantingText.meta,
),
],
),
),
const SizedBox(width: 10),
_PlayControlButton(onPressed: onPlay, size: 44, iconSize: 18),
],
),
);
}
}
class _PlayControlButton extends StatelessWidget {
const _PlayControlButton({
required this.onPressed,
required this.size,
required this.iconSize,
});
final VoidCallback onPressed;
final double size;
final double iconSize;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
return Material(
color: colors.primary,
borderRadius: BorderRadius.circular(YantingRadius.pill),
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(YantingRadius.pill),
child: SizedBox.square(
dimension: size,
child: Icon(
AppIcons.play,
color: colors.primaryForeground,
size: iconSize,
),
),
),
);
}
} }
+524 -50
View File
@@ -1,77 +1,551 @@
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 'package:shadcn_ui/shadcn_ui.dart';
import '../../data/api/report_data_source.dart'; import '../../data/api/report_data_source.dart';
import '../../theme/wise_tokens.dart'; import '../../data/content_providers.dart';
import '../../data/models/models.dart';
import '../../data/providers.dart';
import '../../data/state/app_interaction_state.dart';
import '../../routing/app_routes.dart';
import '../../theme/app_icons.dart';
import '../../theme/yanting_text.dart';
import '../../theme/yanting_tokens.dart';
import '../../widgets/app_buttons.dart'; import '../../widgets/app_buttons.dart';
import '../../widgets/app_card.dart'; import '../../widgets/app_card.dart';
import '../../widgets/page_header.dart';
import '../../widgets/sheets.dart'; import '../../widgets/sheets.dart';
import '../../widgets/states.dart'; import '../../widgets/states.dart';
import '../shared/report_card_widget.dart';
class ProfilePage extends StatelessWidget { class ProfilePage extends ConsumerWidget {
const ProfilePage({required this.dataSource, super.key}); const ProfilePage({required this.dataSource, super.key});
final ReportDataSource dataSource; final ReportDataSource dataSource;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
return ListView( final colors = ShadTheme.of(context).colorScheme;
padding: const EdgeInsets.all(WiseSpacing.x4), final auth = ref.watch(authControllerProvider);
children: [ final profile = ref.watch(profileControllerProvider);
AppCard( final historySnapshot = ref.watch(profileHistoryReportsProvider);
color: WiseColors.secondary200, final favoriteSnapshot = ref.watch(profileFavoriteReportsProvider);
child: Column( final savedListenSnapshot = ref.watch(profileSavedListenReportsProvider);
crossAxisAlignment: CrossAxisAlignment.start, final historyCount = historySnapshot.maybeWhen(
children: [ data: (items) => items.length,
Text('游客', style: Theme.of(context).textTheme.headlineSmall), orElse: () => profile.history.length,
const SizedBox(height: WiseSpacing.x2), );
Text('浏览、阅读和完整收听不需要登录。收藏、历史同步和保存听单等待 auth 接口接入。', style: Theme.of(context).textTheme.bodyMedium), final favoriteCount = favoriteSnapshot.maybeWhen(
const SizedBox(height: WiseSpacing.x4), data: (items) => items.length,
AppButton( orElse: () => profile.favorites.length,
label: '登录后保存个人状态', );
icon: Icons.login, final savedListenCount = savedListenSnapshot.maybeWhen(
onPressed: () => showLoginSheet(context), data: (items) => items.length,
), orElse: () => profile.savedListens.length,
],
),
),
const SizedBox(height: WiseSpacing.x4),
_ProfileRow(icon: Icons.favorite_border, title: '收藏研报', subtitle: '登录后同步收藏', onTap: () => showLoginSheet(context, reason: '登录后保存到你的收藏')),
_ProfileRow(icon: Icons.history, title: '浏览历史', subtitle: '本地历史占位,服务端同步待接入', onTap: () => showAppToast(context, '历史同步接口待接入')),
_ProfileRow(icon: Icons.playlist_add_check, title: '保存听单', subtitle: '登录后保存到你的听单', onTap: () => showLoginSheet(context, reason: '登录后保存到你的听单')),
_ProfileRow(icon: Icons.open_in_new, title: '了解研值相关服务', subtitle: '外跳前提示风险边界', onTap: () => showOutboundSheet(context, title: '研值相关服务')),
],
); );
}
}
class _ProfileRow extends StatelessWidget { return ListView(
const _ProfileRow({required this.icon, required this.title, required this.subtitle, required this.onTap}); padding: const EdgeInsets.fromLTRB(
YantingSpacing.screenX,
final IconData icon; 4,
final String title; YantingSpacing.screenX,
final String subtitle; 16,
final VoidCallback onTap; ),
children: [
@override const PageHeader(title: '我的'),
Widget build(BuildContext context) { if (auth.loggedIn) ...[
return Padding( _LoggedInHeader(auth: auth),
padding: const EdgeInsets.only(bottom: WiseSpacing.x3), const SizedBox(height: YantingSpacing.x3),
child: AppCard( _StatsCard(
onTap: onTap, favoriteCount: favoriteCount,
historyCount: historyCount,
savedListenCount: savedListenCount,
),
] else ...[
AppCard(
color: colors.secondary,
child: Row( child: Row(
children: [ children: [
Icon(icon, color: WiseColors.primary), CircleAvatar(
const SizedBox(width: WiseSpacing.x3), radius: 27,
backgroundColor: colors.background,
foregroundColor: colors.mutedForeground,
child: const Icon(AppIcons.user, size: 28),
),
const SizedBox(width: 15),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(title, style: Theme.of(context).textTheme.titleMedium), Text(
Text(subtitle, style: Theme.of(context).textTheme.bodySmall), '未登录',
style: YantingText.cardTitle.copyWith(
fontSize: 18,
color: colors.foreground,
),
),
const SizedBox(height: 5),
Text(
'登录后同步收藏、历史和听单',
style: YantingText.meta.copyWith(
height: 1.5,
color: colors.mutedForeground,
),
),
], ],
), ),
), ),
const Icon(Icons.chevron_right, color: WiseColors.textTertiary), ],
),
),
const SizedBox(height: YantingSpacing.x3),
AppButton(
label: '登录 / 注册',
expand: true,
onPressed: () => context.push(
'${AppRoutes.login}?next=${Uri.encodeComponent(AppRoutes.profile)}',
),
),
],
const SizedBox(height: 18),
_MenuGroup(
children: [
_MenuRow(
icon: AppIcons.heart,
title: '我的收藏',
trailing: '$favoriteCount',
onTap: () => _showLoginAwareList(
context,
ref,
auth,
title: '我的收藏',
snapshot: favoriteSnapshot,
emptyTitle: '暂无收藏',
emptyMessage: '在研报详情页点击收藏后会出现在这里',
),
),
_MenuRow(
icon: AppIcons.headphones,
title: '保存的听单',
trailing: '$savedListenCount',
onTap: () => _showLoginAwareList(
context,
ref,
auth,
title: '保存的听单',
snapshot: savedListenSnapshot,
emptyTitle: '暂无保存的听单',
emptyMessage: '在音频研报详情页保存听单后会出现在这里',
),
),
_MenuRow(
icon: AppIcons.history,
title: '浏览/收听历史',
trailing: '$historyCount',
onTap: () => _showProfileListSheet(
context,
ref,
title: '浏览/收听历史',
snapshot: historySnapshot,
emptyTitle: '暂无浏览/收听历史',
emptyMessage: '打开研报详情或播放音频后会出现在这里',
),
),
_MenuRow(
icon: Icons.download_outlined,
title: '下载记录',
trailing: 'Phase 1 预留',
onTap: () => showAppToast(context, '下载记录将在后续版本接入'),
),
],
),
const SizedBox(height: YantingSpacing.x3),
_MenuGroup(
children: [
_MenuRow(
icon: AppIcons.settings,
title: '设置',
onTap: () => context.push(AppRoutes.settings),
),
if (auth.loggedIn)
_MenuRow(
icon: Icons.logout,
title: '退出登录',
onTap: () => ref.read(authControllerProvider.notifier).logout(),
),
],
),
const SizedBox(height: YantingSpacing.x3),
AppCard(
color: colors.secondary,
onTap: () => _showOutbound(context, ref, 'profile_services', '相关服务'),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'了解相关服务',
style: YantingText.body.copyWith(
color: colors.foreground,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 3),
const Icon(AppIcons.arrowRight, size: 18),
],
),
const SizedBox(height: 6),
Text(
'与你关注主题相关的延伸服务,内容不构成投资建议。',
style: YantingText.meta.copyWith(
height: 1.5,
color: colors.mutedForeground,
),
),
],
),
),
const SizedBox(height: 22),
Text(
'研听 · 全球机构研报中文解读\n登录不阻断游客完整收听第一期 · 内容不构成投资建议',
textAlign: TextAlign.center,
style: YantingText.meta.copyWith(fontSize: 12, height: 1.7),
),
],
);
}
Future<void> _login(WidgetRef ref, LoginMethod method) async {
await ref.read(authControllerProvider.notifier).login(method);
await ref.read(profileControllerProvider.notifier).refresh();
}
void _showOutbound(
BuildContext context,
WidgetRef ref,
String scene,
String title,
) {
showOutboundSheet(
context,
title: title,
onConfirm: () => ref
.read(outboundRepositoryProvider)
.recordOutbound(OutboundEvent(scene: scene)),
);
}
void _showLoginAwareList(
BuildContext context,
WidgetRef ref,
AuthState auth, {
required String title,
required AsyncValue<List<ReportCardModel>> snapshot,
required String emptyTitle,
required String emptyMessage,
}) {
if (!auth.loggedIn) {
showLoginSheet(
context,
reason: '登录后查看$title',
onPhoneLogin: () => _login(ref, LoginMethod.phone),
onSecondaryLogin: () => _login(ref, LoginMethod.wechat),
);
return;
}
_showProfileListSheet(
context,
ref,
title: title,
snapshot: snapshot,
emptyTitle: emptyTitle,
emptyMessage: emptyMessage,
);
}
void _showProfileListSheet(
BuildContext context,
WidgetRef ref, {
required String title,
required AsyncValue<List<ReportCardModel>> snapshot,
required String emptyTitle,
required String emptyMessage,
}) {
showShadSheet<void>(
context: context,
side: ShadSheetSide.bottom,
builder: (sheetContext) => ShadSheet(
title: Text(title),
description: const Text('本地状态列表,真实同步接口后续接入。'),
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.66,
),
child: snapshot.when(
loading: () => const LoadingState(label: '正在加载列表'),
error: (error, _) => ErrorState(message: error.toString()),
data: (items) {
if (items.isEmpty) {
return EmptyState(
title: emptyTitle,
message: emptyMessage,
icon: Icons.inbox_outlined,
);
}
return ListView.separated(
shrinkWrap: true,
itemCount: items.length,
separatorBuilder: (_, _) =>
const SizedBox(height: YantingSpacing.x3),
itemBuilder: (_, index) {
final report = items[index];
return ReportCardWidget(
report: report,
onTap: () {
Navigator.pop(sheetContext);
ref
.read(profileControllerProvider.notifier)
.addHistory(report.id);
openReportDetail(context, dataSource, report);
},
);
},
);
},
),
),
),
);
}
}
class _LoggedInHeader extends StatelessWidget {
const _LoggedInHeader({required this.auth});
final AuthState auth;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
final phone = auth.phone;
final methodLabel = switch (auth.loginMethod) {
LoginMethod.phone => '手机号',
LoginMethod.wechat => '微信',
LoginMethod.apple => 'Apple',
_ => '本地登录',
};
return AppCard(
color: colors.brandSoft,
borderColor: colors.brandSoftBorder,
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 30),
child: Row(
children: [
CircleAvatar(
radius: 34,
backgroundColor: colors.background,
foregroundColor: colors.primary,
child: Text(
phone == null || phone.isEmpty ? '' : phone.characters.first,
style: YantingText.sectionTitle.copyWith(
color: colors.primary,
fontSize: 27,
),
),
),
const SizedBox(width: 22),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
phone == null || phone.isEmpty ? '研界用户' : '手机号用户',
style: YantingText.sectionTitle.copyWith(
color: colors.foreground,
fontSize: 22,
),
),
const SizedBox(height: 4),
Text(
'已登录 · $methodLabel${phone == null || phone.isEmpty ? '' : ' · ${_maskPhone(phone)}'}',
style: YantingText.body.copyWith(
color: colors.mutedForeground,
fontSize: 15,
),
),
],
),
),
],
),
);
}
}
String _maskPhone(String phone) {
if (phone.length < 7) return phone;
return '${phone.substring(0, 3)}****${phone.substring(phone.length - 4)}';
}
class _StatsCard extends StatelessWidget {
const _StatsCard({
required this.favoriteCount,
required this.historyCount,
required this.savedListenCount,
});
final int favoriteCount;
final int historyCount;
final int savedListenCount;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
return AppCard(
padding: EdgeInsets.zero,
child: Row(
children: [
Expanded(
child: _StatCell(value: favoriteCount, label: '收藏'),
),
SizedBox(
height: 66,
child: VerticalDivider(
width: 1,
thickness: 1,
color: colors.border,
),
),
Expanded(
child: _StatCell(value: historyCount, label: '历史'),
),
SizedBox(
height: 66,
child: VerticalDivider(
width: 1,
thickness: 1,
color: colors.border,
),
),
Expanded(
child: _StatCell(value: savedListenCount, label: '听单'),
),
],
),
);
}
}
class _StatCell extends StatelessWidget {
const _StatCell({required this.value, required this.label});
final int value;
final String label;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 18),
child: Column(
children: [
Text(
'$value',
style: YantingText.sectionTitle.copyWith(
color: const Color(0xFF163E08),
fontSize: 22,
fontFeatures: YantingTypographyFeatures.tabularNums,
),
),
const SizedBox(height: 3),
Text(
label,
style: YantingText.meta.copyWith(
color: colors.mutedForeground,
fontSize: 14,
),
),
],
),
);
}
}
class _MenuGroup extends StatelessWidget {
const _MenuGroup({required this.children});
final List<Widget> children;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
return AppCard(
padding: EdgeInsets.zero,
child: Column(
children: [
for (var index = 0; index < children.length; index++) ...[
children[index],
if (index != children.length - 1)
Divider(height: 1, thickness: 1, color: colors.border),
],
],
),
);
}
}
class _MenuRow extends StatelessWidget {
const _MenuRow({
required this.icon,
required this.title,
required this.onTap,
this.trailing,
});
final IconData icon;
final String title;
final String? trailing;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
child: Row(
children: [
Icon(icon, size: 20, color: const Color(0xFF143B05)),
const SizedBox(width: 13),
Expanded(child: Text(title, style: YantingText.body)),
if (trailing != null)
DecoratedBox(
decoration: BoxDecoration(
color: colors.secondary,
borderRadius: BorderRadius.circular(YantingRadius.pill),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 3,
),
child: Text(
trailing!,
style: YantingText.meta.copyWith(
fontSize: 11.5,
color: colors.secondaryForeground,
),
),
),
)
else
Icon(
AppIcons.arrowRight,
color: colors.mutedForeground,
size: 20,
),
], ],
), ),
), ),
+348 -83
View File
@@ -1,16 +1,22 @@
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 'package:shadcn_ui/shadcn_ui.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 '../../data/providers.dart';
import '../../data/state/report_query.dart';
import '../../routing/app_routes.dart'; import '../../routing/app_routes.dart';
import '../../theme/wise_tokens.dart'; import '../../theme/yanting_text.dart';
import '../../widgets/app_buttons.dart'; import '../../theme/yanting_tokens.dart';
import '../../widgets/badges.dart';
import '../../widgets/mini_player.dart'; import '../../widgets/mini_player.dart';
import '../../widgets/page_header.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,133 +31,392 @@ 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 theme = ShadTheme.of(context);
final searchController = useTextEditingController();
final query = ref.watch(reportFilterProvider);
final snapshot = ref.watch(reportsProvider);
final institutionsSnapshot = ref.watch(institutionsProvider);
final controller = ref.read(reportFilterProvider.notifier);
final institutions = institutionsSnapshot.maybeWhen(
data: (items) => items,
orElse: () => const <Institution>[],
);
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),
),
@override data: (allReports) {
Widget build(BuildContext context) { final items = _applyReportQuery(allReports, query);
return FutureBuilder<List<ReportCardModel>>( return SingleChildScrollView(
future: future, padding: const EdgeInsets.fromLTRB(
builder: (context, snapshot) { YantingSpacing.screenX,
if (snapshot.connectionState != ConnectionState.done) return const LoadingState(label: '正在搜索研报'); 4,
if (snapshot.hasError) return ErrorState(message: snapshot.error.toString(), onRetry: () => setState(() => future = widget.dataSource.reports())); YantingSpacing.screenX,
final items = applyFilters(snapshot.data ?? const []); 16,
return ListView( ),
padding: const EdgeInsets.all(WiseSpacing.x4), child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
TextField( const PageHeader(title: '研报', subtitle: '全部已发布研报解读'),
decoration: InputDecoration( ShadInput(
hintText: '搜索标题、机构或主题', controller: searchController,
prefixIcon: const Icon(Icons.search), placeholder: const Text('搜索标题、机构或主题'),
suffixIcon: query.isEmpty ? null : IconButton(onPressed: () => setState(() => query = ''), icon: const Icon(Icons.close)), leading: const Padding(
filled: true, padding: EdgeInsets.only(right: 8),
fillColor: WiseColors.surface, child: Icon(LucideIcons.search, size: 16),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(WiseRadius.pill), borderSide: BorderSide.none),
), ),
onChanged: (value) => setState(() => query = value.trim()), trailing: query.search.isEmpty
? null
: Padding(
padding: const EdgeInsets.only(left: 8),
child: ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: () {
searchController.clear();
controller.setSearch('');
},
child: const Icon(LucideIcons.x, size: 16),
), ),
const SizedBox(height: WiseSpacing.x3), ),
onChanged: (value) => controller.setSearch(value.trim()),
),
const SizedBox(height: YantingSpacing.cardGap),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
AppButton(label: '筛选', icon: Icons.tune, kind: AppButtonKind.ghost, onPressed: openFilterSheet), Expanded(
const SizedBox(width: WiseSpacing.x2), child: Wrap(
AppChip(label: '有音频', selected: hasAudio, onTap: () => setState(() => hasAudio = !hasAudio)), spacing: YantingSpacing.x2,
runSpacing: YantingSpacing.x2,
children: [
ShadButton.outline(
onPressed: allReports.isEmpty
? null
: () => _openFilterSheet(
context,
items: allReports,
institutions: institutions,
),
leading: const Icon(
LucideIcons.slidersHorizontal,
size: 16,
),
child: Text(query.hasActiveFilter ? '筛选中' : '筛选'),
),
ShadButton.outline(
onPressed: () => controller.setSort(
query.sort == ReportSort.latest
? ReportSort.oldest
: ReportSort.latest,
),
leading: const Icon(
LucideIcons.arrowUpDown,
size: 16,
),
child: Text(
query.sort == ReportSort.latest ? '最新' : '最早',
),
),
ShadBadge.secondary(
onPressed: controller.toggleAudio,
backgroundColor: query.hasAudio
? theme.colorScheme.foreground
: theme.colorScheme.secondary,
foregroundColor: query.hasAudio
? theme.colorScheme.background
: theme.colorScheme.secondaryForeground,
hoverBackgroundColor: query.hasAudio
? theme.colorScheme.foreground.withValues(
alpha: 0.9,
)
: theme.colorScheme.border,
child: const Text('音频'),
),
], ],
), ),
const SizedBox(height: WiseSpacing.x3), ),
Text('${items.length} 篇研报解读${query.isNotEmpty || topic.isNotEmpty || hasAudio ? '(已筛选)' : ''}', style: Theme.of(context).textTheme.bodySmall), const SizedBox(width: YantingSpacing.x2),
const SizedBox(height: WiseSpacing.x3), Padding(
padding: const EdgeInsets.only(top: 10),
child: Text('${items.length}', style: YantingText.meta),
),
],
),
const SizedBox(height: YantingSpacing.cardGap),
if (items.isEmpty) if (items.isEmpty)
EmptyState( EmptyState(
title: query.isNotEmpty ? '未找到相关研报' : '当前筛选下暂无研报', title: query.search.isNotEmpty ? '未找到相关研报' : '当前筛选下暂无研报',
message: query.isNotEmpty ? '换个关键词试试' : '调整筛选条件后再试', message: query.search.isNotEmpty ? '换个关键词试试' : '调整筛选条件后再试',
actionLabel: '清除筛选', actionLabel: '清除筛选',
onAction: () => setState(() { onAction: () {
query = ''; searchController.clear();
topic = ''; controller.reset();
hasAudio = false; },
}),
) )
else else
for (final report in items) ...[ for (final report in items) ...[
ReportCardWidget( ReportCardWidget(
report: report, report: report,
onTap: () => openReportDetail( onTap: () {
ref
.read(profileControllerProvider.notifier)
.addHistory(report.id);
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: YantingSpacing.x3),
], ],
], ],
),
); );
}, },
); );
} }
}
List<ReportCardModel> applyFilters(List<ReportCardModel> items) { List<ReportCardModel> _applyReportQuery(
return items.where((item) { List<ReportCardModel> items,
final hay = '${item.titleCn} ${item.institution.nameCn} ${item.topics.join(' ')}'.toLowerCase(); ReportQuery query,
if (query.isNotEmpty && !hay.contains(query.toLowerCase())) return false; ) {
if (topic.isNotEmpty && !item.topics.contains(topic)) return false; final search = query.search.trim().toLowerCase();
if (hasAudio && !item.hasAudio) return false; final filtered = items.where((item) {
final haystack =
'${item.titleCn} ${item.subtitleCn} ${item.oneLiner} '
'${item.institution.nameCn} ${item.institution.nameEn} '
'${item.topics.join(' ')}'
.toLowerCase();
if (search.isNotEmpty && !haystack.contains(search)) {
return false;
}
if (query.topic != null && !item.topics.contains(query.topic)) {
return false;
}
if (query.institutionId != null &&
item.institution.id != query.institutionId) {
return false;
}
if (query.hasAudio && !item.hasAudio) {
return false;
}
return true; return true;
}).toList(); }).toList();
filtered.sort((a, b) {
final result = (b.releasedAt ?? '').compareTo(a.releasedAt ?? '');
return query.sort == ReportSort.oldest ? -result : result;
});
return filtered;
}
void _openFilterSheet(
BuildContext context, {
required List<ReportCardModel> items,
required List<Institution> institutions,
}) {
const demoTopics = ['宏观', '贵金属', '大宗', '能源', '跨资产', '央行'];
final dynamicTopics = {for (final item in items) ...item.topics};
final topics = [
...demoTopics,
...dynamicTopics.where((topic) => !demoTopics.contains(topic)),
];
final orderedInstitutions = [...institutions]
..sort((a, b) => b.reportCount.compareTo(a.reportCount));
showShadSheet<void>(
context: context,
side: ShadSheetSide.bottom,
builder: (context) {
return Consumer(
builder: (context, ref, _) {
final theme = ShadTheme.of(context);
final query = ref.watch(reportFilterProvider);
final controller = ref.read(reportFilterProvider.notifier);
final selectedBackground = theme.colorScheme.foreground;
final selectedForeground = theme.colorScheme.background;
final unselectedBackground = theme.colorScheme.secondary;
final unselectedForeground = theme.colorScheme.secondaryForeground;
ShadBadge option({
required String label,
required bool selected,
required VoidCallback onPressed,
}) {
return ShadBadge.secondary(
onPressed: onPressed,
backgroundColor: selected
? selectedBackground
: unselectedBackground,
foregroundColor: selected
? selectedForeground
: unselectedForeground,
hoverBackgroundColor: selected
? selectedBackground.withValues(alpha: 0.9)
: theme.colorScheme.border,
child: Text(label),
);
} }
void openFilterSheet() { return ShadSheet(
widget.dataSource.reports().then((items) { title: const Text('筛选研报'),
if (!mounted) return; description: const Text('按主题、机构和音频状态收窄列表。'),
final topics = {for (final item in items) ...item.topics}.toList();
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(WiseRadius.lg))),
builder: (context) => Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 28),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('筛选研报', style: Theme.of(context).textTheme.titleLarge), const _FilterGroupTitle('主题'),
const SizedBox(height: WiseSpacing.x3),
Wrap( Wrap(
spacing: WiseSpacing.x2, spacing: YantingSpacing.x2,
runSpacing: WiseSpacing.x2, runSpacing: YantingSpacing.x2,
children: [ children: [
AppChip(label: '全部主题', selected: topic.isEmpty, onTap: () => selectTopic('')), option(
for (final t in topics) AppChip(label: t, selected: topic == t, onTap: () => selectTopic(t)), label: '全部主题',
selected: query.topic == null,
onPressed: () => controller.setTopic(null),
),
for (final topic in topics)
option(
label: topic,
selected: query.topic == topic,
onPressed: () => controller.setTopic(
query.topic == topic ? null : topic,
),
),
], ],
), ),
const SizedBox(height: WiseSpacing.x4), const SizedBox(height: YantingSpacing.x4),
AppButton(label: '完成', expand: true, onPressed: () => Navigator.pop(context)), const _FilterGroupTitle('机构'),
Wrap(
spacing: YantingSpacing.x2,
runSpacing: YantingSpacing.x2,
children: [
option(
label: '全部机构',
selected: query.institutionId == null,
onPressed: () => controller.setInstitution(null),
),
for (final institution in orderedInstitutions.take(8))
option(
label: institution.nameCn,
selected: query.institutionId == institution.id,
onPressed: () => controller.setInstitution(
query.institutionId == institution.id
? null
: institution.id,
),
),
], ],
), ),
const SizedBox(height: YantingSpacing.x4),
const _FilterGroupTitle('音频'),
Wrap(
spacing: YantingSpacing.x2,
runSpacing: YantingSpacing.x2,
children: [
option(
label: '不限',
selected: !query.hasAudio,
onPressed: () {
if (query.hasAudio) controller.toggleAudio();
},
),
option(
label: '只看音频',
selected: query.hasAudio,
onPressed: () {
if (!query.hasAudio) controller.toggleAudio();
},
),
],
),
const SizedBox(height: YantingSpacing.x4),
const _FilterGroupTitle('排序'),
Wrap(
spacing: YantingSpacing.x2,
runSpacing: YantingSpacing.x2,
children: [
option(
label: '最新发布',
selected: query.sort == ReportSort.latest,
onPressed: () => controller.setSort(ReportSort.latest),
),
option(
label: '最早发布',
selected: query.sort == ReportSort.oldest,
onPressed: () => controller.setSort(ReportSort.oldest),
),
],
),
const SizedBox(height: YantingSpacing.x4),
Row(
children: [
Expanded(
child: ShadButton.outline(
onPressed: controller.reset,
child: const Text('重置'),
),
),
const SizedBox(width: YantingSpacing.x2),
Expanded(
child: ShadButton(
onPressed: () => Navigator.pop(context),
child: const Text('查看结果'),
),
),
],
),
],
), ),
); );
}); },
} );
},
);
}
void selectTopic(String value) { class _FilterGroupTitle extends StatelessWidget {
setState(() => topic = value); const _FilterGroupTitle(this.label);
final String label;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: YantingSpacing.x2),
child: Text(label, style: YantingText.sectionTitle),
);
} }
} }
+370
View File
@@ -0,0 +1,370 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../data/providers.dart';
import '../../data/state/app_interaction_state.dart';
import '../../routing/app_routes.dart';
import '../../theme/app_icons.dart';
import '../../theme/theme_controller.dart';
import '../../theme/yanting_text.dart';
import '../../theme/yanting_tokens.dart';
import '../../widgets/app_card.dart';
import '../../widgets/page_header.dart';
import '../../widgets/sheets.dart';
import '../../widgets/states.dart';
class SettingsPage extends ConsumerWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final themeMode = ref.watch(themeModeProvider);
final scheme = ShadTheme.of(context).colorScheme;
final auth = ref.watch(authControllerProvider);
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(AppIcons.arrowLeft),
onPressed: () {
if (context.canPop()) {
context.pop();
} else {
context.go(AppRoutes.home);
}
},
),
title: const Text('设置 · Settings'),
),
body: ListView(
padding: const EdgeInsets.fromLTRB(
YantingSpacing.screenX,
4,
YantingSpacing.screenX,
20,
),
children: [
const PageHeader(title: '设置', subtitle: '统一外观、主题和常用入口'),
Text('外观', style: YantingText.sectionTitle),
const SizedBox(height: 12),
AppCard(
padding: EdgeInsets.zero,
child: Column(
children: [
_ThemeModeTile(
label: '跟随系统',
description: '按系统深浅色自动切换',
selected: themeMode == ThemeMode.system,
onTap: () => ref
.read(themeModeProvider.notifier)
.setMode(ThemeMode.system),
),
const Divider(height: 1, thickness: 1),
_ThemeModeTile(
label: '浅色',
description: '稳定的浅色展示模式',
selected: themeMode == ThemeMode.light,
onTap: () => ref
.read(themeModeProvider.notifier)
.setMode(ThemeMode.light),
),
const Divider(height: 1, thickness: 1),
_ThemeModeTile(
label: '深色',
description: '适合低光环境阅读',
selected: themeMode == ThemeMode.dark,
onTap: () => ref
.read(themeModeProvider.notifier)
.setMode(ThemeMode.dark),
),
],
),
),
const SizedBox(height: YantingSpacing.x3),
Text('入口', style: YantingText.sectionTitle),
const SizedBox(height: 12),
AppCard(
padding: EdgeInsets.zero,
child: Column(
children: [
_LinkTile(
icon: Icons.description_outlined,
title: '用户协议',
onTap: () => _showOutbound(
context,
ref,
'settings_user_agreement',
'用户协议',
),
),
const Divider(height: 1, thickness: 1),
_LinkTile(
icon: Icons.privacy_tip_outlined,
title: '隐私政策',
onTap: () => _showOutbound(
context,
ref,
'settings_privacy_policy',
'隐私政策',
),
),
],
),
),
const SizedBox(height: YantingSpacing.x3),
Text('关于', style: YantingText.sectionTitle),
const SizedBox(height: 12),
AppCard(
color: scheme.secondary,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('研听', style: YantingText.cardTitle),
const SizedBox(height: 6),
Text(
'全球机构研报中文解读',
style: YantingText.meta.copyWith(fontSize: 12.5),
),
const SizedBox(height: 12),
Text(
'主题、按钮、卡片和间距统一到 demo 的展示层基线。',
style: YantingText.body.copyWith(fontSize: 14),
),
const SizedBox(height: 14),
Text(
'当前版本以本地构建信息为准,发布时再注入正式版本号。',
style: YantingText.meta.copyWith(fontSize: 12),
),
],
),
),
if (auth.loggedIn) ...[
const SizedBox(height: YantingSpacing.x3),
Text('账户', style: YantingText.sectionTitle),
const SizedBox(height: 12),
AppCard(
padding: EdgeInsets.zero,
child: _ActionTile(
icon: Icons.logout,
title: '退出登录',
subtitle: '退出后本地登录态会清空',
destructive: true,
onTap: () => _confirmLogout(context, ref),
),
),
],
],
),
);
}
void _showOutbound(
BuildContext context,
WidgetRef ref,
String scene,
String title,
) {
showOutboundSheet(
context,
title: title,
onConfirm: () => ref
.read(outboundRepositoryProvider)
.recordOutbound(OutboundEvent(scene: scene)),
);
}
Future<void> _confirmLogout(BuildContext context, WidgetRef ref) async {
final ok = await showShadDialog<bool>(
context: context,
variant: ShadDialogVariant.alert,
builder: (dialogContext) => ShadDialog.alert(
title: const Text('退出登录'),
description: const Text('退出后,本设备的登录态会清空,再次登录可继续使用。'),
actions: [
ShadButton.outline(
child: const Text('取消'),
onPressed: () => Navigator.of(dialogContext).pop(false),
),
ShadButton(
child: const Text('确定退出'),
onPressed: () => Navigator.of(dialogContext).pop(true),
),
],
),
);
if (ok != true) return;
await ref.read(authControllerProvider.notifier).logout();
await ref.read(profileControllerProvider.notifier).refresh();
if (!context.mounted) return;
showAppToast(context, '已退出登录');
context.go(AppRoutes.profile);
}
}
class _ThemeModeTile extends StatelessWidget {
const _ThemeModeTile({
required this.label,
required this.description,
required this.selected,
required this.onTap,
});
final String label;
final String description;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final scheme = ShadTheme.of(context).colorScheme;
final foreground = selected ? scheme.background : scheme.foreground;
final muted = scheme.mutedForeground;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: YantingText.body.copyWith(
color: scheme.foreground,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
description,
style: YantingText.meta.copyWith(
color: muted,
fontSize: 12.5,
),
),
],
),
),
const SizedBox(width: 12),
DecoratedBox(
decoration: BoxDecoration(
color: selected ? scheme.foreground : scheme.secondary,
borderRadius: BorderRadius.circular(YantingRadius.pill),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
child: Icon(
selected ? Icons.check : Icons.radio_button_unchecked,
size: 16,
color: foreground,
),
),
),
],
),
),
);
}
}
class _LinkTile extends StatelessWidget {
const _LinkTile({
required this.icon,
required this.title,
required this.onTap,
});
final IconData icon;
final String title;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
child: Row(
children: [
Icon(
icon,
size: 20,
color: ShadTheme.of(context).colorScheme.foreground,
),
const SizedBox(width: 13),
Expanded(
child: Text(
title,
style: YantingText.body.copyWith(
color: ShadTheme.of(context).colorScheme.foreground,
),
),
),
const Icon(AppIcons.arrowRight, size: 18),
],
),
),
);
}
}
class _ActionTile extends StatelessWidget {
const _ActionTile({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
this.destructive = false,
});
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
final bool destructive;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
final titleColor = destructive ? colors.destructive : colors.foreground;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
child: Row(
children: [
Icon(icon, size: 20, color: titleColor),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: YantingText.body.copyWith(
color: titleColor,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
subtitle,
style: YantingText.meta.copyWith(
color: colors.mutedForeground,
fontSize: 12.5,
),
),
],
),
),
const Icon(AppIcons.arrowRight, size: 18),
],
),
),
);
}
}
+71 -22
View File
@@ -1,7 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../data/models/models.dart'; import '../../data/models/models.dart';
import '../../theme/app_icons.dart';
import '../../theme/yanting_text.dart';
import '../../theme/wise_tokens.dart'; import '../../theme/wise_tokens.dart';
import '../../widgets/app_buttons.dart';
import '../../widgets/app_card.dart'; import '../../widgets/app_card.dart';
import '../../widgets/badges.dart'; import '../../widgets/badges.dart';
@@ -23,65 +27,110 @@ class ReportCardWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
final child = Column( final child = Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Wrap( Wrap(
spacing: WiseSpacing.x2, spacing: hero ? WiseSpacing.x2 : 7,
runSpacing: WiseSpacing.x2, runSpacing: hero ? WiseSpacing.x2 : 7,
children: [ children: [
AppBadge(text: report.interpretationLabel, kind: BadgeKind.brand), AppBadge(text: report.interpretationLabel, kind: BadgeKind.brand),
if (report.hasAudio) if (report.hasAudio)
const AppBadge(text: '音频', icon: Icons.graphic_eq, kind: BadgeKind.audio), const AppBadge(
text: '音频',
icon: AppIcons.play,
kind: BadgeKind.audio,
),
if (report.sourceTier.isNotEmpty) if (report.sourceTier.isNotEmpty)
AppBadge(text: report.sourceTier, icon: Icons.verified_outlined, kind: BadgeKind.tier), AppBadge(text: report.sourceTier, kind: BadgeKind.tier),
for (final topic in report.topics.take(3)) AppBadge(text: topic), for (final topic in report.topics.take(3)) AppBadge(text: topic),
], ],
), ),
const SizedBox(height: WiseSpacing.x3), SizedBox(height: hero ? WiseSpacing.x3 : 10),
Text( Text(
report.titleCn, report.titleCn,
maxLines: hero ? 3 : 2, maxLines: hero ? 3 : 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: hero style: hero
? Theme.of(context).textTheme.titleLarge ? YantingText.sectionTitle.copyWith(fontSize: 21, height: 1.4)
: Theme.of(context).textTheme.titleMedium, : YantingText.listTitle.copyWith(
fontSize: 17.5,
height: 1.38,
fontWeight: FontWeight.w700,
),
), ),
if (report.oneLiner.isNotEmpty) ...[ if (report.oneLiner.isNotEmpty) ...[
const SizedBox(height: WiseSpacing.x2), SizedBox(height: hero ? WiseSpacing.x2 : 7),
Text( Text(
report.oneLiner, report.oneLiner,
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium, style: YantingText.body.copyWith(
color: colors.mutedForeground,
fontSize: hero ? null : 14,
),
), ),
], ],
const SizedBox(height: WiseSpacing.x3), SizedBox(height: hero ? WiseSpacing.x3 : 10),
Row( Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
runSpacing: 8,
children: [ children: [
Expanded( InkWell(
child: InkWell(
onTap: onInstitutionTap, onTap: onInstitutionTap,
child: Text( child: Text(
'${report.institution.nameCn}${report.releasedAt == null ? '' : ' · ${formatDate(report.releasedAt)}'}', report.institution.nameCn,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall, style: YantingText.meta.copyWith(
color: colors.foreground,
fontWeight: FontWeight.w500,
), ),
), ),
), ),
if (report.hasAudio) if (report.releasedAt != null) ...[
TextButton.icon( const _MetaDot(),
onPressed: onPlayTap, Text(formatDate(report.releasedAt), style: YantingText.meta),
icon: const Icon(Icons.play_circle_outline, size: 18), ],
label: const Text('听研报'),
),
], ],
), ),
if (report.hasAudio) ...[
const SizedBox(height: 14),
AppButton(
label: '听研报',
icon: AppIcons.play,
kind: hero ? AppButtonKind.primary : AppButtonKind.accent,
compact: !hero,
onPressed: onPlayTap,
),
],
], ],
); );
return hero return hero
? HeroReportCard(onTap: onTap, child: child) ? HeroReportCard(onTap: onTap, child: child)
: AppCard(onTap: onTap, child: child); : AppCard(
onTap: onTap,
padding: const EdgeInsets.all(16),
child: child,
);
}
}
class _MetaDot extends StatelessWidget {
const _MetaDot();
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
return Container(
width: 3,
height: 3,
decoration: BoxDecoration(
color: colors.mutedForeground,
shape: BoxShape.circle,
),
);
} }
} }
+124 -120
View File
@@ -1,152 +1,156 @@
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 'package:shadcn_ui/shadcn_ui.dart';
import '../data/api/report_data_source.dart'; import '../data/providers.dart';
import '../data/models/models.dart'; import '../routing/app_routes.dart';
import '../theme/wise_tokens.dart'; import '../theme/yanting_text.dart';
import '../widgets/bottom_tab_bar.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 ConsumerStatefulWidget {
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(); ConsumerState<ShellPage> createState() => _ShellPageState();
} }
class _ShellPageState extends State<ShellPage> { class _ShellPageState extends ConsumerState<ShellPage> {
int index = 0; static const double _compactHeaderThreshold = 34;
PlayerStateModel player = const PlayerStateModel();
Timer? timer; bool _showCompactHeader = false;
@override @override
void dispose() { void didUpdateWidget(covariant ShellPage oldWidget) {
timer?.cancel(); super.didUpdateWidget(oldWidget);
super.dispose(); if (oldWidget.currentPath != widget.currentPath && _showCompactHeader) {
_showCompactHeader = false;
} }
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final pages = [ final theme = ShadTheme.of(context);
FeedPage( final player = ref.watch(audioPlayerControllerProvider);
dataSource: widget.dataSource, final controller = ref.read(audioPlayerControllerProvider.notifier);
onPlay: startAudio, final canPop = GoRouter.of(context).canPop();
player: player, final selectedIndex = _tabs.indexWhere(
onStartModuleAudio: startModuleAudio, (tab) => tab.path == widget.currentPath,
onToggleAudio: toggleAudio, );
onSeekAudio: seekAudio, final safeIndex = selectedIndex < 0 ? 0 : selectedIndex;
onSpeed: cycleSpeed, final header = _headerForPath(widget.currentPath);
),
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(
backgroundColor: theme.colorScheme.background,
appBar: AppBar( appBar: AppBar(
title: const Column( backgroundColor: theme.colorScheme.background,
crossAxisAlignment: CrossAxisAlignment.start, surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: true,
leading: canPop
? ShadIconButton.ghost(
onPressed: () => context.pop(),
icon: const Icon(LucideIcons.chevronLeft, size: 18),
)
: null,
title: AnimatedOpacity(
opacity: _showCompactHeader ? 1 : 0,
duration: const Duration(milliseconds: 160),
curve: Curves.easeOut,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Text('研听'),
Text( Text(
'全球机构研报中文解读', header.title,
style: TextStyle(fontSize: 12, color: WiseColors.textSecondary, fontWeight: FontWeight.w500), textAlign: TextAlign.center,
style: YantingText.listTitle.copyWith(
color: theme.colorScheme.foreground,
),
),
if (header.subtitle.isNotEmpty)
Text(
header.subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: YantingText.meta.copyWith(
color: theme.colorScheme.mutedForeground,
fontSize: 12,
),
), ),
], ],
), ),
), ),
body: pages[index], ),
bottomNavigationBar: Column( body: ColoredBox(
color: theme.colorScheme.background,
child: NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: Stack(children: [Positioned.fill(child: widget.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( BottomTabBar(
selectedIndex: index, items: yantingBottomTabItems,
onDestinationSelected: (value) => setState(() => index = value), selectedIndex: safeIndex,
destinations: const [ onSelected: (index) => context.go(_tabs[index].path),
NavigationDestination(icon: Icon(Icons.auto_awesome_outlined), selectedIcon: Icon(Icons.auto_awesome), label: '推荐'),
NavigationDestination(icon: Icon(Icons.article_outlined), selectedIcon: Icon(Icons.article), label: '研报'),
NavigationDestination(icon: Icon(Icons.account_balance_outlined), selectedIcon: Icon(Icons.account_balance), label: '机构'),
NavigationDestination(icon: Icon(Icons.headphones_outlined), selectedIcon: Icon(Icons.headphones), label: '听单'),
NavigationDestination(icon: Icon(Icons.person_outline), selectedIcon: Icon(Icons.person), label: '我的'),
],
), ),
], ],
), ),
),
); );
} }
bool _handleScrollNotification(ScrollNotification notification) {
if (notification.metrics.axis != Axis.vertical) {
return false;
}
final next = notification.metrics.pixels > _compactHeaderThreshold;
if (next != _showCompactHeader && mounted) {
setState(() => _showCompactHeader = next);
}
return false;
}
} }
_ShellHeader _headerForPath(String path) {
return switch (path) {
AppRoutes.reports => const _ShellHeader('研报', '全部已发布研报解读'),
AppRoutes.institutions => const _ShellHeader('机构', '可获取研报的机构'),
AppRoutes.listen => const _ShellHeader('听单', '已转音频的研报解读'),
AppRoutes.profile => const _ShellHeader('我的', ''),
_ => const _ShellHeader('研听', '全球机构研报中文解读'),
};
}
class _ShellHeader {
const _ShellHeader(this.title, this.subtitle);
final String title;
final String subtitle;
}
class _TabItem {
const _TabItem({required this.path});
final String path;
}
const List<_TabItem> _tabs = [
_TabItem(path: AppRoutes.home),
_TabItem(path: AppRoutes.reports),
_TabItem(path: AppRoutes.institutions),
_TabItem(path: AppRoutes.listen),
_TabItem(path: AppRoutes.profile),
];
+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));
} }
+226
View File
@@ -0,0 +1,226 @@
import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../data/providers.dart';
import '../features/detail/report_detail_page.dart';
import '../features/feed/feed_page.dart';
import '../features/home/home_page.dart';
import '../features/auth/login_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/settings/settings_page.dart';
import '../features/shell_page.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.homeFeed,
builder: (context, state) => _TabSurface(
child: Consumer(
builder: (context, ref, _) {
final player = ref.watch(audioPlayerControllerProvider);
final controller = ref.read(
audioPlayerControllerProvider.notifier,
);
return HomePage(
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,
pageBuilder: (context, state) {
final id = state.pathParameters['id'] ?? '';
final args = state.extra as ReportDetailRouteArgs?;
return _slidePage(
state: state,
child: 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.login,
pageBuilder: (context, state) {
final next = state.uri.queryParameters['next'];
return _slidePage(
state: state,
child: LoginPage(next: next),
);
},
),
GoRoute(
path: AppRoutes.institutionDetail,
pageBuilder: (context, state) {
final id = state.pathParameters['id'] ?? '';
final args = state.extra as InstitutionDetailRouteArgs?;
return _slidePage(
state: state,
child: InstitutionDetailPage(
institutionId: id,
dataSource: args?.dataSource ?? dataSource,
),
);
},
),
GoRoute(
path: AppRoutes.settings,
pageBuilder: (context, state) =>
_slidePage(state: state, child: const SettingsPage()),
),
],
);
});
CustomTransitionPage<void> _slidePage({
required GoRouterState state,
required Widget child,
}) {
return CustomTransitionPage<void>(
key: state.pageKey,
transitionDuration: const Duration(milliseconds: 260),
reverseTransitionDuration: const Duration(milliseconds: 220),
child: child,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final curved = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
return FadeTransition(
opacity: curved,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.08, 0),
end: Offset.zero,
).animate(curved),
child: child,
),
);
},
);
}
class _TabSurface extends StatelessWidget {
const _TabSurface({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return ColoredBox(
color: ShadTheme.of(context).colorScheme.background,
child: child,
);
}
}
+55 -15
View File
@@ -1,25 +1,70 @@
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 homeFeed = '/feed';
static const reports = '/reports';
static const institutions = '/institutions';
static const listen = '/listen';
static const profile = '/profile';
static const login = '/login';
static const settings = '/settings';
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 +72,6 @@ void openReportDetail(
onSeekAudio: onSeekAudio, onSeekAudio: onSeekAudio,
onSpeed: onSpeed, onSpeed: onSpeed,
), ),
),
); );
} }
@@ -36,12 +80,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,
),
),
); );
} }
+45
View File
@@ -0,0 +1,45 @@
import 'package:flutter/widgets.dart';
import 'package:remixicon/remixicon.dart';
abstract final class AppIcons {
static const IconData sparkle = Remix.star_line;
static const IconData sparkleFill = Remix.star_fill;
static const IconData article = Remix.article_line;
static const IconData articleFill = Remix.article_fill;
static const IconData bank = Remix.bank_line;
static const IconData bankFill = Remix.bank_fill;
static const IconData headphones = Remix.headphone_line;
static const IconData headphonesFill = Remix.headphone_fill;
static const IconData user = Remix.user_3_line;
static const IconData userFill = Remix.user_3_fill;
static const IconData search = Remix.search_line;
static const IconData filter = Remix.equalizer_line;
static const IconData sort = Remix.arrow_down_line;
static const IconData arrowRight = Remix.arrow_right_s_line;
static const IconData arrowLeft = Remix.arrow_left_s_line;
static const IconData play = Remix.play_fill;
static const IconData pause = Remix.pause_fill;
static const IconData playCircle = Remix.play_circle_fill;
static const IconData heart = Remix.heart_3_line;
static const IconData heartFill = Remix.heart_3_fill;
static const IconData externalLink = Remix.external_link_line;
static const IconData warning = Remix.error_warning_line;
static const IconData music = Remix.music_2_line;
static const IconData disc = Remix.disc_line;
static const IconData history = Remix.history_line;
static const IconData settings = Remix.settings_3_line;
static const IconData fileList = Remix.file_list_3_line;
static const IconData shield = Remix.shield_check_line;
static IconData tabIcon(int index, {required bool selected}) {
return switch (index) {
0 => selected ? sparkleFill : sparkle,
1 => selected ? articleFill : article,
2 => selected ? bankFill : bank,
3 => selected ? headphonesFill : headphones,
4 => selected ? userFill : user,
_ => selected ? sparkleFill : sparkle,
};
}
}
+112 -66
View File
@@ -1,98 +1,144 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'wise_tokens.dart'; import 'yanting_text.dart';
import 'yanting_tokens.dart';
ThemeData buildAppTheme() { ThemeData buildAppTheme(Brightness brightness) {
final primary = brightness == Brightness.dark
? YantingDarkColors.primary
: YantingColors.primary;
final primaryForeground = brightness == Brightness.dark
? YantingDarkColors.primaryForeground
: YantingColors.primaryForeground;
final secondary = brightness == Brightness.dark
? YantingDarkColors.secondary
: YantingColors.secondary;
final secondaryForeground = brightness == Brightness.dark
? YantingDarkColors.secondaryForeground
: YantingColors.secondaryForeground;
final link = brightness == Brightness.dark
? YantingDarkColors.link
: YantingColors.link;
final card = brightness == Brightness.dark
? YantingDarkColors.card
: YantingColors.card;
final foreground = brightness == Brightness.dark
? YantingDarkColors.foreground
: YantingColors.foreground;
final destructive = brightness == Brightness.dark
? YantingDarkColors.destructive
: YantingColors.destructive;
final border = brightness == Brightness.dark
? YantingDarkColors.border
: YantingColors.border;
final background = brightness == Brightness.dark
? YantingDarkColors.background
: YantingColors.background;
final mutedForeground = brightness == Brightness.dark
? YantingDarkColors.mutedForeground
: YantingColors.mutedForeground;
final input = brightness == Brightness.dark
? YantingDarkColors.input
: YantingColors.input;
final scheme = ColorScheme.fromSeed( final scheme = ColorScheme.fromSeed(
seedColor: WiseColors.primary, seedColor: primary,
primary: WiseColors.primary, brightness: brightness,
secondary: WiseColors.secondary, primary: primary,
tertiary: WiseColors.accent, onPrimary: primaryForeground,
surface: WiseColors.surface, secondary: secondary,
onSecondary: secondaryForeground,
tertiary: link,
surface: card,
onSurface: foreground,
error: destructive,
outline: border,
); );
return ThemeData( return ThemeData(
useMaterial3: true, useMaterial3: true,
colorScheme: scheme, colorScheme: scheme,
fontFamily: 'Inter', fontFamily: YantingText.fontFamily,
scaffoldBackgroundColor: WiseColors.canvas, fontFamilyFallback: YantingText.fontFallback,
appBarTheme: const AppBarTheme( brightness: brightness,
backgroundColor: WiseColors.canvas, scaffoldBackgroundColor: background,
foregroundColor: WiseColors.primary, appBarTheme: AppBarTheme(
backgroundColor: background,
foregroundColor: foreground,
elevation: 0, elevation: 0,
centerTitle: false, centerTitle: false,
titleTextStyle: TextStyle( titleTextStyle: YantingText.sectionTitle,
color: WiseColors.primary, surfaceTintColor: Colors.transparent,
fontSize: 22,
fontWeight: FontWeight.w800,
), ),
), cardTheme: CardThemeData(
cardTheme: const CardThemeData( color: card,
color: WiseColors.surface,
elevation: 0, elevation: 0,
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(WiseRadius.md)), borderRadius: const BorderRadius.all(Radius.circular(YantingRadius.xl)),
side: BorderSide(color: border),
),
),
dividerTheme: DividerThemeData(
color: border,
thickness: 1,
space: 1,
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: background,
hintStyle: YantingText.body.copyWith(color: mutedForeground),
contentPadding: const EdgeInsets.symmetric(horizontal: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(YantingRadius.md),
borderSide: BorderSide(color: input),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(YantingRadius.md),
borderSide: BorderSide(color: input),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(YantingRadius.md),
borderSide: BorderSide(color: foreground),
),
),
snackBarTheme: SnackBarThemeData(
backgroundColor: foreground,
contentTextStyle: YantingText.body.copyWith(color: background),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(YantingRadius.md),
), ),
), ),
navigationBarTheme: NavigationBarThemeData( navigationBarTheme: NavigationBarThemeData(
backgroundColor: WiseColors.surface, backgroundColor: background,
indicatorColor: WiseColors.secondary200, indicatorColor: Colors.transparent,
labelTextStyle: WidgetStateProperty.resolveWith( labelTextStyle: WidgetStateProperty.resolveWith(
(states) => TextStyle( (states) => YantingText.meta.copyWith(
color: states.contains(WidgetState.selected) color: states.contains(WidgetState.selected)
? WiseColors.primary ? foreground
: WiseColors.textTertiary, : mutedForeground,
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w700, fontWeight: states.contains(WidgetState.selected)
? FontWeight.w600
: FontWeight.w400,
), ),
), ),
iconTheme: WidgetStateProperty.resolveWith( iconTheme: WidgetStateProperty.resolveWith(
(states) => IconThemeData( (states) => IconThemeData(
color: states.contains(WidgetState.selected) color: states.contains(WidgetState.selected)
? WiseColors.primary ? foreground
: WiseColors.textTertiary, : mutedForeground,
), ),
), ),
), ),
textTheme: const TextTheme( textTheme: const TextTheme(
headlineSmall: TextStyle( headlineSmall: YantingText.appTitle,
color: WiseColors.ink, titleLarge: YantingText.sectionTitle,
fontSize: 26, titleMedium: YantingText.cardTitle,
height: 1.18, bodyLarge: YantingText.body,
fontWeight: FontWeight.w800, bodyMedium: YantingText.sub,
), bodySmall: YantingText.meta,
titleLarge: TextStyle( labelLarge: YantingText.chip,
color: WiseColors.ink, labelSmall: YantingText.badge,
fontSize: 21,
height: 1.22,
fontWeight: FontWeight.w800,
),
titleMedium: TextStyle(
color: WiseColors.ink,
fontSize: 17,
height: 1.25,
fontWeight: FontWeight.w800,
),
bodyLarge: TextStyle(
color: WiseColors.ink,
fontSize: 16,
height: 1.55,
),
bodyMedium: TextStyle(
color: WiseColors.ink700,
fontSize: 14,
height: 1.5,
),
bodySmall: TextStyle(
color: WiseColors.textSecondary,
fontSize: 12,
height: 1.45,
),
labelSmall: TextStyle(
color: WiseColors.textSecondary,
fontSize: 11,
fontWeight: FontWeight.w700,
),
), ),
); );
} }
+6
View File
@@ -0,0 +1,6 @@
export 'app_theme.dart';
export 'yanting_shad_theme.dart';
export 'theme_controller.dart';
export 'yanting_text.dart';
export 'yanting_tokens.dart';
export 'wise_tokens.dart';
+42
View File
@@ -0,0 +1,42 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _themeModeKey = 'theme_mode';
final themeModeProvider = StateNotifierProvider<ThemeModeController, ThemeMode>(
(ref) => ThemeModeController(),
);
class ThemeModeController extends StateNotifier<ThemeMode> {
ThemeModeController() : super(ThemeMode.system) {
unawaited(_loadSavedMode());
}
Future<void> _loadSavedMode() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_themeModeKey);
if (raw == null) return;
state = _decode(raw);
}
Future<void> setMode(ThemeMode mode) async {
state = mode;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_themeModeKey, _encode(mode));
}
ThemeMode _decode(String raw) => switch (raw) {
'light' => ThemeMode.light,
'dark' => ThemeMode.dark,
_ => ThemeMode.system,
};
String _encode(ThemeMode mode) => switch (mode) {
ThemeMode.light => 'light',
ThemeMode.dark => 'dark',
ThemeMode.system => 'system',
};
}
+31 -43
View File
@@ -1,39 +1,41 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'yanting_tokens.dart';
final class WiseColors { final class WiseColors {
static const primary = Color(0xFF163300); static const primary = YantingColors.foreground;
static const primarySoft = Color(0xFF1F4708); static const primarySoft = YantingColors.primaryForeground;
static const secondary = Color(0xFF9FE870); static const secondary = YantingColors.primary;
static const secondary200 = Color(0xFFE2F6D5); static const secondary200 = YantingColors.brandSoft;
static const accent = Color(0xFF00A2DD); static const accent = YantingColors.link;
static const canvas = Color(0xFFF4F6F3); static const canvas = YantingColors.background;
static const ink = Color(0xFF0E0F0C); static const ink = YantingColors.foreground;
static const ink700 = Color(0xFF454745); static const ink700 = YantingColors.secondaryForeground;
static const textSecondary = Color(0xFF5D7079); static const textSecondary = YantingColors.mutedForeground;
static const textTertiary = Color(0xFF768E9C); static const textTertiary = YantingColors.mutedForeground;
static const surface = Colors.white; static const surface = YantingColors.card;
static const border = Color(0x1A000000); static const border = YantingColors.border;
static const positive = Color(0xFF008026); static const positive = YantingColors.chart2;
static const warning = Color(0xFF9A6500); static const warning = Color(0xFF9A6A00);
static const negative = Color(0xFFCF2929); static const negative = YantingColors.destructive;
} }
final class WiseSpacing { final class WiseSpacing {
static const x1 = 4.0; static const x1 = YantingSpacing.x1;
static const x2 = 8.0; static const x2 = YantingSpacing.x2;
static const x3 = 12.0; static const x3 = YantingSpacing.cardGap;
static const x4 = 16.0; static const x4 = YantingSpacing.screenX;
static const x5 = 20.0; static const x5 = YantingSpacing.screenX;
static const x6 = 24.0; static const x6 = YantingSpacing.x6;
static const x8 = 32.0; static const x8 = YantingSpacing.x8;
static const x10 = 40.0; static const x10 = YantingSpacing.x10;
} }
final class WiseRadius { final class WiseRadius {
static const sm = 10.0; static const sm = YantingRadius.sm;
static const md = 16.0; static const md = YantingRadius.xl;
static const lg = 24.0; static const lg = 24.0;
static const pill = 999.0; static const pill = YantingRadius.pill;
} }
final class WiseMotion { final class WiseMotion {
@@ -43,26 +45,12 @@ final class WiseMotion {
} }
final class WiseShadows { final class WiseShadows {
static const card = [ static const card = <BoxShadow>[];
BoxShadow( static const elevated = <BoxShadow>[];
color: Color(0x14000000),
blurRadius: 20,
offset: Offset(0, 6),
),
];
static const elevated = [
BoxShadow(
color: Color(0x24000000),
blurRadius: 32,
offset: Offset(0, 10),
),
];
} }
const wiseFontStack = [ const wiseFontStack = [
'Inter', 'DM Sans',
'-apple-system',
'BlinkMacSystemFont',
'PingFang SC', 'PingFang SC',
'Microsoft YaHei', 'Microsoft YaHei',
'Helvetica Neue', 'Helvetica Neue',
+111
View File
@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'yanting_text.dart';
import 'yanting_tokens.dart';
ShadThemeData buildYantingShadTheme() =>
_buildShadTheme(brightness: Brightness.light);
ShadThemeData buildYantingDarkShadTheme() =>
_buildShadTheme(brightness: Brightness.dark);
ShadThemeData _buildShadTheme({required Brightness brightness}) {
final colors = brightness == Brightness.dark
? ShadColorScheme(
background: YantingDarkColors.background,
foreground: YantingDarkColors.foreground,
card: YantingDarkColors.card,
cardForeground: YantingDarkColors.foreground,
popover: YantingDarkColors.card,
popoverForeground: YantingDarkColors.foreground,
primary: YantingDarkColors.primary,
primaryForeground: YantingDarkColors.primaryForeground,
secondary: YantingDarkColors.secondary,
secondaryForeground: YantingDarkColors.secondaryForeground,
muted: YantingDarkColors.muted,
mutedForeground: YantingDarkColors.mutedForeground,
accent: YantingDarkColors.brandSoft,
accentForeground: YantingDarkColors.primaryForeground,
destructive: YantingDarkColors.destructive,
destructiveForeground: YantingDarkColors.background,
border: YantingDarkColors.border,
input: YantingDarkColors.input,
ring: YantingDarkColors.primary,
selection: YantingDarkColors.foreground,
custom: {
'brandSoft': YantingDarkColors.brandSoft,
'brandSoftBorder': YantingDarkColors.brandSoftBorder,
'link': YantingDarkColors.link,
'chart2': YantingDarkColors.chart2,
'warning': YantingDarkColors.warning,
'warningSoft': YantingDarkColors.warningSoft,
'warningSoftBorder': YantingDarkColors.warningSoftBorder,
'warningSoftForeground': YantingDarkColors.warningSoftForeground,
},
)
: ShadColorScheme(
background: YantingColors.background,
foreground: YantingColors.foreground,
card: YantingColors.card,
cardForeground: YantingColors.foreground,
popover: YantingColors.card,
popoverForeground: YantingColors.foreground,
primary: YantingColors.primary,
primaryForeground: YantingColors.primaryForeground,
secondary: YantingColors.secondary,
secondaryForeground: YantingColors.secondaryForeground,
muted: YantingColors.muted,
mutedForeground: YantingColors.mutedForeground,
accent: YantingColors.brandSoft,
accentForeground: YantingColors.primaryForeground,
destructive: YantingColors.destructive,
destructiveForeground: YantingColors.background,
border: YantingColors.border,
input: YantingColors.input,
ring: YantingColors.primary,
selection: YantingColors.foreground,
custom: {
'brandSoft': YantingColors.brandSoft,
'brandSoftBorder': YantingColors.brandSoftBorder,
'link': YantingColors.link,
'chart2': YantingColors.chart2,
'warning': YantingColors.warning,
'warningSoft': YantingColors.warningSoft,
'warningSoftBorder': YantingColors.warningSoftBorder,
'warningSoftForeground': YantingColors.warningSoftForeground,
},
);
final textTheme = ShadTextTheme(
family: YantingText.fontFamily,
h1Large: YantingText.appTitle.copyWith(color: colors.foreground),
h1: YantingText.appTitle.copyWith(color: colors.foreground),
h2: YantingText.sectionTitle.copyWith(color: colors.foreground),
h3: YantingText.cardTitle.copyWith(color: colors.foreground),
h4: YantingText.listTitle.copyWith(color: colors.foreground),
p: YantingText.body.copyWith(color: colors.foreground),
blockquote: YantingText.body.copyWith(color: colors.mutedForeground),
table: YantingText.meta.copyWith(color: colors.mutedForeground),
list: YantingText.body.copyWith(color: colors.foreground),
lead: YantingText.sub.copyWith(color: colors.mutedForeground),
large: YantingText.cardTitle.copyWith(color: colors.foreground),
small: YantingText.badge.copyWith(color: colors.mutedForeground),
muted: YantingText.meta.copyWith(color: colors.mutedForeground),
googleFontBuilder: GoogleFonts.dmSans,
);
return ShadThemeData(
brightness: brightness,
colorScheme: colors,
radius: BorderRadius.circular(YantingRadius.base),
cardTheme: ShadCardTheme(
padding: const EdgeInsets.all(YantingSpacing.cardPadding),
radius: BorderRadius.circular(YantingRadius.xl),
border: ShadBorder.all(color: colors.border),
shadows: const [],
),
textTheme: textTheme,
);
}
+97
View File
@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import 'yanting_tokens.dart';
abstract final class YantingText {
static const fontFamily = 'DM Sans';
static const fontFallback = [
'PingFang SC',
'Microsoft YaHei',
'Helvetica Neue',
'Arial',
'sans-serif',
];
static const appTitle = TextStyle(
fontFamily: fontFamily,
fontFamilyFallback: fontFallback,
fontSize: 34,
height: 1.15,
letterSpacing: 0,
fontWeight: FontWeight.w800,
);
static const sectionTitle = TextStyle(
fontFamily: fontFamily,
fontFamilyFallback: fontFallback,
fontSize: 22,
height: 1.2,
letterSpacing: 0,
fontWeight: FontWeight.w700,
);
static const cardTitle = TextStyle(
fontFamily: fontFamily,
fontFamilyFallback: fontFallback,
fontSize: 19,
height: 1.4,
letterSpacing: 0,
fontWeight: FontWeight.w600,
);
static const listTitle = TextStyle(
fontFamily: fontFamily,
fontFamilyFallback: fontFallback,
fontSize: 16.5,
height: 1.45,
letterSpacing: 0,
fontWeight: FontWeight.w600,
);
static const body = TextStyle(
fontFamily: fontFamily,
fontFamilyFallback: fontFallback,
fontSize: 15,
height: 1.6,
letterSpacing: 0,
fontWeight: FontWeight.w400,
);
static const sub = TextStyle(
fontFamily: fontFamily,
fontFamilyFallback: fontFallback,
fontSize: 15,
height: 1.45,
letterSpacing: 0,
fontWeight: FontWeight.w400,
);
static const meta = TextStyle(
fontFamily: fontFamily,
fontFamilyFallback: fontFallback,
fontSize: 13,
height: 1.5,
letterSpacing: 0,
fontWeight: FontWeight.w400,
fontFeatures: YantingTypographyFeatures.tabularNums,
);
static const chip = TextStyle(
fontFamily: fontFamily,
fontFamilyFallback: fontFallback,
fontSize: 15,
height: 1.2,
letterSpacing: 0,
fontWeight: FontWeight.w500,
);
static const badge = TextStyle(
fontFamily: fontFamily,
fontFamilyFallback: fontFallback,
fontSize: 12,
height: 1.5,
letterSpacing: 0,
fontWeight: FontWeight.w500,
fontFeatures: YantingTypographyFeatures.tabularNums,
);
}
+94
View File
@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
abstract final class YantingColors {
static const background = Color(0xFFFFFFFF);
static const foreground = Color(0xFF1A1A1A);
static const card = Color(0xFFFFFFFF);
static const primary = Color(0xFF95E300);
static const primaryForeground = Color(0xFF365314);
static const secondary = Color(0xFFF4F4F5);
static const secondaryForeground = Color(0xFF27272A);
static const muted = Color(0xFFF7F7F7);
static const mutedForeground = Color(0xFF71717A);
static const border = Color(0xFFE5E5E5);
static const input = Color(0xFFE5E5E5);
static const destructive = Color(0xFFEF4444);
static const warning = Color(0xFF9A6500);
static const warningSoft = Color(0xFFFDE68A);
static const warningSoftBorder = Color(0xFFF5D26A);
static const warningSoftForeground = Color(0xFF7C4A00);
static const chart2 = Color(0xFF84CC16);
static const brandSoft = Color(0xFFECFCCB);
static const brandSoftBorder = Color(0xFFD6F5A8);
static const link = Color(0xFF2563EB);
static const canvas = background;
}
abstract final class YantingDarkColors {
static const background = Color(0xFF09090B);
static const foreground = Color(0xFFF4F4F5);
static const card = Color(0xFF111113);
static const primary = Color(0xFF95E300);
static const primaryForeground = Color(0xFF0F1A00);
static const secondary = Color(0xFF1F1F23);
static const secondaryForeground = Color(0xFFE4E4E7);
static const muted = Color(0xFF18181B);
static const mutedForeground = Color(0xFFA1A1AA);
static const border = Color(0xFF27272A);
static const input = Color(0xFF27272A);
static const destructive = Color(0xFFF87171);
static const warning = Color(0xFFF59E0B);
static const warningSoft = Color(0xFF2A2412);
static const warningSoftBorder = Color(0xFF665113);
static const warningSoftForeground = Color(0xFFFBBF24);
static const chart2 = Color(0xFF84CC16);
static const brandSoft = Color(0xFF1C2B00);
static const brandSoftBorder = Color(0xFF304800);
static const link = Color(0xFF8AB4FF);
static const canvas = background;
}
abstract final class YantingSpacing {
static const x1 = 4.0;
static const x2 = 8.0;
static const x3 = 12.0;
static const cardGap = 14.0;
static const x4 = 16.0;
static const cardPadding = 18.0;
static const screenX = 20.0;
static const x6 = 24.0;
static const sectionGap = 30.0;
static const x8 = 32.0;
static const x10 = 40.0;
static const tabBarHeight = 56.0;
}
abstract final class YantingRadius {
static const base = 7.2;
static const sm = 3.2;
static const md = 5.2;
static const xl = 11.2;
static const pill = 9999.0;
}
abstract final class YantingBorders {
static const card = BorderSide(color: YantingColors.border);
static const soft = BorderSide(color: YantingColors.brandSoftBorder);
}
abstract final class YantingTypographyFeatures {
static const tabularNums = [FontFeature.tabularFigures()];
}
extension YantingShadColorSchemeX on ShadColorScheme {
Color get brandSoft => custom['brandSoft'] ?? accent;
Color get brandSoftBorder => custom['brandSoftBorder'] ?? border;
Color get link => custom['link'] ?? primary;
Color get warning => custom['warning'] ?? destructive;
Color get warningSoft => custom['warningSoft'] ?? muted;
Color get warningSoftBorder => custom['warningSoftBorder'] ?? border;
Color get warningSoftForeground =>
custom['warningSoftForeground'] ?? foreground;
Color get chart2 => custom['chart2'] ?? primary;
}
+85 -18
View File
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../theme/wise_tokens.dart'; import '../theme/yanting_text.dart';
import '../theme/yanting_tokens.dart';
class AppButton extends StatelessWidget { class AppButton extends StatelessWidget {
const AppButton({ const AppButton({
@@ -9,6 +11,7 @@ class AppButton extends StatelessWidget {
this.icon, this.icon,
this.kind = AppButtonKind.primary, this.kind = AppButtonKind.primary,
this.expand = false, this.expand = false,
this.compact = false,
super.key, super.key,
}); });
@@ -17,29 +20,93 @@ class AppButton extends StatelessWidget {
final IconData? icon; final IconData? icon;
final AppButtonKind kind; final AppButtonKind kind;
final bool expand; final bool expand;
final bool compact;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = switch (kind) { final colors = ShadTheme.of(context).colorScheme;
AppButtonKind.primary => (WiseColors.secondary, WiseColors.primary), final variant = switch (kind) {
AppButtonKind.dark => (WiseColors.primary, Colors.white), AppButtonKind.primary => ShadButtonVariant.primary,
AppButtonKind.accent => (WiseColors.accent, Colors.white), AppButtonKind.dark => ShadButtonVariant.primary,
AppButtonKind.ghost => (WiseColors.surface, WiseColors.primary), AppButtonKind.accent => ShadButtonVariant.secondary,
AppButtonKind.ghost => ShadButtonVariant.outline,
}; };
final child = FilledButton.icon( final palette = switch (kind) {
onPressed: onPressed, AppButtonKind.primary => (null, null, null),
icon: icon == null ? const SizedBox.shrink() : Icon(icon, size: 18), AppButtonKind.dark => (
label: Text(label), colors.foreground,
style: FilledButton.styleFrom( colors.background,
backgroundColor: colors.$1, colors.foreground.withValues(alpha: 0.9),
foregroundColor: colors.$2,
disabledBackgroundColor: WiseColors.border,
disabledForegroundColor: WiseColors.textTertiary,
minimumSize: Size(expand ? double.infinity : 0, 44),
shape: const StadiumBorder(),
), ),
AppButtonKind.accent => (
colors.brandSoft,
colors.primaryForeground,
colors.brandSoftBorder,
),
AppButtonKind.ghost => (null, null, null),
};
final button = ShadButton.raw(
variant: variant,
enabled: onPressed != null,
onPressed: onPressed,
width: expand ? double.infinity : null,
height: compact ? 36 : 44,
padding: EdgeInsets.symmetric(horizontal: compact ? 16 : 20),
backgroundColor: palette.$1,
foregroundColor: palette.$2,
hoverBackgroundColor: palette.$3,
leading: icon == null ? null : Icon(icon, size: compact ? 15 : 16),
gap: compact ? 5 : 7,
textStyle: (compact ? YantingText.badge : YantingText.body).copyWith(
color: palette.$2,
fontWeight: FontWeight.w600,
),
child: Text(label),
); );
return expand ? SizedBox(width: double.infinity, child: child) : child; return expand ? SizedBox(width: double.infinity, child: button) : button;
}
}
class AppIconButton extends StatelessWidget {
const AppIconButton({
required this.icon,
required this.onPressed,
this.kind = AppButtonKind.ghost,
super.key,
});
final IconData icon;
final VoidCallback? onPressed;
final AppButtonKind kind;
@override
Widget build(BuildContext context) {
final iconWidget = Icon(icon, size: 16);
final colors = ShadTheme.of(context).colorScheme;
return switch (kind) {
AppButtonKind.primary => ShadIconButton(
onPressed: onPressed,
icon: iconWidget,
),
AppButtonKind.dark => ShadIconButton(
onPressed: onPressed,
backgroundColor: colors.foreground,
foregroundColor: colors.background,
hoverBackgroundColor: colors.foreground.withValues(alpha: 0.9),
icon: iconWidget,
),
AppButtonKind.accent => ShadIconButton.secondary(
onPressed: onPressed,
backgroundColor: colors.brandSoft,
foregroundColor: colors.primaryForeground,
hoverBackgroundColor: colors.brandSoftBorder,
icon: iconWidget,
),
AppButtonKind.ghost => ShadIconButton.outline(
onPressed: onPressed,
icon: iconWidget,
),
};
} }
} }
+38 -14
View File
@@ -1,13 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../theme/wise_tokens.dart'; import '../theme/yanting_tokens.dart';
class AppCard extends StatelessWidget { class AppCard extends StatelessWidget {
const AppCard({ const AppCard({
required this.child, required this.child,
this.onTap, this.onTap,
this.padding = const EdgeInsets.all(WiseSpacing.x4), this.padding = const EdgeInsets.all(YantingSpacing.cardPadding),
this.color = WiseColors.surface, this.color = YantingColors.card,
this.borderColor = YantingColors.border,
super.key, super.key,
}); });
@@ -15,22 +17,42 @@ class AppCard extends StatelessWidget {
final VoidCallback? onTap; final VoidCallback? onTap;
final EdgeInsetsGeometry padding; final EdgeInsetsGeometry padding;
final Color color; final Color color;
final Color borderColor;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final content = DecoratedBox( final theme = ShadTheme.of(context);
decoration: BoxDecoration( final colors = theme.colorScheme;
color: color, final radius = theme.radius.resolve(TextDirection.ltr);
borderRadius: BorderRadius.circular(WiseRadius.md), final decoration = BoxDecoration(
boxShadow: WiseShadows.card, color: color == YantingColors.card ? colors.card : color,
borderRadius: radius,
border: Border.all(
color: borderColor == YantingColors.border
? colors.border
: borderColor,
), ),
);
if (onTap == null) {
return DecoratedBox(
decoration: decoration,
child: Padding(padding: padding, child: child), child: Padding(padding: padding, child: child),
); );
if (onTap == null) return content; }
return InkWell(
borderRadius: BorderRadius.circular(WiseRadius.md), return Material(
color: Colors.transparent,
borderRadius: radius,
child: Ink(
decoration: decoration,
child: InkWell(
borderRadius: radius,
splashColor: colors.mutedForeground.withValues(alpha: 0.08),
highlightColor: colors.mutedForeground.withValues(alpha: 0.04),
onTap: onTap, onTap: onTap,
child: content, child: Padding(padding: padding, child: child),
),
),
); );
} }
} }
@@ -43,10 +65,12 @@ class HeroReportCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
return AppCard( return AppCard(
onTap: onTap, onTap: onTap,
color: WiseColors.secondary200, color: colors.brandSoft,
padding: const EdgeInsets.all(WiseSpacing.x5), borderColor: colors.brandSoftBorder,
padding: const EdgeInsets.all(YantingSpacing.cardPadding),
child: child, child: child,
); );
} }
+50 -34
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../theme/wise_tokens.dart'; import '../theme/yanting_tokens.dart';
class AppBadge extends StatelessWidget { class AppBadge extends StatelessWidget {
const AppBadge({ const AppBadge({
@@ -16,35 +17,47 @@ class AppBadge extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = switch (kind) { final colors = ShadTheme.of(context).colorScheme;
BadgeKind.brand => (WiseColors.secondary200, WiseColors.primarySoft), final child = Row(
BadgeKind.audio => (const Color(0x1F00A2DD), WiseColors.accent),
BadgeKind.tier => (const Color(0x1A008026), WiseColors.positive),
BadgeKind.warning => (const Color(0x209A6500), WiseColors.warning),
BadgeKind.neutral => (const Color(0x1286A7BD), WiseColors.textSecondary),
};
return DecoratedBox(
decoration: BoxDecoration(
color: colors.$1,
borderRadius: BorderRadius.circular(WiseRadius.pill),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (icon != null) ...[ if (icon != null) ...[Icon(icon, size: 12), const SizedBox(width: 4)],
Icon(icon, size: 14, color: colors.$2), Text(text),
const SizedBox(width: 4),
], ],
Text(
text,
style: Theme.of(context).textTheme.labelSmall?.copyWith(color: colors.$2),
),
],
),
),
); );
final shape = RoundedRectangleBorder(
borderRadius: BorderRadius.circular(YantingRadius.sm),
side: kind == BadgeKind.tier || kind == BadgeKind.warning
? BorderSide(color: colors.border)
: BorderSide.none,
);
return switch (kind) {
BadgeKind.brand => ShadBadge(
shape: shape,
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
child: child,
),
BadgeKind.audio || BadgeKind.neutral => ShadBadge.secondary(
shape: shape,
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
child: child,
),
BadgeKind.tier => ShadBadge.outline(
shape: shape,
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
foregroundColor: colors.mutedForeground,
child: child,
),
BadgeKind.warning => ShadBadge.destructive(
shape: shape,
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
backgroundColor: colors.warningSoft,
foregroundColor: colors.warningSoftForeground,
hoverBackgroundColor: colors.warningSoftBorder,
child: child,
),
};
} }
} }
@@ -64,16 +77,19 @@ class AppChip extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ActionChip( final colors = ShadTheme.of(context).colorScheme;
return ShadBadge.secondary(
onPressed: onTap, onPressed: onTap,
label: Text(label),
labelStyle: TextStyle(
color: selected ? Colors.white : WiseColors.textSecondary,
fontWeight: FontWeight.w700,
),
backgroundColor: selected ? WiseColors.primary : WiseColors.surface,
side: const BorderSide(color: WiseColors.border),
shape: const StadiumBorder(), shape: const StadiumBorder(),
padding: const EdgeInsets.symmetric(horizontal: 17, vertical: 9),
backgroundColor: selected ? colors.foreground : colors.secondary,
hoverBackgroundColor: selected
? colors.foreground.withValues(alpha: 0.9)
: colors.border,
foregroundColor: selected
? colors.background
: colors.secondaryForeground,
child: Text(label),
); );
} }
} }
+129
View File
@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../theme/app_icons.dart';
import '../theme/yanting_text.dart';
import '../theme/yanting_tokens.dart';
class BottomTabBarItem {
const BottomTabBarItem({
required this.label,
required this.icon,
required this.selectedIcon,
});
final String label;
final IconData icon;
final IconData selectedIcon;
}
class BottomTabBar extends StatelessWidget {
const BottomTabBar({
required this.items,
required this.selectedIndex,
required this.onSelected,
super.key,
});
final List<BottomTabBarItem> items;
final int selectedIndex;
final ValueChanged<int> onSelected;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
return DecoratedBox(
decoration: const BoxDecoration(color: Colors.transparent),
child: SizedBox(
height: YantingSpacing.tabBarHeight,
child: DecoratedBox(
decoration: BoxDecoration(
color: colors.background,
border: Border(top: BorderSide(color: colors.border)),
),
child: Row(
children: [
for (var index = 0; index < items.length; index++)
Expanded(
child: _BottomTabButton(
item: items[index],
selected: index == selectedIndex,
onTap: () => onSelected(index),
),
),
],
),
),
),
);
}
}
class _BottomTabButton extends StatelessWidget {
const _BottomTabButton({
required this.item,
required this.selected,
required this.onTap,
});
final BottomTabBarItem item;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
final color = selected ? colors.foreground : colors.mutedForeground;
return InkWell(
onTap: onTap,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
selected ? item.selectedIcon : item.icon,
size: 22,
color: color,
),
const SizedBox(height: 4),
Text(
item.label,
style: YantingText.meta.copyWith(
color: color,
fontSize: 11,
letterSpacing: 0,
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
),
),
],
),
);
}
}
const yantingBottomTabItems = [
BottomTabBarItem(
label: '推荐',
icon: AppIcons.sparkle,
selectedIcon: AppIcons.sparkleFill,
),
BottomTabBarItem(
label: '研报',
icon: AppIcons.article,
selectedIcon: AppIcons.articleFill,
),
BottomTabBarItem(
label: '机构',
icon: AppIcons.bank,
selectedIcon: AppIcons.bankFill,
),
BottomTabBarItem(
label: '听单',
icon: AppIcons.headphones,
selectedIcon: AppIcons.headphonesFill,
),
BottomTabBarItem(
label: '我的',
icon: AppIcons.user,
selectedIcon: AppIcons.userFill,
),
];
+145
View File
@@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../data/models/models.dart';
import '../theme/yanting_text.dart';
import '../theme/yanting_tokens.dart';
import 'app_card.dart';
import 'badges.dart';
class InstitutionCard extends StatelessWidget {
const InstitutionCard({
required this.institution,
required this.onTap,
super.key,
});
final Institution institution;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
final initials = institution.nameCn.isEmpty
? ''
: institution.nameCn.characters.take(2).toString();
return AppCard(
onTap: onTap,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
InstitutionLogo(
logoUrl: institution.logoUrl,
initials: initials,
size: 48,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
institution.nameCn,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: YantingText.listTitle.copyWith(
fontWeight: FontWeight.w700,
color: colors.foreground,
),
),
if (institution.nameEn.isNotEmpty) ...[
const SizedBox(height: 3),
Text(
institution.nameEn,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: YantingText.meta,
),
],
const SizedBox(height: 8),
Wrap(
spacing: 7,
runSpacing: 7,
children: [
if (institution.institutionType.isNotEmpty)
AppBadge(
text: institution.institutionType,
kind: BadgeKind.tier,
),
for (final topic in institution.coveredTopics.take(3))
AppBadge(text: topic),
],
),
],
),
),
const SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${institution.reportCount}',
style: YantingText.sectionTitle.copyWith(
fontSize: 20,
fontFeatures: YantingTypographyFeatures.tabularNums,
color: colors.foreground,
),
),
Text('份研报', style: YantingText.meta.copyWith(fontSize: 11)),
],
),
],
),
);
}
}
class InstitutionLogo extends StatelessWidget {
const InstitutionLogo({
required this.logoUrl,
required this.initials,
required this.size,
super.key,
});
final String logoUrl;
final String initials;
final double size;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
final fallback = DecoratedBox(
decoration: BoxDecoration(
color: colors.secondary,
border: Border.all(color: colors.border),
borderRadius: BorderRadius.circular(size * 0.25),
),
child: Center(
child: Text(
initials,
style: YantingText.meta.copyWith(
color: colors.secondaryForeground,
fontSize: 14,
fontWeight: FontWeight.w700,
fontFeatures: null,
),
),
),
);
if (logoUrl.isEmpty) {
return SizedBox(width: size, height: size, child: fallback);
}
return ClipRRect(
borderRadius: BorderRadius.circular(size * 0.25),
child: Image.network(
logoUrl,
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => fallback,
),
);
}
}
+153 -65
View File
@@ -1,7 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../data/models/models.dart'; import '../data/models/models.dart';
import '../theme/app_icons.dart';
import '../theme/yanting_text.dart';
import '../theme/wise_tokens.dart'; import '../theme/wise_tokens.dart';
import 'app_buttons.dart';
import 'app_card.dart'; import 'app_card.dart';
class PlayerStateModel { class PlayerStateModel {
@@ -47,11 +51,7 @@ class PlayerStateModel {
} }
class MiniPlayer extends StatelessWidget { class MiniPlayer extends StatelessWidget {
const MiniPlayer({ const MiniPlayer({required this.player, required this.onToggle, super.key});
required this.player,
required this.onToggle,
super.key,
});
final PlayerStateModel player; final PlayerStateModel player;
final VoidCallback onToggle; final VoidCallback onToggle;
@@ -59,55 +59,87 @@ class MiniPlayer extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (!player.hasAudio) return const SizedBox.shrink(); if (!player.hasAudio) return const SizedBox.shrink();
final ratio = player.durationSec == 0 ? 0.0 : player.positionSec / player.durationSec; final colors = ShadTheme.of(context).colorScheme;
return Padding( final ratio = player.durationSec == 0
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), ? 0.0
child: AppCard( : player.positionSec / player.durationSec;
padding: const EdgeInsets.all(12), return DecoratedBox(
color: WiseColors.primary, decoration: BoxDecoration(
child: Column( color: colors.secondary,
crossAxisAlignment: CrossAxisAlignment.start, border: Border(top: BorderSide(color: colors.border)),
),
child: Stack(
children: [ children: [
Row( Positioned(
children: [ top: 0,
IconButton.filled( left: 0,
onPressed: onToggle, right: 0,
icon: Icon(player.playing ? Icons.pause : Icons.play_arrow), child: Align(
style: IconButton.styleFrom( alignment: Alignment.centerLeft,
backgroundColor: WiseColors.secondary, child: FractionallySizedBox(
foregroundColor: WiseColors.primary, widthFactor: ratio.clamp(0, 1),
child: SizedBox(
height: 2,
child: ColoredBox(color: colors.primary),
), ),
), ),
const SizedBox(width: WiseSpacing.x2), ),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
child: Row(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: colors.primary,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
AppIcons.disc,
color: colors.primaryForeground,
size: 20,
),
),
const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
player.title, player.title,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w800), style: YantingText.meta.copyWith(
color: colors.foreground,
fontWeight: FontWeight.w600,
fontFeatures: null,
), ),
),
const SizedBox(height: 2),
Text( Text(
'${formatDuration(player.positionSec)} / ${formatDuration(player.durationSec)} · ${player.speed.toStringAsFixed(1)}x', '${formatDuration(player.positionSec)} / ${formatDuration(player.durationSec)} · ${player.speed.toStringAsFixed(1)}x',
style: const TextStyle(color: Color(0xCCFFFFFF), fontSize: 12), maxLines: 1,
overflow: TextOverflow.ellipsis,
style: YantingText.meta.copyWith(fontSize: 11),
),
],
),
),
ShadIconButton.ghost(
onPressed: onToggle,
icon: Icon(
player.playing ? AppIcons.pause : AppIcons.playCircle,
size: player.playing ? 24 : 28,
),
), ),
], ],
), ),
), ),
], ],
), ),
const SizedBox(height: WiseSpacing.x2),
LinearProgressIndicator(
value: ratio.clamp(0, 1),
minHeight: 4,
backgroundColor: const Color(0x33FFFFFF),
color: WiseColors.secondary,
),
],
),
),
); );
} }
} }
@@ -137,52 +169,108 @@ class PlayerCard extends StatelessWidget {
final active = player.hasAudio && player.title == title; final active = player.hasAudio && player.title == title;
final position = active ? player.positionSec : 0; final position = active ? player.positionSec : 0;
final ratio = durationSec == 0 ? 0.0 : position / durationSec; final ratio = durationSec == 0 ? 0.0 : position / durationSec;
final colors = ShadTheme.of(context).colorScheme;
return AppCard( return AppCard(
color: WiseColors.secondary200, color: colors.secondary,
borderColor: colors.border,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('音频解读', style: Theme.of(context).textTheme.titleMedium), Text('音频解读', style: YantingText.listTitle),
const SizedBox(height: WiseSpacing.x2), const SizedBox(height: 6),
Text(title, style: Theme.of(context).textTheme.bodyMedium), Text(
const SizedBox(height: WiseSpacing.x3), title,
LinearProgressIndicator( maxLines: 2,
value: ratio.clamp(0, 1), overflow: TextOverflow.ellipsis,
minHeight: 6, style: YantingText.meta.copyWith(fontSize: 12.5),
backgroundColor: Colors.white,
color: WiseColors.accent,
), ),
const SizedBox(height: 16),
ShadProgress(value: ratio.clamp(0, 1)),
const SizedBox(height: WiseSpacing.x2), const SizedBox(height: WiseSpacing.x2),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text(formatDuration(position), style: Theme.of(context).textTheme.bodySmall), Text(
Text(formatDuration(durationSec), style: Theme.of(context).textTheme.bodySmall), formatDuration(position),
], style: YantingText.meta.copyWith(fontSize: 11),
),
const SizedBox(height: WiseSpacing.x3),
Row(
children: [
IconButton.outlined(onPressed: () => onSeek(-15), icon: const Icon(Icons.replay_10)),
IconButton.filled(
onPressed: active ? onToggle : onStart,
icon: Icon(active && player.playing ? Icons.pause : Icons.play_arrow),
style: IconButton.styleFrom(
backgroundColor: WiseColors.primary,
foregroundColor: Colors.white,
),
),
IconButton.outlined(onPressed: () => onSeek(15), icon: const Icon(Icons.forward_10)),
const Spacer(),
TextButton(onPressed: onSpeed, child: Text('${player.speed.toStringAsFixed(1)}x')),
],
), ),
Text(
formatDuration(durationSec),
style: YantingText.meta.copyWith(fontSize: 11),
),
],
),
const SizedBox(height: 18),
SizedBox(
height: 56,
child: Stack(
alignment: Alignment.center,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_SkipButton(label: '-15', onPressed: () => onSeek(-15)),
const SizedBox(width: 26),
SizedBox(
width: 56,
height: 56,
child: AppIconButton(
kind: AppButtonKind.primary,
onPressed: active ? onToggle : onStart,
icon: active && player.playing
? AppIcons.pause
: AppIcons.play,
),
),
const SizedBox(width: 26),
_SkipButton(label: '+15', onPressed: () => onSeek(15)),
],
),
Align(
alignment: Alignment.centerRight,
child: ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: onSpeed,
child: Text('${player.speed.toStringAsFixed(1)}x'),
),
),
],
),
),
const SizedBox(height: 16),
Text( Text(
'真实音频流待 /audio/{id}/stream 接入,当前为本地进度占位。', '真实音频流待 /audio/{id}/stream 接入,当前为本地进度占位。',
style: Theme.of(context).textTheme.bodySmall, style: YantingText.meta.copyWith(fontSize: 11.5, height: 1.6),
), ),
], ],
), ),
); );
} }
} }
class _SkipButton extends StatelessWidget {
const _SkipButton({required this.label, required this.onPressed});
final String label;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
return TextButton(
onPressed: onPressed,
style: TextButton.styleFrom(
foregroundColor: colors.foreground,
minimumSize: const Size(40, 40),
padding: EdgeInsets.zero,
),
child: Text(
label,
style: YantingText.meta.copyWith(
color: colors.foreground,
fontWeight: FontWeight.w600,
),
),
);
}
}
+60
View File
@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../theme/yanting_text.dart';
import '../theme/yanting_tokens.dart';
class PageHeader extends StatelessWidget {
const PageHeader({required this.title, this.subtitle, super.key});
final String title;
final String? subtitle;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(top: 4, bottom: 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: YantingText.appTitle),
if (subtitle != null && subtitle!.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
subtitle!,
style: YantingText.sub.copyWith(color: colors.mutedForeground),
),
],
],
),
);
}
}
class SectionTitle extends StatelessWidget {
const SectionTitle({required this.title, this.icon, super.key});
final String title;
final IconData? icon;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(
top: YantingSpacing.sectionGap,
bottom: 16,
),
child: Row(
children: [
Text(title, style: YantingText.sectionTitle),
if (icon != null) ...[
const SizedBox(width: 6),
Icon(icon, size: 18, color: colors.mutedForeground),
],
],
),
);
}
}
+29 -41
View File
@@ -1,37 +1,35 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../theme/wise_tokens.dart';
import 'app_buttons.dart'; import 'app_buttons.dart';
import 'states.dart'; import 'states.dart';
Future<void> showLoginSheet(BuildContext context, {String reason = '登录后保存当前动作'}) { Future<void> showLoginSheet(
return showModalBottomSheet<void>( BuildContext context, {
String reason = '登录后保存当前动作',
VoidCallback? onPhoneLogin,
VoidCallback? onSecondaryLogin,
}) {
return showShadSheet<void>(
context: context, context: context,
showDragHandle: true, side: ShadSheetSide.bottom,
backgroundColor: WiseColors.surface, builder: (context) => ShadSheet(
shape: const RoundedRectangleBorder( title: const Text('登录研听'),
borderRadius: BorderRadius.vertical(top: Radius.circular(WiseRadius.lg)), description: Text(reason),
),
builder: (context) => Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 28),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('登录研听', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: WiseSpacing.x2),
Text(reason, style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: WiseSpacing.x4),
AppButton( AppButton(
label: '使用手机号继续', label: '使用手机号继续',
icon: Icons.phone_iphone, icon: Icons.phone_iphone,
expand: true, expand: true,
onPressed: () { onPressed: () {
Navigator.pop(context); Navigator.pop(context);
showAppToast(context, '登录接口待接入,已保留当前页面'); onPhoneLogin?.call();
showAppToast(context, '已使用本地登录态继续');
}, },
), ),
const SizedBox(height: WiseSpacing.x2), const SizedBox(height: 8),
AppButton( AppButton(
label: '微信 / Apple 登录占位', label: '微信 / Apple 登录占位',
icon: Icons.account_circle_outlined, icon: Icons.account_circle_outlined,
@@ -39,7 +37,8 @@ Future<void> showLoginSheet(BuildContext context, {String reason = '登录后保
expand: true, expand: true,
onPressed: () { onPressed: () {
Navigator.pop(context); Navigator.pop(context);
showAppToast(context, '真实 auth 待后端接入'); onSecondaryLogin?.call();
showAppToast(context, '已使用本地登录态继续');
}, },
), ),
], ],
@@ -48,39 +47,28 @@ Future<void> showLoginSheet(BuildContext context, {String reason = '登录后保
); );
} }
Future<void> showOutboundSheet(BuildContext context, {required String title}) { Future<void> showOutboundSheet(
return showModalBottomSheet<void>( BuildContext context, {
required String title,
VoidCallback? onConfirm,
}) {
return showShadSheet<void>(
context: context, context: context,
showDragHandle: true, side: ShadSheetSide.bottom,
backgroundColor: WiseColors.surface, builder: (context) => ShadSheet(
shape: const RoundedRectangleBorder( title: const Text('即将打开外部服务'),
borderRadius: BorderRadius.vertical(top: Radius.circular(WiseRadius.lg)), description: Text('$title\n外跳仅用于了解原文或相关服务,本内容不构成投资建议。'),
), child: AppButton(
builder: (context) => Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 28),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('即将打开外部服务', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: WiseSpacing.x2),
Text(
'$title\n外跳仅用于了解原文或相关服务,本内容不构成投资建议。',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: WiseSpacing.x4),
AppButton(
label: '确认并记录占位事件', label: '确认并记录占位事件',
icon: Icons.open_in_new, icon: Icons.open_in_new,
kind: AppButtonKind.accent, kind: AppButtonKind.accent,
expand: true, expand: true,
onPressed: () { onPressed: () {
Navigator.pop(context); Navigator.pop(context);
onConfirm?.call();
showAppToast(context, '外跳事件接口待接入'); showAppToast(context, '外跳事件接口待接入');
}, },
), ),
],
),
), ),
); );
} }
+80 -23
View File
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../theme/wise_tokens.dart'; import '../theme/yanting_tokens.dart';
import 'app_buttons.dart'; import 'app_buttons.dart';
import 'app_card.dart'; import 'app_card.dart';
@@ -12,9 +14,10 @@ class LoadingState extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListView.separated( return ListView.separated(
padding: const EdgeInsets.all(WiseSpacing.x4), padding: const EdgeInsets.all(YantingSpacing.screenX),
itemCount: 4, itemCount: 4,
separatorBuilder: (_, _) => const SizedBox(height: WiseSpacing.x3), separatorBuilder: (_, _) =>
const SizedBox(height: YantingSpacing.cardGap),
itemBuilder: (context, index) => const SkeletonCard(), itemBuilder: (context, index) => const SkeletonCard(),
); );
} }
@@ -30,11 +33,11 @@ class SkeletonCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: const [ children: const [
SkeletonLine(width: 96), SkeletonLine(width: 96),
SizedBox(height: WiseSpacing.x3), SizedBox(height: YantingSpacing.cardGap),
SkeletonLine(width: double.infinity, height: 18), SkeletonLine(width: double.infinity, height: 18),
SizedBox(height: WiseSpacing.x2), SizedBox(height: YantingSpacing.x2),
SkeletonLine(width: 240), SkeletonLine(width: 240),
SizedBox(height: WiseSpacing.x3), SizedBox(height: YantingSpacing.cardGap),
SkeletonLine(width: 160), SkeletonLine(width: 160),
], ],
), ),
@@ -50,12 +53,58 @@ class SkeletonLine extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( final theme = ShadTheme.of(context);
return _PulsingSkeleton(
width: width, width: width,
height: height, height: height,
color: theme.colorScheme.muted,
);
}
}
class _PulsingSkeleton extends StatefulWidget {
const _PulsingSkeleton({
required this.color,
required this.width,
required this.height,
});
final Color color;
final double width;
final double height;
@override
State<_PulsingSkeleton> createState() => _PulsingSkeletonState();
}
class _PulsingSkeletonState extends State<_PulsingSkeleton>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
)..repeat(reverse: true);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return FadeTransition(
opacity: Tween<double>(
begin: 0.4,
end: 1,
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)),
child: Container(
width: widget.width,
height: widget.height,
decoration: BoxDecoration( decoration: BoxDecoration(
color: WiseColors.border, color: widget.color,
borderRadius: BorderRadius.circular(WiseRadius.pill), borderRadius: theme.radius,
),
), ),
); );
} }
@@ -79,24 +128,29 @@ class EmptyState extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return Center( return Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(WiseSpacing.x6), padding: const EdgeInsets.all(YantingSpacing.x6),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(icon, size: 42, color: WiseColors.primary), Icon(icon, size: 42, color: theme.colorScheme.foreground),
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: YantingSpacing.cardGap),
Text(title, style: Theme.of(context).textTheme.titleMedium), Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: WiseSpacing.x2), const SizedBox(height: YantingSpacing.x2),
Text( Text(
message, message,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.bodyMedium,
), ),
if (actionLabel != null) ...[ if (actionLabel != null) ...[
const SizedBox(height: WiseSpacing.x4), const SizedBox(height: YantingSpacing.x4),
AppButton(label: actionLabel!, onPressed: onAction, kind: AppButtonKind.ghost), AppButton(
label: actionLabel!,
onPressed: onAction,
kind: AppButtonKind.ghost,
),
], ],
], ],
), ),
@@ -123,13 +177,16 @@ class ErrorState extends StatelessWidget {
} }
} }
void showAppToast(BuildContext context, String message) { Future<bool?> showAppToast(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar( // ShadToaster.of(context).show(ShadToast(title: Text(message)));
SnackBar(
content: Text(message), return Fluttertoast.showToast(
behavior: SnackBarBehavior.floating, msg: message,
backgroundColor: WiseColors.primary, toastLength: Toast.LENGTH_SHORT,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(WiseRadius.md)), gravity: ToastGravity.BOTTOM,
), timeInSecForIosWeb: 1,
backgroundColor: const Color(0xCC111111),
textColor: Colors.white,
fontSize: 16,
); );
} }
+9
View File
@@ -0,0 +1,9 @@
export 'app_buttons.dart';
export 'app_card.dart';
export 'badges.dart';
export 'bottom_tab_bar.dart';
export 'institution_card.dart';
export 'mini_player.dart';
export 'page_header.dart';
export 'sheets.dart';
export 'states.dart';
+483 -1
View File
@@ -1,6 +1,14 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async: async:
dependency: transitive dependency: transitive
description: description:
@@ -17,6 +25,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.2"
boxy:
dependency: transitive
description:
name: boxy
sha256: "42ccafe13b2893878042acc5b7e2446025328e11a3197b0bb78db42ff76aa3f0"
url: "https://pub.dev"
source: hosted
version: "2.3.0"
characters: characters:
dependency: transitive dependency: transitive
description: description:
@@ -41,6 +57,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" version: "1.19.1"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
cupertino_icons: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -49,6 +73,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.9" version: "1.0.9"
extended_image:
dependency: transitive
description:
name: extended_image
sha256: f6cbb1d798f51262ed1a3d93b4f1f2aa0d76128df39af18ecb77fa740f88b2e0
url: "https://pub.dev"
source: hosted
version: "10.0.1"
extended_image_library:
dependency: transitive
description:
name: extended_image_library
sha256: "1f9a24d3a00c2633891c6a7b5cab2807999eb2d5b597e5133b63f49d113811fe"
url: "https://pub.dev"
source: hosted
version: "5.0.1"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@@ -57,11 +97,43 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.3" version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_animate:
dependency: transitive
description:
name: flutter_animate
sha256: "7befe2d3252728afb77aecaaea1dec88a89d35b9b1d2eea6d04479e8af9117b5"
url: "https://pub.dev"
source: hosted
version: "4.5.2"
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 +142,77 @@ 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_shaders:
dependency: transitive
description:
name: flutter_shaders
sha256: "34794acadd8275d971e02df03afee3dee0f98dbfb8c4837082ad0034f612a3e2"
url: "https://pub.dev"
source: hosted
version: "0.1.3"
flutter_svg:
dependency: transitive
description:
name: flutter_svg
sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f"
url: "https://pub.dev"
source: hosted
version: "2.3.0"
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"
fluttertoast:
dependency: "direct main"
description:
name: fluttertoast
sha256: "144ddd74d49c865eba47abe31cbc746c7b311c82d6c32e571fd73c4264b740e2"
url: "https://pub.dev"
source: hosted
version: "9.0.0"
go_router:
dependency: "direct main"
description:
name: go_router
sha256: d8f590a69729f719177ea68eb1e598295e8dbc41bbc247fed78b2c8a25660d7c
url: "https://pub.dev"
source: hosted
version: "16.3.0"
google_fonts:
dependency: "direct main"
description:
name: google_fonts
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
url: "https://pub.dev"
source: hosted
version: "6.3.3"
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:
@@ -83,6 +221,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.6.0" version: "1.6.0"
http_client_helper:
dependency: transitive
description:
name: http_client_helper
sha256: "8a9127650734da86b5c73760de2b404494c968a3fd55602045ffec789dac3cb1"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
http_parser: http_parser:
dependency: transitive dependency: transitive
description: description:
@@ -91,6 +237,38 @@ 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"
jni:
dependency: transitive
description:
name: jni
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
url: "https://pub.dev"
source: hosted
version: "1.0.0"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@@ -123,6 +301,22 @@ 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"
lucide_icons_flutter:
dependency: transitive
description:
name: lucide_icons_flutter
sha256: "7c5dc01a32a9905ae34e2d84224e92d6d0c42acf8926df9e01c35a1446bf1b69"
url: "https://pub.dev"
source: hosted
version: "3.1.14+2"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
@@ -147,6 +341,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.16.0" version: "1.16.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path: path:
dependency: transitive dependency: transitive
description: description:
@@ -155,11 +357,203 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" version: "1.9.1"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.dev"
source: hosted
version: "2.3.1"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4"
url: "https://pub.dev"
source: hosted
version: "2.5.1"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
phosphor_flutter:
dependency: "direct main"
description:
name: phosphor_flutter
sha256: "8a14f238f28a0b54842c5a4dc20676598dd4811fcba284ed828bd5a262c11fde"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
remixicon:
dependency: "direct main"
description:
name: remixicon
sha256: "4b8e334b78b0fbf05fb7abe1b48f3c3df9e4a11ab767e3f3e7f1cc36dc1e046e"
url: "https://pub.dev"
source: hosted
version: "4.9.3"
riverpod:
dependency: transitive
description:
name: riverpod
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
serial_csv:
dependency: transitive
description:
name: serial_csv
sha256: "2d62bb70cb3ce7251383fc86ea9aae1298ab1e57af6ef4e93b6a9751c5c268dd"
url: "https://pub.dev"
source: hosted
version: "0.5.2"
shadcn_ui:
dependency: "direct main"
description:
name: shadcn_ui
sha256: "6c06f2bcebd8734b9ed0bf3f63ef5c71981573d5664923589b0302f8280e7eaf"
url: "https://pub.dev"
source: hosted
version: "0.53.6"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.dev"
source: hosted
version: "2.5.5"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
url: "https://pub.dev"
source: hosted
version: "2.4.23"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
slang:
dependency: transitive
description:
name: slang
sha256: "46e929158c2f563994c4d1fce5819cfa13e18b164941473d2553bcddcf387c31"
url: "https://pub.dev"
source: hosted
version: "4.15.0"
slang_flutter:
dependency: transitive
description:
name: slang_flutter
sha256: "0eb6348416a296f1bd940fe02669bcd2df5c5cfdabf893b98e448df8b7ecf4ac"
url: "https://pub.dev"
source: hosted
version: "4.15.0"
source_span: source_span:
dependency: transitive dependency: transitive
description: description:
@@ -176,6 +570,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:
@@ -208,6 +610,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.6" version: "0.7.6"
theme_extensions_builder_annotation:
dependency: transitive
description:
name: theme_extensions_builder_annotation
sha256: "75f28ac85d396d143d111a47c1395b01f3be41b7135f37bd51512921944e4206"
url: "https://pub.dev"
source: hosted
version: "7.3.0"
two_dimensional_scrollables:
dependency: transitive
description:
name: two_dimensional_scrollables
sha256: "4f25bd42783626c5f2810333418727455397195acb61e53710e638a6a98e0e5e"
url: "https://pub.dev"
source: hosted
version: "0.5.2"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -216,6 +634,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
universal_image:
dependency: transitive
description:
name: universal_image
sha256: "2eae13df84a47960cc4148fec88f09031ea64cbf667b4131ff2c907dd7b7c6d1"
url: "https://pub.dev"
source: hosted
version: "1.0.12"
vector_graphics:
dependency: transitive
description:
name: vector_graphics
sha256: "2306c03da2ba81724afeb589c351ebbc0aa7d86005925be8f8735856dbe5e42d"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
vector_graphics_codec:
dependency: transitive
description:
name: vector_graphics_codec
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
url: "https://pub.dev"
source: hosted
version: "1.1.13"
vector_graphics_compiler:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: b9b3f391857781aa96acacef96066f2f49b4cd03cf9fce3ca4d8da2ef5ea129e
url: "https://pub.dev"
source: hosted
version: "1.2.3"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@@ -232,6 +682,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.2.0" version: "15.2.0"
watcher:
dependency: transitive
description:
name: watcher
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
web: web:
dependency: transitive dependency: transitive
description: description:
@@ -240,6 +698,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
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.35.6"
+11
View File
@@ -29,11 +29,22 @@ 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
google_fonts: ^6.2.1
phosphor_flutter: ^2.1.0
remixicon: ^4.9.3
shared_preferences: ^2.3.3
shadcn_ui: ^0.53.6
fluttertoast: ^9.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
+2 -2
View File
@@ -24,12 +24,12 @@ void main() {
await tester.tap(find.text('报告摘要').last); await tester.tap(find.text('报告摘要').last);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('报告摘要'), findsOneWidget); expect(find.text('报告摘要'), findsWidgets);
expect(find.text('需求结构'), findsOneWidget); expect(find.text('需求结构'), findsOneWidget);
}); });
} }
class FakeDataSource implements ReportDataSource { class FakeDataSource extends ReportDataSource {
final institution = const Institution( final institution = const Institution(
id: 'inst_ssga', id: 'inst_ssga',
nameCn: '道富环球投资管理', nameCn: '道富环球投资管理',