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