fix:按html的假数据demo

This commit is contained in:
jingyun
2026-06-05 11:12:55 +08:00
parent b4272b5ec9
commit 9727b906c6
28 changed files with 2159 additions and 711 deletions
+493
View File
@@ -0,0 +1,493 @@
import '../models/models.dart';
import 'report_data_source.dart';
class MockReportDataSource implements 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',
);
}
}
+16 -6
View File
@@ -18,12 +18,16 @@ List<String> asStringList(Object? value) {
JsonMap asMap(Object? 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 {};
}
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 [];
}
@@ -46,6 +50,7 @@ class Institution {
required this.id,
required this.nameCn,
this.nameEn = '',
this.logoUrl = '',
this.institutionType = '',
this.sourceTier = '',
this.websiteUrl = '',
@@ -60,6 +65,7 @@ class Institution {
final String id;
final String nameCn;
final String nameEn;
final String logoUrl;
final String institutionType;
final String sourceTier;
final String websiteUrl;
@@ -75,6 +81,7 @@ class Institution {
id: asString(json['institution_id']),
nameCn: asString(json['name_cn']),
nameEn: asString(json['name_en']),
logoUrl: asString(json['logo_url']),
institutionType: asString(json['institution_type']),
sourceTier: asString(json['source_tier']),
websiteUrl: asString(json['website_url']),
@@ -83,9 +90,9 @@ class Institution {
latestReportAt: json['latest_report_at']?.toString(),
credibilityNote: asString(json['credibility_note']),
introCn: asString(json['intro_cn']),
recentReports: asMapList(json['recent_reports'])
.map(ReportCardModel.fromJson)
.toList(),
recentReports: asMapList(
json['recent_reports'],
).map(ReportCardModel.fromJson).toList(),
);
}
}
@@ -161,7 +168,10 @@ class AudioItem {
audioId: asString(json['audio_id']),
reportId: asString(json['report_id']),
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']),
institution: Institution.fromJson(asMap(json['institution'])),
releasedAt: json['released_at']?.toString(),
+6 -1
View File
@@ -1,14 +1,19 @@
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 '../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();
});
});
+148 -130
View File
@@ -4,6 +4,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../../data/api/report_data_source.dart';
import '../../../data/models/models.dart';
import '../../../theme/app_icons.dart';
import '../../../theme/yanting_text.dart';
import '../../../theme/yanting_tokens.dart';
import '../../../theme/wise_tokens.dart';
import '../../../widgets/app_card.dart';
import '../../../widgets/badges.dart';
@@ -50,7 +53,7 @@ class ModuleRendererRegistry {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_ModuleHeader(module: module),
const SizedBox(height: WiseSpacing.x4),
const SizedBox(height: WiseSpacing.x3),
_contentFor(
context,
type: module.type,
@@ -66,12 +69,12 @@ class ModuleRendererRegistry {
compact: module.renderMode != 'inline',
),
if (module.hasDetailPage) ...[
const SizedBox(height: WiseSpacing.x4),
const SizedBox(height: WiseSpacing.x3),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: openDetail,
icon: const Icon(Icons.open_in_new),
icon: const Icon(AppIcons.externalLink),
label: const Text('查看详情'),
),
),
@@ -215,8 +218,13 @@ class _ModuleDetailContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4),
padding: const EdgeInsets.fromLTRB(WiseSpacing.x4, 4, WiseSpacing.x4, 16),
children: [
Text(
detail.titleCn,
style: YantingText.sectionTitle.copyWith(fontSize: 21),
),
const SizedBox(height: WiseSpacing.x2),
AppCard(
child: registry.page(
context,
@@ -226,10 +234,7 @@ class _ModuleDetailContent extends StatelessWidget {
),
),
const SizedBox(height: WiseSpacing.x3),
Text(
'缓存版本 ${detail.cacheVersion}',
style: Theme.of(context).textTheme.bodySmall,
),
Text('缓存版本 ${detail.cacheVersion}', style: YantingText.meta),
],
);
}
@@ -247,7 +252,7 @@ class _ModuleHeader extends StatelessWidget {
Expanded(
child: Text(
module.titleCn,
style: Theme.of(context).textTheme.titleMedium,
style: YantingText.cardTitle.copyWith(fontSize: 17),
),
),
if (module.layer.isNotEmpty)
@@ -276,7 +281,7 @@ class _BasicInfo extends StatelessWidget {
payload['summary_cn'],
asString(payload['scope_cn'], report?.oneLiner ?? ''),
),
style: Theme.of(context).textTheme.bodyMedium,
style: YantingText.body,
),
const SizedBox(height: WiseSpacing.x2),
Wrap(
@@ -306,8 +311,14 @@ class _CoreInsights extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final point in points)
Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x3),
Container(
margin: const EdgeInsets.only(bottom: WiseSpacing.x3),
padding: const EdgeInsets.all(WiseSpacing.x3),
decoration: BoxDecoration(
color: YantingColors.background,
border: Border.all(color: YantingColors.border),
borderRadius: BorderRadius.circular(YantingRadius.md),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -316,10 +327,7 @@ class _CoreInsights extends StatelessWidget {
kind: _kindBadge(asString(point['kind'])),
),
const SizedBox(height: WiseSpacing.x1),
Text(
asString(point['text']),
style: Theme.of(context).textTheme.bodyMedium,
),
Text(asString(point['text']), style: YantingText.body),
],
),
),
@@ -341,13 +349,10 @@ class _SourceCompliance extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (asString(payload['source_note']).isNotEmpty)
Text(
asString(payload['source_note']),
style: Theme.of(context).textTheme.bodyMedium,
),
Text(asString(payload['source_note']), style: YantingText.body),
if (institution != null) ...[
const SizedBox(height: WiseSpacing.x4),
Text('发布机构', style: Theme.of(context).textTheme.titleMedium),
Text('发布机构', style: YantingText.cardTitle.copyWith(fontSize: 17)),
const SizedBox(height: WiseSpacing.x2),
_InfoLine(label: '机构名称', value: institution.nameCn),
if (institution.nameEn.isNotEmpty)
@@ -373,24 +378,20 @@ class _SourceCompliance extends StatelessWidget {
],
if (asString(payload['copyright_cn']).isNotEmpty) ...[
const SizedBox(height: WiseSpacing.x4),
Text(
asString(payload['copyright_cn']),
style: Theme.of(context).textTheme.bodySmall,
),
Text(asString(payload['copyright_cn']), style: YantingText.meta),
],
const SizedBox(height: WiseSpacing.x3),
DecoratedBox(
decoration: BoxDecoration(
color: const Color(0x109A6500),
borderRadius: BorderRadius.circular(WiseRadius.sm),
color: YantingColors.background,
border: Border.all(color: YantingColors.border),
borderRadius: BorderRadius.circular(YantingRadius.md),
),
child: Padding(
padding: const EdgeInsets.all(WiseSpacing.x3),
child: Text(
asString(payload['disclaimer'], '本内容为公开/授权研报的结构化解读,不构成投资建议。'),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: WiseColors.warning),
style: YantingText.meta.copyWith(color: YantingColors.warning),
),
),
),
@@ -415,12 +416,12 @@ class _InfoLine extends StatelessWidget {
children: [
Text(
label,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: WiseColors.ink700),
style: YantingText.badge.copyWith(
color: YantingColors.mutedForeground,
),
),
const SizedBox(height: WiseSpacing.x1),
Text(value, style: Theme.of(context).textTheme.bodyMedium),
Text(value, style: YantingText.body),
],
),
);
@@ -478,14 +479,12 @@ class _InstitutionModule extends StatelessWidget {
final name = asString(payload['name_cn'], report?.institution.nameCn ?? '');
return Row(
children: [
const Icon(Icons.account_balance_outlined, color: WiseColors.primary),
const Icon(AppIcons.bank, color: YantingColors.foreground),
const SizedBox(width: WiseSpacing.x2),
Expanded(
child: Text(name, style: Theme.of(context).textTheme.bodyMedium),
),
Expanded(child: Text(name, style: YantingText.body)),
Text(
'${asInt(payload['report_count'], report?.institution.reportCount ?? 0)}',
style: Theme.of(context).textTheme.bodySmall,
style: YantingText.meta,
),
],
);
@@ -509,19 +508,15 @@ class _SectionsModule extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (summary.isNotEmpty)
Text(summary, style: Theme.of(context).textTheme.bodyMedium),
if (summary.isNotEmpty) Text(summary, style: YantingText.body),
for (final section in sections) ...[
const SizedBox(height: WiseSpacing.x3),
Text(
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,
),
Text(asString(section['body']), style: YantingText.body),
],
],
);
@@ -542,39 +537,40 @@ class _KeyDataModule extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final row in rows)
Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x4),
Container(
margin: const EdgeInsets.only(bottom: WiseSpacing.x3),
padding: const EdgeInsets.all(WiseSpacing.x3),
decoration: BoxDecoration(
color: YantingColors.secondary,
borderRadius: BorderRadius.circular(YantingRadius.md),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
asString(row['metric']),
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(asString(row['metric']), style: YantingText.meta),
const SizedBox(height: 6),
Text(
asString(row['judgment'], asString(row['importance'])),
style: Theme.of(context).textTheme.bodyMedium,
style: YantingText.body.copyWith(
color: YantingColors.foreground,
),
),
],
if (asString(row['importance']).isNotEmpty &&
asString(row['importance']) !=
asString(row['judgment'])) ...[
const SizedBox(height: WiseSpacing.x1),
),
),
const SizedBox(width: WiseSpacing.x2),
Text(
asString(row['importance']),
style: Theme.of(context).textTheme.bodySmall,
_valueWithUnit(row),
textAlign: TextAlign.right,
style: YantingText.cardTitle.copyWith(
fontSize: 17,
fontFeatures: YantingTypographyFeatures.tabularNums,
),
),
],
],
),
),
@@ -596,33 +592,77 @@ class _TimelineModule extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final event in events)
Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x4),
for (var index = 0; index < events.length; index++)
_TimelineEntry(
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) {
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
children: [
Container(
width: 9,
height: 9,
margin: const EdgeInsets.only(top: 6),
decoration: const BoxDecoration(
color: YantingColors.primary,
shape: BoxShape.circle,
),
),
if (!isLast)
Expanded(
child: Container(
width: 1,
margin: const EdgeInsets.symmetric(vertical: 4),
color: YantingColors.border,
),
),
],
),
const SizedBox(width: WiseSpacing.x2),
Expanded(
child: Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x3),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (asString(event['date']).isNotEmpty)
Text(
asString(event['date']),
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: WiseColors.primary),
style: YantingText.meta.copyWith(
color: YantingColors.foreground,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: WiseSpacing.x1),
Text(
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,
),
Text(asString(event['impact']), style: YantingText.body),
],
),
),
),
],
),
);
}
}
@@ -642,21 +682,15 @@ class _StudyGuideModule extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (asString(payload['intro_cn']).isNotEmpty)
Text(
asString(payload['intro_cn']),
style: Theme.of(context).textTheme.bodyMedium,
),
Text(asString(payload['intro_cn']), style: YantingText.body),
for (final item in faqs)
ExpansionTile(
tilePadding: EdgeInsets.zero,
title: Text(asString(item['question'])),
title: Text(asString(item['question']), style: YantingText.body),
children: [
Align(
alignment: Alignment.centerLeft,
child: Text(
asString(item['answer']),
style: Theme.of(context).textTheme.bodyMedium,
),
child: Text(asString(item['answer']), style: YantingText.body),
),
],
),
@@ -694,7 +728,7 @@ class _StructureGraphModule extends StatelessWidget {
children: [
Text(
asString(payload['root']),
style: Theme.of(context).textTheme.titleMedium,
style: YantingText.cardTitle.copyWith(fontSize: 17),
),
const SizedBox(height: WiseSpacing.x3),
for (final node in nodes)
@@ -705,16 +739,13 @@ class _StructureGraphModule extends StatelessWidget {
children: [
Text(
asString(node['label']),
style: Theme.of(context).textTheme.titleMedium,
style: YantingText.cardTitle.copyWith(fontSize: 17),
),
const SizedBox(height: WiseSpacing.x1),
for (final child in asStringList(node['children']))
Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x1),
child: Text(
child,
style: Theme.of(context).textTheme.bodyMedium,
),
child: Text(child, style: YantingText.body),
),
],
),
@@ -745,12 +776,12 @@ class _RelatedSourcesModule extends StatelessWidget {
children: [
Text(
asString(item['title']),
style: Theme.of(context).textTheme.titleMedium,
style: YantingText.cardTitle.copyWith(fontSize: 17),
),
const SizedBox(height: WiseSpacing.x1),
Text(
asString(item['summary_cn'], asString(item['source_name'])),
style: Theme.of(context).textTheme.bodyMedium,
style: YantingText.body,
),
],
),
@@ -784,34 +815,34 @@ class _DifferentiatedViewModule extends StatelessWidget {
children: [
Text(
asString(item['topic']),
style: Theme.of(context).textTheme.titleMedium,
style: YantingText.cardTitle.copyWith(fontSize: 17),
),
const SizedBox(height: WiseSpacing.x2),
if (asString(item['consensus_view']).isNotEmpty) ...[
Text(
'常见观点',
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: WiseColors.ink700),
style: YantingText.badge.copyWith(
color: YantingColors.mutedForeground,
),
),
const SizedBox(height: WiseSpacing.x1),
Text(
asString(item['consensus_view']),
style: Theme.of(context).textTheme.bodyMedium,
style: YantingText.body,
),
const SizedBox(height: WiseSpacing.x2),
],
if (asString(item['report_position']).isNotEmpty) ...[
Text(
'报告观点',
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: WiseColors.primary),
style: YantingText.badge.copyWith(
color: YantingColors.foreground,
),
),
const SizedBox(height: WiseSpacing.x1),
Text(
asString(item['report_position']),
style: Theme.of(context).textTheme.bodyMedium,
style: YantingText.body,
),
],
],
@@ -842,10 +873,7 @@ class _WeaknessesModule extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (asString(payload['disclaimer_cn']).isNotEmpty)
Text(
asString(payload['disclaimer_cn']),
style: Theme.of(context).textTheme.bodySmall,
),
Text(asString(payload['disclaimer_cn']), style: YantingText.meta),
for (final item in items)
Padding(
padding: const EdgeInsets.only(
@@ -857,13 +885,10 @@ class _WeaknessesModule extends StatelessWidget {
children: [
Text(
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,
),
Text(asString(item['weakness']), style: YantingText.body),
],
),
),
@@ -881,9 +906,9 @@ class _WeaknessesModule extends StatelessWidget {
children: [
Text(
'需要继续验证',
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: WiseColors.warning),
style: YantingText.badge.copyWith(
color: YantingColors.warning,
),
),
const SizedBox(height: WiseSpacing.x1),
for (final note
@@ -892,10 +917,7 @@ class _WeaknessesModule extends StatelessWidget {
: counterEvidence)
Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x1),
child: Text(
note,
style: Theme.of(context).textTheme.bodySmall,
),
child: Text(note, style: YantingText.meta),
),
],
),
@@ -922,15 +944,11 @@ class _Preview extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (headline.isNotEmpty)
Text(headline, style: Theme.of(context).textTheme.bodyMedium),
if (headline.isNotEmpty) Text(headline, style: YantingText.body),
for (final item in highlights.take(3))
Padding(
padding: const EdgeInsets.only(top: WiseSpacing.x1),
child: Text(
'$item',
style: Theme.of(context).textTheme.bodySmall,
),
child: Text('$item', style: YantingText.meta),
),
],
);
@@ -953,7 +971,7 @@ class _TextLines extends StatelessWidget {
.join('\n');
return Text(
values.isEmpty ? '该模块暂无可展示内容。' : values,
style: Theme.of(context).textTheme.bodyMedium,
style: YantingText.body,
);
}
}
+18 -18
View File
@@ -4,6 +4,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../data/api/report_data_source.dart';
import '../../data/models/models.dart';
import '../../theme/app_icons.dart';
import '../../theme/yanting_text.dart';
import '../../theme/yanting_tokens.dart';
import '../../theme/wise_tokens.dart';
import '../../widgets/app_buttons.dart';
import '../../widgets/app_card.dart';
@@ -42,10 +45,11 @@ class ReportDetailPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final retryCount = useState(0);
final detailFuture = useMemoized(
() => dataSource.reportDetail(reportId),
[dataSource, reportId, retryCount.value],
);
final detailFuture = useMemoized(() => dataSource.reportDetail(reportId), [
dataSource,
reportId,
retryCount.value,
]);
final snapshot = useFuture(detailFuture);
const registry = ModuleRendererRegistry();
@@ -102,10 +106,11 @@ class _ReportDetailContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4),
padding: const EdgeInsets.fromLTRB(WiseSpacing.x4, 4, WiseSpacing.x4, 16),
children: [
AppCard(
color: WiseColors.secondary200,
color: YantingColors.brandSoft,
borderColor: YantingColors.brandSoftBorder,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -120,12 +125,11 @@ class _ReportDetailContent extends StatelessWidget {
if (detail.hasAudio)
const AppBadge(
text: '音频',
icon: Icons.graphic_eq,
icon: AppIcons.playCircle,
kind: BadgeKind.audio,
),
AppBadge(
text: asString(detail.source['source_tier']),
icon: Icons.verified_outlined,
kind: BadgeKind.tier,
),
],
@@ -135,19 +139,16 @@ class _ReportDetailContent extends StatelessWidget {
detail.titleCn,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.headlineSmall,
style: YantingText.sectionTitle.copyWith(fontSize: 21),
),
if (detail.oneLiner.isNotEmpty) ...[
const SizedBox(height: WiseSpacing.x2),
Text(
detail.oneLiner,
style: Theme.of(context).textTheme.bodyMedium,
),
Text(detail.oneLiner, style: YantingText.body),
],
const SizedBox(height: WiseSpacing.x3),
Text(
'${detail.institution.nameCn} · ${formatDate(detail.releasedAt)}',
style: Theme.of(context).textTheme.bodySmall,
style: YantingText.meta,
),
],
),
@@ -188,17 +189,16 @@ class _ActionBar extends StatelessWidget {
Expanded(
child: AppButton(
label: '收藏',
icon: Icons.favorite_border,
icon: AppIcons.heart,
kind: AppButtonKind.ghost,
onPressed: () =>
showLoginSheet(context, reason: '登录后保存到你的收藏'),
onPressed: () => showLoginSheet(context, reason: '登录后保存到你的收藏'),
),
),
const SizedBox(width: WiseSpacing.x2),
Expanded(
child: AppButton(
label: '原文',
icon: Icons.open_in_new,
icon: AppIcons.externalLink,
kind: AppButtonKind.ghost,
onPressed: () => showOutboundSheet(context, title: detail.titleCn),
),
+24 -10
View File
@@ -9,6 +9,7 @@ import '../../routing/app_routes.dart';
import '../../theme/wise_tokens.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';
@@ -27,7 +28,13 @@ class FeedPage extends HookConsumerWidget {
final ReportDataSource dataSource;
final void Function(AudioItem item) onPlay;
final PlayerStateModel player;
final void Function(String audioId, String reportId, String title, int durationSec)? onStartModuleAudio;
final void Function(
String audioId,
String reportId,
String title,
int durationSec,
)?
onStartModuleAudio;
final VoidCallback? onToggleAudio;
final void Function(int delta)? onSeekAudio;
final VoidCallback? onSpeed;
@@ -45,19 +52,27 @@ class FeedPage extends HookConsumerWidget {
),
data: (items) {
final currentTopic = topic.value;
final topics = ['全部', ...{for (final item in items) ...item.topics}];
final topics = [
'全部',
...{for (final item in items) ...item.topics},
];
final visible = currentTopic == '全部'
? items
: items.where((item) => item.topics.contains(currentTopic)).toList();
: items
.where((item) => item.topics.contains(currentTopic))
.toList();
if (items.isEmpty) {
return const EmptyState(
title: '暂无可推荐的研报解读',
message: '稍后再来看看最新内容',
);
return const EmptyState(title: '暂无可推荐的研报解读', message: '稍后再来看看最新内容');
}
return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4),
padding: const EdgeInsets.fromLTRB(
WiseSpacing.x4,
4,
WiseSpacing.x4,
16,
),
children: [
const PageHeader(title: '研听', subtitle: '全球机构研报中文解读'),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
@@ -98,8 +113,7 @@ class FeedPage extends HookConsumerWidget {
onPlayTap: () => _playFromReport(onPlay, visible.first),
),
const SizedBox(height: WiseSpacing.x5),
Text('最新解读', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: WiseSpacing.x3),
const SectionTitle(title: '最新解读', icon: Icons.chevron_right),
for (final report in visible.skip(1)) ...[
ReportCardWidget(
report: report,
@@ -5,12 +5,16 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../data/api/report_data_source.dart';
import '../../data/models/models.dart';
import '../../routing/app_routes.dart';
import '../../theme/app_icons.dart';
import '../../theme/yanting_text.dart';
import '../../theme/yanting_tokens.dart';
import '../../theme/wise_tokens.dart';
import '../../widgets/app_buttons.dart';
import '../../widgets/app_card.dart';
import '../../widgets/badges.dart';
import '../../widgets/sheets.dart';
import '../../widgets/states.dart';
import '../../widgets/institution_card.dart';
import '../shared/report_card_widget.dart';
class InstitutionDetailPage extends HookConsumerWidget {
@@ -61,26 +65,47 @@ class _InstitutionDetailContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4),
padding: const EdgeInsets.fromLTRB(WiseSpacing.x4, 4, WiseSpacing.x4, 16),
children: [
AppCard(
color: WiseColors.secondary200,
color: YantingColors.brandSoft,
borderColor: YantingColors.brandSoftBorder,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.nameCn, style: Theme.of(context).textTheme.headlineSmall),
if (item.nameEn.isNotEmpty)
Text(item.nameEn, style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: WiseSpacing.x3),
Wrap(
spacing: WiseSpacing.x2,
runSpacing: WiseSpacing.x2,
Row(
children: [
AppBadge(
text: item.sourceTier,
icon: Icons.verified_outlined,
kind: BadgeKind.tier,
InstitutionLogo(
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,
@@ -93,28 +118,23 @@ class _InstitutionDetailContent extends StatelessWidget {
),
const SizedBox(height: WiseSpacing.x3),
if (item.introCn.isNotEmpty)
AppCard(
child: Text(item.introCn, style: Theme.of(context).textTheme.bodyMedium),
),
AppCard(child: Text(item.introCn, style: YantingText.body)),
const SizedBox(height: WiseSpacing.x3),
if (item.credibilityNote.isNotEmpty)
AppCard(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.verified_user_outlined, color: WiseColors.positive),
const Icon(AppIcons.shield, color: YantingColors.chart2),
const SizedBox(width: WiseSpacing.x2),
Expanded(
child: Text(
item.credibilityNote,
style: Theme.of(context).textTheme.bodyMedium,
),
child: Text(item.credibilityNote, style: YantingText.body),
),
],
),
),
const SizedBox(height: WiseSpacing.x5),
Text('最新研报', style: Theme.of(context).textTheme.titleMedium),
Text('最新研报', style: YantingText.sectionTitle.copyWith(fontSize: 21)),
const SizedBox(height: WiseSpacing.x3),
if (item.recentReports.isEmpty)
const EmptyState(
@@ -132,7 +152,7 @@ class _InstitutionDetailContent extends StatelessWidget {
],
AppButton(
label: '了解相关服务',
icon: Icons.open_in_new,
icon: AppIcons.externalLink,
kind: AppButtonKind.ghost,
expand: true,
onPressed: () => showOutboundSheet(context, title: item.nameCn),
@@ -3,11 +3,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../data/api/report_data_source.dart';
import '../../data/content_providers.dart';
import '../../data/models/models.dart';
import '../../routing/app_routes.dart';
import '../../theme/wise_tokens.dart';
import '../../widgets/app_card.dart';
import '../../widgets/badges.dart';
import '../../widgets/institution_card.dart';
import '../../widgets/page_header.dart';
import '../../widgets/states.dart';
class InstitutionsPage extends HookConsumerWidget {
@@ -35,18 +34,19 @@ class InstitutionsPage extends HookConsumerWidget {
);
}
return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4),
padding: const EdgeInsets.fromLTRB(
WiseSpacing.x4,
4,
WiseSpacing.x4,
16,
),
children: [
Text('研报来源机构', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: WiseSpacing.x3),
const PageHeader(title: '机构', subtitle: '可获取研报的机构'),
for (final item in sorted) ...[
InstitutionCard(
institution: item,
onTap: () => openInstitutionDetail(
context,
dataSource,
item.id,
),
onTap: () =>
openInstitutionDetail(context, dataSource, item.id),
),
const SizedBox(height: WiseSpacing.x3),
],
@@ -56,76 +56,3 @@ class InstitutionsPage extends HookConsumerWidget {
);
}
}
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),
],
),
],
),
);
}
}
+176 -48
View File
@@ -4,8 +4,13 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../data/api/report_data_source.dart';
import '../../data/content_providers.dart';
import '../../data/models/models.dart';
import '../../theme/app_icons.dart';
import '../../theme/yanting_text.dart';
import '../../theme/yanting_tokens.dart';
import '../../theme/wise_tokens.dart';
import '../../widgets/app_card.dart';
import '../../widgets/badges.dart';
import '../../widgets/page_header.dart';
import '../../widgets/states.dart';
class ListenPage extends HookConsumerWidget {
@@ -31,57 +36,24 @@ class ListenPage extends HookConsumerWidget {
icon: Icons.headphones_outlined,
);
}
final current = items.first;
return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4),
padding: const EdgeInsets.fromLTRB(
WiseSpacing.x4,
4,
WiseSpacing.x4,
16,
),
children: [
Text('全站音频解读', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: WiseSpacing.x2),
Text(
'游客可完整收听;真实音频流待后端接入。',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: WiseSpacing.x4),
for (final item in items) ...[
AppCard(
onTap: () => onPlay(item),
child: Row(
children: [
IconButton.filled(
onPressed: () => onPlay(item),
icon: const Icon(Icons.play_arrow),
style: IconButton.styleFrom(
backgroundColor: WiseColors.primary,
foregroundColor: Colors.white,
),
),
const SizedBox(width: WiseSpacing.x3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.reportTitleCn,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium,
),
Text(
'${item.institution.nameCn} · ${formatDuration(item.durationSec)}',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: WiseSpacing.x2),
LinearProgressIndicator(
value: 0,
minHeight: 4,
color: WiseColors.accent,
backgroundColor: WiseColors.border,
),
],
),
),
],
),
const PageHeader(title: '听单', subtitle: '已转音频的研报解读'),
const SectionTitle(title: '继续收听'),
_ContinueListeningCard(
item: current,
onPlay: () => onPlay(current),
),
const SectionTitle(title: '全部音频', icon: AppIcons.arrowRight),
for (final item in items.skip(1)) ...[
_AudioListCard(item: item, onPlay: () => onPlay(item)),
const SizedBox(height: WiseSpacing.x3),
],
],
@@ -90,3 +62,159 @@ class ListenPage extends HookConsumerWidget {
);
}
}
class _ContinueListeningCard extends StatelessWidget {
const _ContinueListeningCard({required this.item, required this.onPlay});
final AudioItem item;
final VoidCallback onPlay;
@override
Widget build(BuildContext context) {
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: YantingColors.foreground,
fontWeight: FontWeight.w500,
),
),
Text('·', style: YantingText.meta),
Text(
'全长 ${formatDuration(item.durationSec)}',
style: YantingText.meta,
),
],
),
const SizedBox(height: 16),
Row(
children: [
IconButton.filled(
onPressed: onPlay,
icon: const Icon(AppIcons.play),
style: IconButton.styleFrom(
backgroundColor: YantingColors.primary,
foregroundColor: YantingColors.primaryForeground,
fixedSize: const Size(48, 48),
),
),
const SizedBox(width: 13),
Expanded(
child: Column(
children: [
LinearProgressIndicator(
value: 0.42,
minHeight: 5,
borderRadius: BorderRadius.circular(YantingRadius.pill),
backgroundColor: YantingColors.border,
color: YantingColors.primary,
),
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) {
return AppCard(
padding: const EdgeInsets.all(14),
onTap: onPlay,
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: YantingColors.secondary,
borderRadius: BorderRadius.circular(YantingRadius.xl),
),
child: const Icon(
AppIcons.music,
color: YantingColors.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),
IconButton.filled(
onPressed: onPlay,
icon: const Icon(AppIcons.play),
style: IconButton.styleFrom(
backgroundColor: YantingColors.primary,
foregroundColor: YantingColors.primaryForeground,
fixedSize: const Size(44, 44),
),
),
],
),
);
}
}
+163 -46
View File
@@ -1,9 +1,13 @@
import 'package:flutter/material.dart';
import '../../data/api/report_data_source.dart';
import '../../theme/app_icons.dart';
import '../../theme/yanting_text.dart';
import '../../theme/yanting_tokens.dart';
import '../../theme/wise_tokens.dart';
import '../../widgets/app_buttons.dart';
import '../../widgets/app_card.dart';
import '../../widgets/page_header.dart';
import '../../widgets/sheets.dart';
import '../../widgets/states.dart';
@@ -15,63 +19,176 @@ class ProfilePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4),
padding: const EdgeInsets.fromLTRB(WiseSpacing.x4, 4, WiseSpacing.x4, 16),
children: [
const PageHeader(title: '我的'),
AppCard(
color: WiseColors.secondary200,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('游客', style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: WiseSpacing.x2),
Text('浏览、阅读和完整收听不需要登录。收藏、历史同步和保存听单等待 auth 接口接入。', style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: WiseSpacing.x4),
AppButton(
label: '登录后保存个人状态',
icon: Icons.login,
onPressed: () => showLoginSheet(context),
),
],
),
),
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 {
const _ProfileRow({required this.icon, required this.title, required this.subtitle, required this.onTap});
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x3),
child: AppCard(
onTap: onTap,
color: YantingColors.secondary,
child: Row(
children: [
Icon(icon, color: WiseColors.primary),
const SizedBox(width: WiseSpacing.x3),
CircleAvatar(
radius: 27,
backgroundColor: YantingColors.background,
foregroundColor: YantingColors.mutedForeground,
child: const Icon(AppIcons.user, size: 28),
),
const SizedBox(width: 15),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleMedium),
Text(subtitle, style: Theme.of(context).textTheme.bodySmall),
Text(
'未登录',
style: YantingText.cardTitle.copyWith(fontSize: 18),
),
const SizedBox(height: 5),
Text(
'登录后同步收藏、历史和听单',
style: YantingText.meta.copyWith(height: 1.5),
),
],
),
),
const Icon(Icons.chevron_right, color: WiseColors.textTertiary),
],
),
),
const SizedBox(height: WiseSpacing.x3),
AppButton(
label: '登录 / 注册',
expand: true,
onPressed: () => showLoginSheet(context),
),
const SizedBox(height: 18),
_MenuGroup(
children: [
_MenuRow(
icon: AppIcons.history,
title: '本地浏览记录',
trailing: '0 条 · 本地临时',
onTap: () => showAppToast(context, '历史同步接口待接入'),
),
],
),
const SizedBox(height: WiseSpacing.x3),
_MenuGroup(
children: [
_MenuRow(
icon: AppIcons.settings,
title: '设置',
onTap: () => showAppToast(context, '设置待接入'),
),
_MenuRow(
icon: AppIcons.fileList,
title: '用户协议',
onTap: () => showOutboundSheet(context, title: '用户协议'),
),
_MenuRow(
icon: AppIcons.shield,
title: '隐私政策',
onTap: () => showOutboundSheet(context, title: '隐私政策'),
),
],
),
const SizedBox(height: WiseSpacing.x3),
AppCard(
color: YantingColors.secondary,
onTap: () => showOutboundSheet(context, title: '相关服务'),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'了解相关服务',
style: YantingText.body.copyWith(
color: YantingColors.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),
),
],
),
),
const SizedBox(height: 22),
Text(
'研听 · 全球机构研报中文解读\n登录不阻断游客完整收听第一期 · 内容不构成投资建议',
textAlign: TextAlign.center,
style: YantingText.meta.copyWith(fontSize: 12, height: 1.7),
),
],
);
}
}
class _MenuGroup extends StatelessWidget {
const _MenuGroup({required this.children});
final List<Widget> children;
@override
Widget build(BuildContext context) {
return AppCard(
padding: EdgeInsets.zero,
child: Column(children: children),
);
}
}
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) {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
child: Row(
children: [
Icon(icon, size: 20, color: YantingColors.foreground),
const SizedBox(width: 13),
Expanded(child: Text(title, style: YantingText.body)),
if (trailing != null)
DecoratedBox(
decoration: BoxDecoration(
color: YantingColors.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),
),
),
)
else
const Icon(
AppIcons.arrowRight,
color: YantingColors.mutedForeground,
size: 20,
),
],
),
),
+31 -14
View File
@@ -6,10 +6,14 @@ 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 '../../theme/wise_tokens.dart';
import '../../widgets/app_buttons.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';
@@ -28,7 +32,12 @@ class ReportsPage extends HookConsumerWidget {
final ReportDataSource dataSource;
final void Function(AudioItem item) onPlay;
final PlayerStateModel player;
final void Function(String audioId, String reportId, String title, int durationSec)?
final void Function(
String audioId,
String reportId,
String title,
int durationSec,
)?
onStartModuleAudio;
final VoidCallback? onToggleAudio;
final void Function(int delta)? onSeekAudio;
@@ -59,23 +68,27 @@ class ReportsPage extends HookConsumerWidget {
);
return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4),
padding: const EdgeInsets.fromLTRB(
WiseSpacing.x4,
4,
WiseSpacing.x4,
16,
),
children: [
const PageHeader(title: '研报', subtitle: '全部已发布研报解读'),
TextField(
decoration: InputDecoration(
hintText: '搜索标题、机构或主题',
prefixIcon: const Icon(Icons.search),
prefixIcon: const Icon(AppIcons.search),
suffixIcon: currentQuery.isEmpty
? null
: IconButton(
onPressed: () => query.value = '',
icon: const Icon(Icons.close),
),
filled: true,
fillColor: WiseColors.surface,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(WiseRadius.pill),
borderSide: BorderSide.none,
borderRadius: BorderRadius.circular(YantingRadius.md),
borderSide: const BorderSide(color: YantingColors.input),
),
),
onChanged: (value) => query.value = value.trim(),
@@ -85,7 +98,7 @@ class ReportsPage extends HookConsumerWidget {
children: [
AppButton(
label: '筛选',
icon: Icons.tune,
icon: AppIcons.filter,
kind: AppButtonKind.ghost,
onPressed: items.isEmpty
? null
@@ -96,19 +109,23 @@ class ReportsPage extends HookConsumerWidget {
),
),
const SizedBox(width: WiseSpacing.x2),
AppButton(
label: '最新',
icon: AppIcons.sort,
kind: AppButtonKind.ghost,
onPressed: () {},
),
const SizedBox(width: WiseSpacing.x2),
AppChip(
label: '音频',
label: '音频',
selected: currentHasAudio,
onTap: () => hasAudio.value = !currentHasAudio,
),
const Spacer(),
Text('${filtered.length}', style: YantingText.meta),
],
),
const SizedBox(height: WiseSpacing.x3),
Text(
'${filtered.length} 篇研报解读${currentQuery.isNotEmpty || currentTopic.isNotEmpty || currentHasAudio ? '(已筛选)' : ''}',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: WiseSpacing.x3),
if (filtered.isEmpty)
EmptyState(
title: currentQuery.isNotEmpty ? '未找到相关研报' : '当前筛选下暂无研报',
+53 -16
View File
@@ -1,7 +1,11 @@
import 'package:flutter/material.dart';
import '../../data/models/models.dart';
import '../../theme/app_icons.dart';
import '../../theme/yanting_text.dart';
import '../../theme/yanting_tokens.dart';
import '../../theme/wise_tokens.dart';
import '../../widgets/app_buttons.dart';
import '../../widgets/app_card.dart';
import '../../widgets/badges.dart';
@@ -32,9 +36,13 @@ class ReportCardWidget extends StatelessWidget {
children: [
AppBadge(text: report.interpretationLabel, kind: BadgeKind.brand),
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)
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),
],
),
@@ -44,8 +52,8 @@ class ReportCardWidget extends StatelessWidget {
maxLines: hero ? 3 : 2,
overflow: TextOverflow.ellipsis,
style: hero
? Theme.of(context).textTheme.titleLarge
: Theme.of(context).textTheme.titleMedium,
? YantingText.sectionTitle.copyWith(fontSize: 21, height: 1.4)
: YantingText.cardTitle,
),
if (report.oneLiner.isNotEmpty) ...[
const SizedBox(height: WiseSpacing.x2),
@@ -53,31 +61,44 @@ class ReportCardWidget extends StatelessWidget {
report.oneLiner,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium,
style: YantingText.body.copyWith(
color: YantingColors.mutedForeground,
),
),
],
const SizedBox(height: WiseSpacing.x3),
Row(
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
Expanded(
child: InkWell(
InkWell(
onTap: onInstitutionTap,
child: Text(
'${report.institution.nameCn}${report.releasedAt == null ? '' : ' · ${formatDate(report.releasedAt)}'}',
report.institution.nameCn,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
style: YantingText.meta.copyWith(
color: YantingColors.foreground,
fontWeight: FontWeight.w500,
),
),
),
if (report.hasAudio)
TextButton.icon(
onPressed: onPlayTap,
icon: const Icon(Icons.play_circle_outline, size: 18),
label: const Text('听研报'),
),
if (report.releasedAt != null) ...[
const _MetaDot(),
Text(formatDate(report.releasedAt), style: YantingText.meta),
],
],
),
if (report.hasAudio) ...[
const SizedBox(height: 14),
AppButton(
label: '听研报',
icon: AppIcons.play,
kind: hero ? AppButtonKind.primary : AppButtonKind.accent,
onPressed: onPlayTap,
),
],
],
);
return hero
@@ -85,3 +106,19 @@ class ReportCardWidget extends StatelessWidget {
: AppCard(onTap: onTap, child: child);
}
}
class _MetaDot extends StatelessWidget {
const _MetaDot();
@override
Widget build(BuildContext context) {
return Container(
width: 3,
height: 3,
decoration: const BoxDecoration(
color: YantingColors.mutedForeground,
shape: BoxShape.circle,
),
);
}
}
+12 -58
View File
@@ -4,8 +4,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../routing/app_routes.dart';
import '../data/providers.dart';
import '../theme/app_icons.dart';
import '../theme/wise_tokens.dart';
import '../widgets/bottom_tab_bar.dart';
import '../widgets/mini_player.dart';
class ShellPage extends ConsumerWidget {
@@ -39,7 +39,7 @@ class ShellPage extends ConsumerWidget {
),
),
body: ColoredBox(
color: WiseColors.canvas,
color: Theme.of(context).scaffoldBackgroundColor,
child: Stack(children: [Positioned.fill(child: child)]),
),
bottomNavigationBar: SafeArea(
@@ -48,54 +48,10 @@ class ShellPage extends ConsumerWidget {
mainAxisSize: MainAxisSize.min,
children: [
MiniPlayer(player: player, onToggle: controller.toggleAudio),
Container(
height: 64,
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
decoration: const BoxDecoration(
color: WiseColors.canvas,
border: Border(
top: BorderSide(color: Color(0x11000000), width: 0.5),
),
),
child: Row(
children: List.generate(_tabs.length, (index) {
final tab = _tabs[index];
final active = index == safeIndex;
return Expanded(
child: InkWell(
onTap: () => context.go(tab.path),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
tab.icon,
size: 20,
color: active
? WiseColors.ink
: WiseColors.textTertiary,
),
const SizedBox(height: 4),
Text(
tab.label,
style:
(Theme.of(context).textTheme.labelLarge ??
const TextStyle())
.copyWith(
color: active
? WiseColors.ink
: WiseColors.textTertiary,
fontFamily: 'Inter',
fontSize: 12,
letterSpacing: 0.72,
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}),
),
BottomTabBar(
items: yantingBottomTabItems,
selectedIndex: safeIndex,
onSelected: (index) => context.go(_tabs[index].path),
),
],
),
@@ -105,17 +61,15 @@ class ShellPage extends ConsumerWidget {
}
class _TabItem {
const _TabItem({required this.label, required this.path, required this.icon});
const _TabItem({required this.path});
final String label;
final String path;
final IconData icon;
}
const List<_TabItem> _tabs = [
_TabItem(label: '推荐', path: AppRoutes.home, icon: AppIcons.sparkle),
_TabItem(label: '研报', path: AppRoutes.reports, icon: AppIcons.article),
_TabItem(label: '机构', path: AppRoutes.institutions, icon: AppIcons.bank),
_TabItem(label: '听单', path: AppRoutes.listen, icon: AppIcons.headphones),
_TabItem(label: '我的', path: AppRoutes.profile, icon: AppIcons.user),
_TabItem(path: AppRoutes.home),
_TabItem(path: AppRoutes.reports),
_TabItem(path: AppRoutes.institutions),
_TabItem(path: AppRoutes.listen),
_TabItem(path: AppRoutes.profile),
];
+41 -6
View File
@@ -1,10 +1,45 @@
import 'package:flutter/widgets.dart';
import 'package:phosphor_flutter/phosphor_flutter.dart';
import 'package:remixicon/remixicon.dart';
abstract final class AppIcons {
static const IconData sparkle = PhosphorIconsRegular.sparkle;
static const IconData article = PhosphorIconsRegular.article;
static const IconData bank = PhosphorIconsRegular.bank;
static const IconData headphones = PhosphorIconsRegular.headphones;
static const IconData user = PhosphorIconsRegular.user;
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,
};
}
}
+63 -61
View File
@@ -1,98 +1,100 @@
import 'package:flutter/material.dart';
import 'yanting_text.dart';
import 'yanting_tokens.dart';
import 'wise_tokens.dart';
ThemeData buildAppTheme() {
final scheme = ColorScheme.fromSeed(
seedColor: WiseColors.primary,
primary: WiseColors.primary,
secondary: WiseColors.secondary,
tertiary: WiseColors.accent,
surface: WiseColors.surface,
seedColor: YantingColors.primary,
primary: YantingColors.primary,
onPrimary: YantingColors.primaryForeground,
secondary: YantingColors.secondary,
onSecondary: YantingColors.secondaryForeground,
tertiary: YantingColors.link,
surface: YantingColors.card,
onSurface: YantingColors.foreground,
error: YantingColors.destructive,
outline: YantingColors.border,
);
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
fontFamily: 'Inter',
scaffoldBackgroundColor: WiseColors.canvas,
fontFamily: YantingText.fontFamily,
fontFamilyFallback: YantingText.fontFallback,
scaffoldBackgroundColor: YantingColors.background,
appBarTheme: const AppBarTheme(
backgroundColor: WiseColors.canvas,
foregroundColor: WiseColors.primary,
backgroundColor: YantingColors.background,
foregroundColor: YantingColors.foreground,
elevation: 0,
centerTitle: false,
titleTextStyle: TextStyle(
color: WiseColors.primary,
fontSize: 22,
fontWeight: FontWeight.w800,
),
titleTextStyle: YantingText.sectionTitle,
),
cardTheme: const CardThemeData(
color: WiseColors.surface,
color: YantingColors.card,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(WiseRadius.md)),
side: BorderSide(color: YantingColors.border),
),
),
dividerTheme: const DividerThemeData(
color: YantingColors.border,
thickness: 1,
space: 1,
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: YantingColors.background,
hintStyle: YantingText.body.copyWith(
color: YantingColors.mutedForeground,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(YantingRadius.md),
borderSide: const BorderSide(color: YantingColors.input),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(YantingRadius.md),
borderSide: const BorderSide(color: YantingColors.input),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(YantingRadius.md),
borderSide: const BorderSide(color: YantingColors.foreground),
),
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: WiseColors.surface,
indicatorColor: WiseColors.secondary200,
backgroundColor: YantingColors.background,
indicatorColor: Colors.transparent,
labelTextStyle: WidgetStateProperty.resolveWith(
(states) => TextStyle(
(states) => YantingText.meta.copyWith(
color: states.contains(WidgetState.selected)
? WiseColors.primary
: WiseColors.textTertiary,
? YantingColors.foreground
: YantingColors.mutedForeground,
fontSize: 11,
fontWeight: FontWeight.w700,
fontWeight: states.contains(WidgetState.selected)
? FontWeight.w600
: FontWeight.w400,
),
),
iconTheme: WidgetStateProperty.resolveWith(
(states) => IconThemeData(
color: states.contains(WidgetState.selected)
? WiseColors.primary
: WiseColors.textTertiary,
? YantingColors.foreground
: YantingColors.mutedForeground,
),
),
),
textTheme: const TextTheme(
headlineSmall: TextStyle(
color: WiseColors.ink,
fontSize: 26,
height: 1.18,
fontWeight: FontWeight.w800,
),
titleLarge: TextStyle(
color: WiseColors.ink,
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,
),
headlineSmall: YantingText.appTitle,
titleLarge: YantingText.sectionTitle,
titleMedium: YantingText.cardTitle,
bodyLarge: YantingText.body,
bodyMedium: YantingText.sub,
bodySmall: YantingText.meta,
labelLarge: YantingText.chip,
labelSmall: YantingText.badge,
),
);
}
+31 -43
View File
@@ -1,39 +1,41 @@
import 'package:flutter/material.dart';
import 'yanting_tokens.dart';
final class WiseColors {
static const primary = Color(0xFF163300);
static const primarySoft = Color(0xFF1F4708);
static const secondary = Color(0xFF9FE870);
static const secondary200 = Color(0xFFE2F6D5);
static const accent = Color(0xFF00A2DD);
static const canvas = Color(0xFFF4F6F3);
static const ink = Color(0xFF0E0F0C);
static const ink700 = Color(0xFF454745);
static const textSecondary = Color(0xFF5D7079);
static const textTertiary = Color(0xFF768E9C);
static const surface = Colors.white;
static const border = Color(0x1A000000);
static const positive = Color(0xFF008026);
static const warning = Color(0xFF9A6500);
static const negative = Color(0xFFCF2929);
static const primary = YantingColors.foreground;
static const primarySoft = YantingColors.primaryForeground;
static const secondary = YantingColors.primary;
static const secondary200 = YantingColors.brandSoft;
static const accent = YantingColors.link;
static const canvas = YantingColors.background;
static const ink = YantingColors.foreground;
static const ink700 = YantingColors.secondaryForeground;
static const textSecondary = YantingColors.mutedForeground;
static const textTertiary = YantingColors.mutedForeground;
static const surface = YantingColors.card;
static const border = YantingColors.border;
static const positive = YantingColors.chart2;
static const warning = Color(0xFF9A6A00);
static const negative = YantingColors.destructive;
}
final class WiseSpacing {
static const x1 = 4.0;
static const x2 = 8.0;
static const x3 = 12.0;
static const x4 = 16.0;
static const x5 = 20.0;
static const x6 = 24.0;
static const x8 = 32.0;
static const x10 = 40.0;
static const x1 = YantingSpacing.x1;
static const x2 = YantingSpacing.x2;
static const x3 = YantingSpacing.cardGap;
static const x4 = YantingSpacing.screenX;
static const x5 = YantingSpacing.screenX;
static const x6 = YantingSpacing.x6;
static const x8 = YantingSpacing.x8;
static const x10 = YantingSpacing.x10;
}
final class WiseRadius {
static const sm = 10.0;
static const md = 16.0;
static const sm = YantingRadius.sm;
static const md = YantingRadius.xl;
static const lg = 24.0;
static const pill = 999.0;
static const pill = YantingRadius.pill;
}
final class WiseMotion {
@@ -43,26 +45,12 @@ final class WiseMotion {
}
final class WiseShadows {
static const card = [
BoxShadow(
color: Color(0x14000000),
blurRadius: 20,
offset: Offset(0, 6),
),
];
static const elevated = [
BoxShadow(
color: Color(0x24000000),
blurRadius: 32,
offset: Offset(0, 10),
),
];
static const card = <BoxShadow>[];
static const elevated = <BoxShadow>[];
}
const wiseFontStack = [
'Inter',
'-apple-system',
'BlinkMacSystemFont',
'DM Sans',
'PingFang SC',
'Microsoft YaHei',
'Helvetica Neue',
+106
View File
@@ -0,0 +1,106 @@
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(
color: YantingColors.foreground,
fontFamily: fontFamily,
fontFamilyFallback: fontFallback,
fontSize: 34,
height: 1.15,
letterSpacing: 0,
fontWeight: FontWeight.w800,
);
static const sectionTitle = TextStyle(
color: YantingColors.foreground,
fontFamily: fontFamily,
fontFamilyFallback: fontFallback,
fontSize: 22,
height: 1.2,
letterSpacing: 0,
fontWeight: FontWeight.w700,
);
static const cardTitle = TextStyle(
color: YantingColors.foreground,
fontFamily: fontFamily,
fontFamilyFallback: fontFallback,
fontSize: 19,
height: 1.4,
letterSpacing: 0,
fontWeight: FontWeight.w600,
);
static const listTitle = TextStyle(
color: YantingColors.foreground,
fontFamily: fontFamily,
fontFamilyFallback: fontFallback,
fontSize: 16.5,
height: 1.45,
letterSpacing: 0,
fontWeight: FontWeight.w600,
);
static const body = TextStyle(
color: YantingColors.foreground,
fontFamily: fontFamily,
fontFamilyFallback: fontFallback,
fontSize: 15,
height: 1.6,
letterSpacing: 0,
fontWeight: FontWeight.w400,
);
static const sub = TextStyle(
color: YantingColors.mutedForeground,
fontFamily: fontFamily,
fontFamilyFallback: fontFallback,
fontSize: 15,
height: 1.45,
letterSpacing: 0,
fontWeight: FontWeight.w400,
);
static const meta = TextStyle(
color: YantingColors.mutedForeground,
fontFamily: fontFamily,
fontFamilyFallback: fontFallback,
fontSize: 13,
height: 1.5,
letterSpacing: 0,
fontWeight: FontWeight.w400,
fontFeatures: YantingTypographyFeatures.tabularNums,
);
static const chip = TextStyle(
color: YantingColors.secondaryForeground,
fontFamily: fontFamily,
fontFamilyFallback: fontFallback,
fontSize: 15,
height: 1.2,
letterSpacing: 0,
fontWeight: FontWeight.w500,
);
static const badge = TextStyle(
color: YantingColors.mutedForeground,
fontFamily: fontFamily,
fontFamilyFallback: fontFallback,
fontSize: 12,
height: 1.5,
letterSpacing: 0,
fontWeight: FontWeight.w500,
fontFeatures: YantingTypographyFeatures.tabularNums,
);
}
+54
View File
@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
abstract final class YantingColors {
static const background = Color(0xFFFFFFFF);
static const foreground = Color(0xFF171717);
static const card = Color(0xFFFFFFFF);
static const primary = Color(0xFFA3E635);
static const primaryForeground = Color(0xFF3F6212);
static const secondary = Color(0xFFF4F4F5);
static const secondaryForeground = Color(0xFF27272A);
static const muted = Color(0xFFF5F5F5);
static const mutedForeground = Color(0xFF737373);
static const border = Color(0xFFE5E5E5);
static const input = Color(0xFFE5E5E5);
static const destructive = Color(0xFFEF4444);
static const warning = Color(0xFF9A6A00);
static const chart2 = Color(0xFF84CC16);
static const brandSoft = Color(0xFFEEFBD8);
static const brandSoftBorder = Color(0xFFD6F5A8);
static const link = Color(0xFF2563EB);
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()];
}
+31 -8
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../theme/wise_tokens.dart';
import '../theme/yanting_text.dart';
import '../theme/yanting_tokens.dart';
class AppButton extends StatelessWidget {
const AppButton({
@@ -21,10 +22,26 @@ class AppButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colors = switch (kind) {
AppButtonKind.primary => (WiseColors.secondary, WiseColors.primary),
AppButtonKind.dark => (WiseColors.primary, Colors.white),
AppButtonKind.accent => (WiseColors.accent, Colors.white),
AppButtonKind.ghost => (WiseColors.surface, WiseColors.primary),
AppButtonKind.primary => (
YantingColors.primary,
YantingColors.primaryForeground,
Colors.transparent,
),
AppButtonKind.dark => (
YantingColors.foreground,
YantingColors.background,
Colors.transparent,
),
AppButtonKind.accent => (
YantingColors.brandSoft,
YantingColors.primaryForeground,
Colors.transparent,
),
AppButtonKind.ghost => (
YantingColors.background,
YantingColors.foreground,
YantingColors.border,
),
};
final child = FilledButton.icon(
onPressed: onPressed,
@@ -33,10 +50,16 @@ class AppButton extends StatelessWidget {
style: FilledButton.styleFrom(
backgroundColor: colors.$1,
foregroundColor: colors.$2,
disabledBackgroundColor: WiseColors.border,
disabledForegroundColor: WiseColors.textTertiary,
disabledBackgroundColor: YantingColors.border,
disabledForegroundColor: YantingColors.mutedForeground,
minimumSize: Size(expand ? double.infinity : 0, 44),
shape: const StadiumBorder(),
textStyle: YantingText.body.copyWith(fontWeight: FontWeight.w600),
side: colors.$3 == Colors.transparent
? BorderSide.none
: BorderSide(color: colors.$3),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(YantingRadius.md),
),
),
);
return expand ? SizedBox(width: double.infinity, child: child) : child;
+24 -10
View File
@@ -1,13 +1,14 @@
import 'package:flutter/material.dart';
import '../theme/wise_tokens.dart';
import '../theme/yanting_tokens.dart';
class AppCard extends StatelessWidget {
const AppCard({
required this.child,
this.onTap,
this.padding = const EdgeInsets.all(WiseSpacing.x4),
this.color = WiseColors.surface,
this.padding = const EdgeInsets.all(YantingSpacing.cardPadding),
this.color = YantingColors.card,
this.borderColor = YantingColors.border,
super.key,
});
@@ -15,22 +16,34 @@ class AppCard extends StatelessWidget {
final VoidCallback? onTap;
final EdgeInsetsGeometry padding;
final Color color;
final Color borderColor;
@override
Widget build(BuildContext context) {
final decoration = BoxDecoration(
color: color,
border: Border.all(color: borderColor),
borderRadius: BorderRadius.circular(YantingRadius.xl),
);
final content = DecoratedBox(
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(WiseRadius.md),
boxShadow: WiseShadows.card,
border: Border.all(color: borderColor),
borderRadius: BorderRadius.circular(YantingRadius.xl),
),
child: Padding(padding: padding, child: child),
);
if (onTap == null) return content;
return InkWell(
borderRadius: BorderRadius.circular(WiseRadius.md),
return Material(
color: Colors.transparent,
child: Ink(
decoration: decoration,
child: InkWell(
borderRadius: BorderRadius.circular(YantingRadius.xl),
onTap: onTap,
child: content,
child: Padding(padding: padding, child: child),
),
),
);
}
}
@@ -45,8 +58,9 @@ class HeroReportCard extends StatelessWidget {
Widget build(BuildContext context) {
return AppCard(
onTap: onTap,
color: WiseColors.secondary200,
padding: const EdgeInsets.all(WiseSpacing.x5),
color: YantingColors.brandSoft,
borderColor: YantingColors.brandSoftBorder,
padding: const EdgeInsets.all(YantingSpacing.cardPadding),
child: child,
);
}
+61 -18
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../theme/wise_tokens.dart';
import '../theme/yanting_text.dart';
import '../theme/yanting_tokens.dart';
class AppBadge extends StatelessWidget {
const AppBadge({
@@ -17,19 +18,42 @@ class AppBadge extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colors = switch (kind) {
BadgeKind.brand => (WiseColors.secondary200, WiseColors.primarySoft),
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),
BadgeKind.brand => (
YantingColors.primary,
YantingColors.primaryForeground,
Colors.transparent,
),
BadgeKind.audio => (
YantingColors.secondary,
YantingColors.secondaryForeground,
Colors.transparent,
),
BadgeKind.tier => (
YantingColors.background,
YantingColors.mutedForeground,
YantingColors.border,
),
BadgeKind.warning => (
YantingColors.background,
YantingColors.destructive,
YantingColors.border,
),
BadgeKind.neutral => (
YantingColors.secondary,
YantingColors.secondaryForeground,
Colors.transparent,
),
};
return DecoratedBox(
decoration: BoxDecoration(
color: colors.$1,
borderRadius: BorderRadius.circular(WiseRadius.pill),
border: colors.$3 == Colors.transparent
? null
: Border.all(color: colors.$3),
borderRadius: BorderRadius.circular(YantingRadius.sm),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
@@ -39,7 +63,9 @@ class AppBadge extends StatelessWidget {
],
Text(
text,
style: Theme.of(context).textTheme.labelSmall?.copyWith(color: colors.$2),
style:
(Theme.of(context).textTheme.labelSmall ?? YantingText.badge)
.copyWith(color: colors.$2, letterSpacing: 0),
),
],
),
@@ -64,16 +90,33 @@ class AppChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ActionChip(
onPressed: onTap,
label: Text(label),
labelStyle: TextStyle(
color: selected ? Colors.white : WiseColors.textSecondary,
fontWeight: FontWeight.w700,
final background = selected
? YantingColors.foreground
: YantingColors.secondary;
final foreground = selected
? YantingColors.background
: YantingColors.secondaryForeground;
return Material(
color: Colors.transparent,
child: Ink(
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(YantingRadius.pill),
),
child: InkWell(
borderRadius: BorderRadius.circular(YantingRadius.pill),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 17, vertical: 9),
child: Text(
label,
style:
(Theme.of(context).textTheme.labelLarge ?? YantingText.chip)
.copyWith(color: foreground, letterSpacing: 0),
),
),
),
),
backgroundColor: selected ? WiseColors.primary : WiseColors.surface,
side: const BorderSide(color: WiseColors.border),
shape: const StadiumBorder(),
);
}
}
+125
View File
@@ -0,0 +1,125 @@
import 'package:flutter/material.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) {
return DecoratedBox(
decoration: const BoxDecoration(
color: YantingColors.background,
border: Border(top: BorderSide(color: YantingColors.border)),
),
child: SizedBox(
height: YantingSpacing.tabBarHeight,
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 color = selected
? YantingColors.foreground
: YantingColors.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,
),
];
+140
View File
@@ -0,0 +1,140 @@
import 'package:flutter/material.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 initials = institution.nameCn.isEmpty
? ''
: institution.nameCn.characters.take(2).toString();
return AppCard(
onTap: onTap,
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InstitutionLogo(
logoUrl: institution.logoUrl,
initials: initials,
size: 52,
),
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,
),
),
if (institution.nameEn.isNotEmpty) ...[
const SizedBox(height: 3),
Text(
institution.nameEn,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: YantingText.meta,
),
],
const SizedBox(height: 11),
Wrap(
spacing: 8,
runSpacing: 8,
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: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${institution.reportCount}',
style: YantingText.sectionTitle.copyWith(
fontSize: 21,
fontFeatures: YantingTypographyFeatures.tabularNums,
),
),
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 fallback = DecoratedBox(
decoration: BoxDecoration(
color: YantingColors.secondary,
border: Border.all(color: YantingColors.border),
borderRadius: BorderRadius.circular(size * 0.25),
),
child: Center(
child: Text(
initials,
style: YantingText.meta.copyWith(
color: YantingColors.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,
),
);
}
}
+170 -63
View File
@@ -1,6 +1,9 @@
import 'package:flutter/material.dart';
import '../data/models/models.dart';
import '../theme/app_icons.dart';
import '../theme/yanting_text.dart';
import '../theme/yanting_tokens.dart';
import '../theme/wise_tokens.dart';
import 'app_card.dart';
@@ -47,11 +50,7 @@ class PlayerStateModel {
}
class MiniPlayer extends StatelessWidget {
const MiniPlayer({
required this.player,
required this.onToggle,
super.key,
});
const MiniPlayer({required this.player, required this.onToggle, super.key});
final PlayerStateModel player;
final VoidCallback onToggle;
@@ -59,55 +58,88 @@ class MiniPlayer extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (!player.hasAudio) return const SizedBox.shrink();
final ratio = player.durationSec == 0 ? 0.0 : player.positionSec / player.durationSec;
return Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: AppCard(
padding: const EdgeInsets.all(12),
color: WiseColors.primary,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
final ratio = player.durationSec == 0
? 0.0
: player.positionSec / player.durationSec;
return DecoratedBox(
decoration: const BoxDecoration(
color: YantingColors.secondary,
border: Border(top: BorderSide(color: YantingColors.border)),
),
child: Stack(
children: [
Row(
children: [
IconButton.filled(
onPressed: onToggle,
icon: Icon(player.playing ? Icons.pause : Icons.play_arrow),
style: IconButton.styleFrom(
backgroundColor: WiseColors.secondary,
foregroundColor: WiseColors.primary,
Positioned(
top: 0,
left: 0,
right: 0,
child: Align(
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: ratio.clamp(0, 1),
child: const SizedBox(
height: 2,
child: ColoredBox(color: YantingColors.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: YantingColors.primary,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
AppIcons.disc,
color: YantingColors.primaryForeground,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
player.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w800),
style: YantingText.meta.copyWith(
color: YantingColors.foreground,
fontWeight: FontWeight.w600,
fontFeatures: null,
),
),
const SizedBox(height: 2),
Text(
'${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),
),
],
),
),
IconButton(
onPressed: onToggle,
icon: Icon(
player.playing ? AppIcons.pause : AppIcons.playCircle,
size: player.playing ? 24 : 28,
),
color: YantingColors.foreground,
visualDensity: VisualDensity.compact,
),
],
),
),
],
),
const SizedBox(height: WiseSpacing.x2),
LinearProgressIndicator(
value: ratio.clamp(0, 1),
minHeight: 4,
backgroundColor: const Color(0x33FFFFFF),
color: WiseColors.secondary,
),
],
),
),
);
}
}
@@ -138,51 +170,126 @@ class PlayerCard extends StatelessWidget {
final position = active ? player.positionSec : 0;
final ratio = durationSec == 0 ? 0.0 : position / durationSec;
return AppCard(
color: WiseColors.secondary200,
color: YantingColors.secondary,
borderColor: YantingColors.border,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('音频解读', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: WiseSpacing.x2),
Text(title, style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: WiseSpacing.x3),
Text('音频解读', style: YantingText.listTitle),
const SizedBox(height: 6),
Text(
title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: YantingText.meta.copyWith(fontSize: 12.5),
),
const SizedBox(height: 16),
LinearProgressIndicator(
value: ratio.clamp(0, 1),
minHeight: 6,
backgroundColor: Colors.white,
color: WiseColors.accent,
minHeight: 4,
borderRadius: BorderRadius.circular(YantingRadius.pill),
backgroundColor: YantingColors.border,
color: YantingColors.primary,
),
const SizedBox(height: WiseSpacing.x2),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(formatDuration(position), style: Theme.of(context).textTheme.bodySmall),
Text(formatDuration(durationSec), style: Theme.of(context).textTheme.bodySmall),
],
),
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(position),
style: YantingText.meta.copyWith(fontSize: 11),
),
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),
IconButton.filled(
onPressed: active ? onToggle : onStart,
icon: Icon(
active && player.playing
? AppIcons.pause
: AppIcons.play,
size: 28,
),
style: IconButton.styleFrom(
backgroundColor: YantingColors.primary,
foregroundColor: YantingColors.primaryForeground,
fixedSize: const Size(56, 56),
),
),
const SizedBox(width: 26),
_SkipButton(label: '+15', onPressed: () => onSeek(15)),
],
),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: onSpeed,
style: TextButton.styleFrom(
backgroundColor: YantingColors.background,
foregroundColor: YantingColors.foreground,
side: const BorderSide(color: YantingColors.border),
shape: const StadiumBorder(),
padding: const EdgeInsets.symmetric(horizontal: 12),
),
child: Text(
'${player.speed.toStringAsFixed(1)}x',
style: YantingText.meta.copyWith(
color: YantingColors.foreground,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
const SizedBox(height: 16),
Text(
'真实音频流待 /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) {
return TextButton(
onPressed: onPressed,
style: TextButton.styleFrom(
foregroundColor: YantingColors.foreground,
minimumSize: const Size(40, 40),
padding: EdgeInsets.zero,
),
child: Text(
label,
style: YantingText.meta.copyWith(
color: YantingColors.foreground,
fontWeight: FontWeight.w600,
),
),
);
}
}
+59
View File
@@ -0,0 +1,59 @@
import 'package:flutter/material.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) {
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: YantingColors.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) {
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: YantingColors.mutedForeground),
],
],
),
);
}
}
+3
View File
@@ -1,6 +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';
+8
View File
@@ -221,6 +221,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.0"
remixicon:
dependency: "direct main"
description:
name: remixicon
sha256: "4b8e334b78b0fbf05fb7abe1b48f3c3df9e4a11ab767e3f3e7f1cc36dc1e046e"
url: "https://pub.dev"
source: hosted
version: "4.9.3"
riverpod:
dependency: transitive
description:
+1
View File
@@ -40,6 +40,7 @@ dependencies:
go_router: ^16.2.4
hooks_riverpod: ^2.6.1
phosphor_flutter: ^2.1.0
remixicon: ^4.9.3
dev_dependencies:
flutter_test: