From 9727b906c65c59adb07c599982a484aafe655c2a Mon Sep 17 00:00:00 2001 From: jingyun <> Date: Fri, 5 Jun 2026 11:12:55 +0800 Subject: [PATCH] =?UTF-8?q?fix=EF=BC=9A=E6=8C=89html=E7=9A=84=E5=81=87?= =?UTF-8?q?=E6=95=B0=E6=8D=AEdemo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/data/api/mock_report_data_source.dart | 493 ++++++++++++++++++ lib/data/models/models.dart | 22 +- lib/data/providers.dart | 9 +- .../detail/modules/renderer_registry.dart | 338 ++++++------ lib/features/detail/report_detail_page.dart | 64 +-- lib/features/feed/feed_page.dart | 34 +- .../institutions/institution_detail_page.dart | 82 +-- .../institutions/institutions_page.dart | 95 +--- lib/features/listen/listen_page.dart | 224 ++++++-- lib/features/profile/profile_page.dart | 187 +++++-- lib/features/reports/reports_page.dart | 53 +- lib/features/shared/report_card_widget.dart | 77 ++- lib/features/shell_page.dart | 70 +-- lib/theme/app_icons.dart | 47 +- lib/theme/app_theme.dart | 124 ++--- lib/theme/wise_tokens.dart | 74 ++- lib/theme/yanting_text.dart | 106 ++++ lib/theme/yanting_tokens.dart | 54 ++ lib/widgets/app_buttons.dart | 39 +- lib/widgets/app_card.dart | 36 +- lib/widgets/badges.dart | 79 ++- lib/widgets/bottom_tab_bar.dart | 125 +++++ lib/widgets/institution_card.dart | 140 +++++ lib/widgets/mini_player.dart | 227 +++++--- lib/widgets/page_header.dart | 59 +++ lib/widgets/widgets.dart | 3 + pubspec.lock | 8 + pubspec.yaml | 1 + 28 files changed, 2159 insertions(+), 711 deletions(-) create mode 100644 lib/data/api/mock_report_data_source.dart create mode 100644 lib/theme/yanting_text.dart create mode 100644 lib/theme/yanting_tokens.dart create mode 100644 lib/widgets/bottom_tab_bar.dart create mode 100644 lib/widgets/institution_card.dart create mode 100644 lib/widgets/page_header.dart diff --git a/lib/data/api/mock_report_data_source.dart b/lib/data/api/mock_report_data_source.dart new file mode 100644 index 0000000..c26b235 --- /dev/null +++ b/lib/data/api/mock_report_data_source.dart @@ -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 _details = [_gold, _bis, _iea, _worldBank]; + + static final Map _detailById = { + for (final detail in _details) detail.id: detail, + }; + + static final Map _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 _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> recommended() async => [ + _gold.asCard(), + _bis.asCard(), + _iea.asCard(), + ]; + + @override + Future> reports() async => + _details.map((d) => d.asCard()).toList(); + + @override + Future> institutions() async { + return _institutionDetails.values.toList() + ..sort((a, b) => b.reportCount.compareTo(a.reportCount)); + } + + @override + Future institutionDetail(String institutionId) async { + return _institutionDetails[institutionId] ?? _institutionDetails['wgc']!; + } + + @override + Future> listen() async { + return [ + _audioFromReport(_gold, 860), + _audioFromReport(_bis, 1040), + _audioFromReport(_iea, 1120), + _audioFromReport(_worldBank, 980), + ]; + } + + @override + Future reportDetail(String reportId) async { + return _detailById[reportId] ?? _gold; + } + + @override + Future 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 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 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', + ); + } +} diff --git a/lib/data/models/models.dart b/lib/data/models/models.dart index bafbaa5..6674c02 100644 --- a/lib/data/models/models.dart +++ b/lib/data/models/models.dart @@ -18,12 +18,16 @@ List asStringList(Object? value) { JsonMap asMap(Object? value) { if (value is Map) return value; - if (value is Map) return value.map((key, val) => MapEntry(key.toString(), val)); + if (value is Map) { + return value.map((key, val) => MapEntry(key.toString(), val)); + } return const {}; } List asMapList(Object? value) { - if (value is List) return value.map(asMap).where((item) => item.isNotEmpty).toList(); + if (value is List) { + return value.map(asMap).where((item) => item.isNotEmpty).toList(); + } return const []; } @@ -46,6 +50,7 @@ class Institution { required this.id, required this.nameCn, this.nameEn = '', + this.logoUrl = '', this.institutionType = '', this.sourceTier = '', this.websiteUrl = '', @@ -60,6 +65,7 @@ class Institution { final String id; final String nameCn; final String nameEn; + final String logoUrl; final String institutionType; final String sourceTier; final String websiteUrl; @@ -75,6 +81,7 @@ class Institution { id: asString(json['institution_id']), nameCn: asString(json['name_cn']), nameEn: asString(json['name_en']), + logoUrl: asString(json['logo_url']), institutionType: asString(json['institution_type']), sourceTier: asString(json['source_tier']), websiteUrl: asString(json['website_url']), @@ -83,9 +90,9 @@ class Institution { latestReportAt: json['latest_report_at']?.toString(), credibilityNote: asString(json['credibility_note']), introCn: asString(json['intro_cn']), - recentReports: asMapList(json['recent_reports']) - .map(ReportCardModel.fromJson) - .toList(), + recentReports: asMapList( + json['recent_reports'], + ).map(ReportCardModel.fromJson).toList(), ); } } @@ -161,7 +168,10 @@ class AudioItem { audioId: asString(json['audio_id']), reportId: asString(json['report_id']), titleCn: asString(json['title_cn']), - reportTitleCn: asString(json['report_title_cn'], asString(json['title_cn'])), + reportTitleCn: asString( + json['report_title_cn'], + asString(json['title_cn']), + ), durationSec: asInt(json['duration_sec']), institution: Institution.fromJson(asMap(json['institution'])), releasedAt: json['released_at']?.toString(), diff --git a/lib/data/providers.dart b/lib/data/providers.dart index a3c3525..fffb266 100644 --- a/lib/data/providers.dart +++ b/lib/data/providers.dart @@ -1,14 +1,19 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'api/mock_report_data_source.dart'; import 'api/report_data_source.dart'; import 'audio_player_controller.dart'; import '../widgets/mini_player.dart'; final reportDataSourceProvider = Provider((ref) { + const useMock = bool.fromEnvironment('YANTING_USE_MOCK', defaultValue: true); + if (useMock) { + return MockReportDataSource(); + } return RnbApiDataSource(); }); final audioPlayerControllerProvider = StateNotifierProvider((ref) { - return AudioPlayerController(); -}); + return AudioPlayerController(); + }); diff --git a/lib/features/detail/modules/renderer_registry.dart b/lib/features/detail/modules/renderer_registry.dart index 3b57454..b4feb83 100644 --- a/lib/features/detail/modules/renderer_registry.dart +++ b/lib/features/detail/modules/renderer_registry.dart @@ -4,6 +4,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../data/api/report_data_source.dart'; import '../../../data/models/models.dart'; +import '../../../theme/app_icons.dart'; +import '../../../theme/yanting_text.dart'; +import '../../../theme/yanting_tokens.dart'; import '../../../theme/wise_tokens.dart'; import '../../../widgets/app_card.dart'; import '../../../widgets/badges.dart'; @@ -50,7 +53,7 @@ class ModuleRendererRegistry { crossAxisAlignment: CrossAxisAlignment.start, children: [ _ModuleHeader(module: module), - const SizedBox(height: WiseSpacing.x4), + const SizedBox(height: WiseSpacing.x3), _contentFor( context, type: module.type, @@ -66,12 +69,12 @@ class ModuleRendererRegistry { compact: module.renderMode != 'inline', ), if (module.hasDetailPage) ...[ - const SizedBox(height: WiseSpacing.x4), + const SizedBox(height: WiseSpacing.x3), Align( alignment: Alignment.centerLeft, child: TextButton.icon( onPressed: openDetail, - icon: const Icon(Icons.open_in_new), + icon: const Icon(AppIcons.externalLink), label: const Text('查看详情'), ), ), @@ -183,20 +186,20 @@ class ModuleDetailPage extends HookConsumerWidget { body: snapshot.connectionState != ConnectionState.done ? const Center(child: CircularProgressIndicator()) : snapshot.hasError - ? Center( - child: TextButton( - onPressed: () => retryCount.value++, - child: Text( - snapshot.error.toString(), - textAlign: TextAlign.center, - ), - ), - ) - : _ModuleDetailContent( - detail: snapshot.data!, - report: report, - registry: registry, + ? Center( + child: TextButton( + onPressed: () => retryCount.value++, + child: Text( + snapshot.error.toString(), + textAlign: TextAlign.center, ), + ), + ) + : _ModuleDetailContent( + detail: snapshot.data!, + report: report, + registry: registry, + ), ); } } @@ -215,8 +218,13 @@ class _ModuleDetailContent extends StatelessWidget { @override Widget build(BuildContext context) { return ListView( - padding: const EdgeInsets.all(WiseSpacing.x4), + padding: const EdgeInsets.fromLTRB(WiseSpacing.x4, 4, WiseSpacing.x4, 16), children: [ + Text( + detail.titleCn, + style: YantingText.sectionTitle.copyWith(fontSize: 21), + ), + const SizedBox(height: WiseSpacing.x2), AppCard( child: registry.page( context, @@ -226,10 +234,7 @@ class _ModuleDetailContent extends StatelessWidget { ), ), const SizedBox(height: WiseSpacing.x3), - Text( - '缓存版本 ${detail.cacheVersion}', - style: Theme.of(context).textTheme.bodySmall, - ), + Text('缓存版本 ${detail.cacheVersion}', style: YantingText.meta), ], ); } @@ -247,7 +252,7 @@ class _ModuleHeader extends StatelessWidget { Expanded( child: Text( module.titleCn, - style: Theme.of(context).textTheme.titleMedium, + style: YantingText.cardTitle.copyWith(fontSize: 17), ), ), if (module.layer.isNotEmpty) @@ -276,7 +281,7 @@ class _BasicInfo extends StatelessWidget { payload['summary_cn'], asString(payload['scope_cn'], report?.oneLiner ?? ''), ), - style: Theme.of(context).textTheme.bodyMedium, + style: YantingText.body, ), const SizedBox(height: WiseSpacing.x2), Wrap( @@ -306,8 +311,14 @@ class _CoreInsights extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final point in points) - Padding( - padding: const EdgeInsets.only(bottom: WiseSpacing.x3), + Container( + margin: const EdgeInsets.only(bottom: WiseSpacing.x3), + padding: const EdgeInsets.all(WiseSpacing.x3), + decoration: BoxDecoration( + color: YantingColors.background, + border: Border.all(color: YantingColors.border), + borderRadius: BorderRadius.circular(YantingRadius.md), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -316,10 +327,7 @@ class _CoreInsights extends StatelessWidget { kind: _kindBadge(asString(point['kind'])), ), const SizedBox(height: WiseSpacing.x1), - Text( - asString(point['text']), - style: Theme.of(context).textTheme.bodyMedium, - ), + Text(asString(point['text']), style: YantingText.body), ], ), ), @@ -341,13 +349,10 @@ class _SourceCompliance extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (asString(payload['source_note']).isNotEmpty) - Text( - asString(payload['source_note']), - style: Theme.of(context).textTheme.bodyMedium, - ), + Text(asString(payload['source_note']), style: YantingText.body), if (institution != null) ...[ const SizedBox(height: WiseSpacing.x4), - Text('发布机构', style: Theme.of(context).textTheme.titleMedium), + Text('发布机构', style: YantingText.cardTitle.copyWith(fontSize: 17)), const SizedBox(height: WiseSpacing.x2), _InfoLine(label: '机构名称', value: institution.nameCn), if (institution.nameEn.isNotEmpty) @@ -373,24 +378,20 @@ class _SourceCompliance extends StatelessWidget { ], if (asString(payload['copyright_cn']).isNotEmpty) ...[ const SizedBox(height: WiseSpacing.x4), - Text( - asString(payload['copyright_cn']), - style: Theme.of(context).textTheme.bodySmall, - ), + Text(asString(payload['copyright_cn']), style: YantingText.meta), ], const SizedBox(height: WiseSpacing.x3), DecoratedBox( decoration: BoxDecoration( - color: const Color(0x109A6500), - borderRadius: BorderRadius.circular(WiseRadius.sm), + color: YantingColors.background, + border: Border.all(color: YantingColors.border), + borderRadius: BorderRadius.circular(YantingRadius.md), ), child: Padding( padding: const EdgeInsets.all(WiseSpacing.x3), child: Text( asString(payload['disclaimer'], '本内容为公开/授权研报的结构化解读,不构成投资建议。'), - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: WiseColors.warning), + style: YantingText.meta.copyWith(color: YantingColors.warning), ), ), ), @@ -415,12 +416,12 @@ class _InfoLine extends StatelessWidget { children: [ Text( label, - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: WiseColors.ink700), + style: YantingText.badge.copyWith( + color: YantingColors.mutedForeground, + ), ), const SizedBox(height: WiseSpacing.x1), - Text(value, style: Theme.of(context).textTheme.bodyMedium), + Text(value, style: YantingText.body), ], ), ); @@ -478,14 +479,12 @@ class _InstitutionModule extends StatelessWidget { final name = asString(payload['name_cn'], report?.institution.nameCn ?? ''); return Row( children: [ - const Icon(Icons.account_balance_outlined, color: WiseColors.primary), + const Icon(AppIcons.bank, color: YantingColors.foreground), const SizedBox(width: WiseSpacing.x2), - Expanded( - child: Text(name, style: Theme.of(context).textTheme.bodyMedium), - ), + Expanded(child: Text(name, style: YantingText.body)), Text( '${asInt(payload['report_count'], report?.institution.reportCount ?? 0)} 份', - style: Theme.of(context).textTheme.bodySmall, + style: YantingText.meta, ), ], ); @@ -509,19 +508,15 @@ class _SectionsModule extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (summary.isNotEmpty) - Text(summary, style: Theme.of(context).textTheme.bodyMedium), + if (summary.isNotEmpty) Text(summary, style: YantingText.body), for (final section in sections) ...[ const SizedBox(height: WiseSpacing.x3), Text( asString(section['heading']), - style: Theme.of(context).textTheme.titleMedium, + style: YantingText.cardTitle.copyWith(fontSize: 17), ), const SizedBox(height: WiseSpacing.x1), - Text( - asString(section['body']), - style: Theme.of(context).textTheme.bodyMedium, - ), + Text(asString(section['body']), style: YantingText.body), ], ], ); @@ -542,39 +537,40 @@ class _KeyDataModule extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final row in rows) - Padding( - padding: const EdgeInsets.only(bottom: WiseSpacing.x4), - child: Column( + Container( + margin: const EdgeInsets.only(bottom: WiseSpacing.x3), + padding: const EdgeInsets.all(WiseSpacing.x3), + decoration: BoxDecoration( + color: YantingColors.secondary, + borderRadius: BorderRadius.circular(YantingRadius.md), + ), + child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - asString(row['metric']), - style: Theme.of(context).textTheme.titleMedium, + Expanded( + child: Column( + 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( _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( crossAxisAlignment: CrossAxisAlignment.start, children: [ - for (final event in events) - Padding( - padding: const EdgeInsets.only(bottom: WiseSpacing.x4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (asString(event['date']).isNotEmpty) - Text( - asString(event['date']), - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: WiseColors.primary), - ), - 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, - ), - ], - ), + for (var index = 0; index < events.length; index++) + _TimelineEntry( + event: events[index], + isLast: index == events.length - 1, ), ], ); } } +class _TimelineEntry extends StatelessWidget { + const _TimelineEntry({required this.event, required this.isLast}); + + final JsonMap event; + final bool isLast; + + @override + Widget build(BuildContext context) { + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + children: [ + Container( + width: 9, + height: 9, + margin: const EdgeInsets.only(top: 6), + decoration: const BoxDecoration( + color: YantingColors.primary, + shape: BoxShape.circle, + ), + ), + if (!isLast) + Expanded( + child: Container( + width: 1, + margin: const EdgeInsets.symmetric(vertical: 4), + color: YantingColors.border, + ), + ), + ], + ), + const SizedBox(width: WiseSpacing.x2), + Expanded( + child: Padding( + padding: const EdgeInsets.only(bottom: WiseSpacing.x3), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (asString(event['date']).isNotEmpty) + Text( + asString(event['date']), + style: 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 { const _StudyGuideModule({required this.payload, required this.compact}); @@ -642,21 +682,15 @@ class _StudyGuideModule extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (asString(payload['intro_cn']).isNotEmpty) - Text( - asString(payload['intro_cn']), - style: Theme.of(context).textTheme.bodyMedium, - ), + Text(asString(payload['intro_cn']), style: YantingText.body), for (final item in faqs) ExpansionTile( tilePadding: EdgeInsets.zero, - title: Text(asString(item['question'])), + title: Text(asString(item['question']), style: YantingText.body), children: [ Align( alignment: Alignment.centerLeft, - child: Text( - asString(item['answer']), - style: Theme.of(context).textTheme.bodyMedium, - ), + child: Text(asString(item['answer']), style: YantingText.body), ), ], ), @@ -694,7 +728,7 @@ class _StructureGraphModule extends StatelessWidget { children: [ Text( asString(payload['root']), - style: Theme.of(context).textTheme.titleMedium, + style: YantingText.cardTitle.copyWith(fontSize: 17), ), const SizedBox(height: WiseSpacing.x3), for (final node in nodes) @@ -705,16 +739,13 @@ class _StructureGraphModule extends StatelessWidget { children: [ Text( asString(node['label']), - style: Theme.of(context).textTheme.titleMedium, + style: YantingText.cardTitle.copyWith(fontSize: 17), ), const SizedBox(height: WiseSpacing.x1), for (final child in asStringList(node['children'])) Padding( padding: const EdgeInsets.only(bottom: WiseSpacing.x1), - child: Text( - child, - style: Theme.of(context).textTheme.bodyMedium, - ), + child: Text(child, style: YantingText.body), ), ], ), @@ -745,12 +776,12 @@ class _RelatedSourcesModule extends StatelessWidget { children: [ Text( asString(item['title']), - style: Theme.of(context).textTheme.titleMedium, + style: YantingText.cardTitle.copyWith(fontSize: 17), ), const SizedBox(height: WiseSpacing.x1), Text( asString(item['summary_cn'], asString(item['source_name'])), - style: Theme.of(context).textTheme.bodyMedium, + style: YantingText.body, ), ], ), @@ -784,34 +815,34 @@ class _DifferentiatedViewModule extends StatelessWidget { children: [ Text( asString(item['topic']), - style: Theme.of(context).textTheme.titleMedium, + style: YantingText.cardTitle.copyWith(fontSize: 17), ), const SizedBox(height: WiseSpacing.x2), if (asString(item['consensus_view']).isNotEmpty) ...[ Text( '常见观点', - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: WiseColors.ink700), + style: YantingText.badge.copyWith( + color: YantingColors.mutedForeground, + ), ), const SizedBox(height: WiseSpacing.x1), Text( asString(item['consensus_view']), - style: Theme.of(context).textTheme.bodyMedium, + style: YantingText.body, ), const SizedBox(height: WiseSpacing.x2), ], if (asString(item['report_position']).isNotEmpty) ...[ Text( '报告观点', - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: WiseColors.primary), + style: YantingText.badge.copyWith( + color: YantingColors.foreground, + ), ), const SizedBox(height: WiseSpacing.x1), Text( asString(item['report_position']), - style: Theme.of(context).textTheme.bodyMedium, + style: YantingText.body, ), ], ], @@ -842,10 +873,7 @@ class _WeaknessesModule extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (asString(payload['disclaimer_cn']).isNotEmpty) - Text( - asString(payload['disclaimer_cn']), - style: Theme.of(context).textTheme.bodySmall, - ), + Text(asString(payload['disclaimer_cn']), style: YantingText.meta), for (final item in items) Padding( padding: const EdgeInsets.only( @@ -857,13 +885,10 @@ class _WeaknessesModule extends StatelessWidget { children: [ Text( asString(item['topic']), - style: Theme.of(context).textTheme.titleMedium, + style: YantingText.cardTitle.copyWith(fontSize: 17), ), const SizedBox(height: WiseSpacing.x1), - Text( - asString(item['weakness']), - style: Theme.of(context).textTheme.bodyMedium, - ), + Text(asString(item['weakness']), style: YantingText.body), ], ), ), @@ -881,9 +906,9 @@ class _WeaknessesModule extends StatelessWidget { children: [ Text( '需要继续验证', - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: WiseColors.warning), + style: YantingText.badge.copyWith( + color: YantingColors.warning, + ), ), const SizedBox(height: WiseSpacing.x1), for (final note @@ -892,10 +917,7 @@ class _WeaknessesModule extends StatelessWidget { : counterEvidence) Padding( padding: const EdgeInsets.only(bottom: WiseSpacing.x1), - child: Text( - note, - style: Theme.of(context).textTheme.bodySmall, - ), + child: Text(note, style: YantingText.meta), ), ], ), @@ -922,15 +944,11 @@ class _Preview extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (headline.isNotEmpty) - Text(headline, style: Theme.of(context).textTheme.bodyMedium), + if (headline.isNotEmpty) Text(headline, style: YantingText.body), for (final item in highlights.take(3)) Padding( padding: const EdgeInsets.only(top: WiseSpacing.x1), - child: Text( - '• $item', - style: Theme.of(context).textTheme.bodySmall, - ), + child: Text('• $item', style: YantingText.meta), ), ], ); @@ -953,7 +971,7 @@ class _TextLines extends StatelessWidget { .join('\n'); return Text( values.isEmpty ? '该模块暂无可展示内容。' : values, - style: Theme.of(context).textTheme.bodyMedium, + style: YantingText.body, ); } } diff --git a/lib/features/detail/report_detail_page.dart b/lib/features/detail/report_detail_page.dart index 1cc9845..8c7d949 100644 --- a/lib/features/detail/report_detail_page.dart +++ b/lib/features/detail/report_detail_page.dart @@ -4,6 +4,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../data/api/report_data_source.dart'; import '../../data/models/models.dart'; +import '../../theme/app_icons.dart'; +import '../../theme/yanting_text.dart'; +import '../../theme/yanting_tokens.dart'; import '../../theme/wise_tokens.dart'; import '../../widgets/app_buttons.dart'; import '../../widgets/app_card.dart'; @@ -42,10 +45,11 @@ class ReportDetailPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final retryCount = useState(0); - final detailFuture = useMemoized( - () => dataSource.reportDetail(reportId), - [dataSource, reportId, retryCount.value], - ); + final detailFuture = useMemoized(() => dataSource.reportDetail(reportId), [ + dataSource, + reportId, + retryCount.value, + ]); final snapshot = useFuture(detailFuture); const registry = ModuleRendererRegistry(); @@ -54,20 +58,20 @@ class ReportDetailPage extends HookConsumerWidget { body: snapshot.connectionState != ConnectionState.done ? const LoadingState() : snapshot.hasError - ? ErrorState( - message: snapshot.error.toString(), - onRetry: () => retryCount.value++, - ) - : _ReportDetailContent( - detail: snapshot.data!, - dataSource: dataSource, - player: player, - onStartAudio: onStartAudio, - onToggleAudio: onToggleAudio, - onSeekAudio: onSeekAudio, - onSpeed: onSpeed, - registry: registry, - ), + ? ErrorState( + message: snapshot.error.toString(), + onRetry: () => retryCount.value++, + ) + : _ReportDetailContent( + detail: snapshot.data!, + dataSource: dataSource, + player: player, + onStartAudio: onStartAudio, + onToggleAudio: onToggleAudio, + onSeekAudio: onSeekAudio, + onSpeed: onSpeed, + registry: registry, + ), ); } } @@ -102,10 +106,11 @@ class _ReportDetailContent extends StatelessWidget { @override Widget build(BuildContext context) { return ListView( - padding: const EdgeInsets.all(WiseSpacing.x4), + padding: const EdgeInsets.fromLTRB(WiseSpacing.x4, 4, WiseSpacing.x4, 16), children: [ AppCard( - color: WiseColors.secondary200, + color: YantingColors.brandSoft, + borderColor: YantingColors.brandSoftBorder, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -120,12 +125,11 @@ class _ReportDetailContent extends StatelessWidget { if (detail.hasAudio) const AppBadge( text: '音频', - icon: Icons.graphic_eq, + icon: AppIcons.playCircle, kind: BadgeKind.audio, ), AppBadge( text: asString(detail.source['source_tier']), - icon: Icons.verified_outlined, kind: BadgeKind.tier, ), ], @@ -135,19 +139,16 @@ class _ReportDetailContent extends StatelessWidget { detail.titleCn, maxLines: 3, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.headlineSmall, + style: YantingText.sectionTitle.copyWith(fontSize: 21), ), if (detail.oneLiner.isNotEmpty) ...[ const SizedBox(height: WiseSpacing.x2), - Text( - detail.oneLiner, - style: Theme.of(context).textTheme.bodyMedium, - ), + Text(detail.oneLiner, style: YantingText.body), ], const SizedBox(height: WiseSpacing.x3), Text( '${detail.institution.nameCn} · ${formatDate(detail.releasedAt)}', - style: Theme.of(context).textTheme.bodySmall, + style: YantingText.meta, ), ], ), @@ -188,17 +189,16 @@ class _ActionBar extends StatelessWidget { Expanded( child: AppButton( label: '收藏', - icon: Icons.favorite_border, + icon: AppIcons.heart, kind: AppButtonKind.ghost, - onPressed: () => - showLoginSheet(context, reason: '登录后保存到你的收藏'), + onPressed: () => showLoginSheet(context, reason: '登录后保存到你的收藏'), ), ), const SizedBox(width: WiseSpacing.x2), Expanded( child: AppButton( label: '原文', - icon: Icons.open_in_new, + icon: AppIcons.externalLink, kind: AppButtonKind.ghost, onPressed: () => showOutboundSheet(context, title: detail.titleCn), ), diff --git a/lib/features/feed/feed_page.dart b/lib/features/feed/feed_page.dart index 88829a5..8372432 100644 --- a/lib/features/feed/feed_page.dart +++ b/lib/features/feed/feed_page.dart @@ -9,6 +9,7 @@ import '../../routing/app_routes.dart'; import '../../theme/wise_tokens.dart'; import '../../widgets/badges.dart'; import '../../widgets/mini_player.dart'; +import '../../widgets/page_header.dart'; import '../../widgets/states.dart'; import '../shared/report_card_widget.dart'; @@ -27,7 +28,13 @@ class FeedPage extends HookConsumerWidget { final ReportDataSource dataSource; final void Function(AudioItem item) onPlay; final PlayerStateModel player; - final void Function(String audioId, String reportId, String title, int durationSec)? onStartModuleAudio; + final void Function( + String audioId, + String reportId, + String title, + int durationSec, + )? + onStartModuleAudio; final VoidCallback? onToggleAudio; final void Function(int delta)? onSeekAudio; final VoidCallback? onSpeed; @@ -45,19 +52,27 @@ class FeedPage extends HookConsumerWidget { ), data: (items) { final currentTopic = topic.value; - final topics = ['全部', ...{for (final item in items) ...item.topics}]; + final topics = [ + '全部', + ...{for (final item in items) ...item.topics}, + ]; final visible = currentTopic == '全部' ? items - : items.where((item) => item.topics.contains(currentTopic)).toList(); + : items + .where((item) => item.topics.contains(currentTopic)) + .toList(); if (items.isEmpty) { - return const EmptyState( - title: '暂无可推荐的研报解读', - message: '稍后再来看看最新内容', - ); + return const EmptyState(title: '暂无可推荐的研报解读', message: '稍后再来看看最新内容'); } return ListView( - padding: const EdgeInsets.all(WiseSpacing.x4), + padding: const EdgeInsets.fromLTRB( + WiseSpacing.x4, + 4, + WiseSpacing.x4, + 16, + ), children: [ + const PageHeader(title: '研听', subtitle: '全球机构研报中文解读'), SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( @@ -98,8 +113,7 @@ class FeedPage extends HookConsumerWidget { onPlayTap: () => _playFromReport(onPlay, visible.first), ), const SizedBox(height: WiseSpacing.x5), - Text('最新解读', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: WiseSpacing.x3), + const SectionTitle(title: '最新解读', icon: Icons.chevron_right), for (final report in visible.skip(1)) ...[ ReportCardWidget( report: report, diff --git a/lib/features/institutions/institution_detail_page.dart b/lib/features/institutions/institution_detail_page.dart index d7ea719..0ea8785 100644 --- a/lib/features/institutions/institution_detail_page.dart +++ b/lib/features/institutions/institution_detail_page.dart @@ -5,12 +5,16 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../data/api/report_data_source.dart'; import '../../data/models/models.dart'; import '../../routing/app_routes.dart'; +import '../../theme/app_icons.dart'; +import '../../theme/yanting_text.dart'; +import '../../theme/yanting_tokens.dart'; import '../../theme/wise_tokens.dart'; import '../../widgets/app_buttons.dart'; import '../../widgets/app_card.dart'; import '../../widgets/badges.dart'; import '../../widgets/sheets.dart'; import '../../widgets/states.dart'; +import '../../widgets/institution_card.dart'; import '../shared/report_card_widget.dart'; class InstitutionDetailPage extends HookConsumerWidget { @@ -37,14 +41,14 @@ class InstitutionDetailPage extends HookConsumerWidget { body: snapshot.connectionState != ConnectionState.done ? const LoadingState() : snapshot.hasError - ? ErrorState( - message: snapshot.error.toString(), - onRetry: () => retryCount.value++, - ) - : _InstitutionDetailContent( - item: snapshot.data!, - dataSource: dataSource, - ), + ? ErrorState( + message: snapshot.error.toString(), + onRetry: () => retryCount.value++, + ) + : _InstitutionDetailContent( + item: snapshot.data!, + dataSource: dataSource, + ), ); } } @@ -61,26 +65,47 @@ class _InstitutionDetailContent extends StatelessWidget { @override Widget build(BuildContext context) { return ListView( - padding: const EdgeInsets.all(WiseSpacing.x4), + padding: const EdgeInsets.fromLTRB(WiseSpacing.x4, 4, WiseSpacing.x4, 16), children: [ AppCard( - color: WiseColors.secondary200, + color: YantingColors.brandSoft, + borderColor: YantingColors.brandSoftBorder, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(item.nameCn, style: Theme.of(context).textTheme.headlineSmall), - if (item.nameEn.isNotEmpty) - Text(item.nameEn, style: Theme.of(context).textTheme.bodyMedium), - const SizedBox(height: WiseSpacing.x3), - Wrap( - spacing: WiseSpacing.x2, - runSpacing: WiseSpacing.x2, + Row( children: [ - AppBadge( - text: item.sourceTier, - icon: Icons.verified_outlined, - kind: BadgeKind.tier, + InstitutionLogo( + logoUrl: item.logoUrl, + initials: item.nameCn.isEmpty + ? '研' + : item.nameCn.characters.take(2).toString(), + size: 48, ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.nameCn, + style: YantingText.sectionTitle.copyWith( + fontSize: 21, + ), + ), + if (item.nameEn.isNotEmpty) + Text(item.nameEn, style: YantingText.meta), + ], + ), + ), + ], + ), + const SizedBox(height: 14), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + AppBadge(text: item.sourceTier, kind: BadgeKind.tier), AppBadge( text: '${item.reportCount} 份研报', kind: BadgeKind.brand, @@ -93,28 +118,23 @@ class _InstitutionDetailContent extends StatelessWidget { ), const SizedBox(height: WiseSpacing.x3), if (item.introCn.isNotEmpty) - AppCard( - child: Text(item.introCn, style: Theme.of(context).textTheme.bodyMedium), - ), + AppCard(child: Text(item.introCn, style: YantingText.body)), const SizedBox(height: WiseSpacing.x3), if (item.credibilityNote.isNotEmpty) AppCard( child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon(Icons.verified_user_outlined, color: WiseColors.positive), + const Icon(AppIcons.shield, color: YantingColors.chart2), const SizedBox(width: WiseSpacing.x2), Expanded( - child: Text( - item.credibilityNote, - style: Theme.of(context).textTheme.bodyMedium, - ), + child: Text(item.credibilityNote, style: YantingText.body), ), ], ), ), const SizedBox(height: WiseSpacing.x5), - Text('最新研报', style: Theme.of(context).textTheme.titleMedium), + Text('最新研报', style: YantingText.sectionTitle.copyWith(fontSize: 21)), const SizedBox(height: WiseSpacing.x3), if (item.recentReports.isEmpty) const EmptyState( @@ -132,7 +152,7 @@ class _InstitutionDetailContent extends StatelessWidget { ], AppButton( label: '了解相关服务', - icon: Icons.open_in_new, + icon: AppIcons.externalLink, kind: AppButtonKind.ghost, expand: true, onPressed: () => showOutboundSheet(context, title: item.nameCn), diff --git a/lib/features/institutions/institutions_page.dart b/lib/features/institutions/institutions_page.dart index 7a6b439..23e511b 100644 --- a/lib/features/institutions/institutions_page.dart +++ b/lib/features/institutions/institutions_page.dart @@ -3,11 +3,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../data/api/report_data_source.dart'; import '../../data/content_providers.dart'; -import '../../data/models/models.dart'; import '../../routing/app_routes.dart'; import '../../theme/wise_tokens.dart'; -import '../../widgets/app_card.dart'; -import '../../widgets/badges.dart'; +import '../../widgets/institution_card.dart'; +import '../../widgets/page_header.dart'; import '../../widgets/states.dart'; class InstitutionsPage extends HookConsumerWidget { @@ -35,18 +34,19 @@ class InstitutionsPage extends HookConsumerWidget { ); } return ListView( - padding: const EdgeInsets.all(WiseSpacing.x4), + padding: const EdgeInsets.fromLTRB( + WiseSpacing.x4, + 4, + WiseSpacing.x4, + 16, + ), children: [ - Text('研报来源机构', style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: WiseSpacing.x3), + const PageHeader(title: '机构', subtitle: '可获取研报的机构'), for (final item in sorted) ...[ InstitutionCard( institution: item, - onTap: () => openInstitutionDetail( - context, - dataSource, - item.id, - ), + onTap: () => + openInstitutionDetail(context, dataSource, item.id), ), const SizedBox(height: WiseSpacing.x3), ], @@ -56,76 +56,3 @@ class InstitutionsPage extends HookConsumerWidget { ); } } - -class InstitutionCard extends StatelessWidget { - const InstitutionCard({required this.institution, required this.onTap, super.key}); - - final Institution institution; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final initials = institution.nameCn.isEmpty - ? '研' - : institution.nameCn.characters.take(2).toString(); - return AppCard( - onTap: onTap, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - CircleAvatar( - radius: 25, - backgroundColor: WiseColors.secondary200, - foregroundColor: WiseColors.primary, - child: Text( - initials, - style: const TextStyle(fontWeight: FontWeight.w800), - ), - ), - const SizedBox(width: WiseSpacing.x3), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - institution.nameCn, - style: Theme.of(context).textTheme.titleMedium, - ), - if (institution.nameEn.isNotEmpty) - Text( - institution.nameEn, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: WiseSpacing.x2), - Wrap( - spacing: WiseSpacing.x2, - runSpacing: WiseSpacing.x2, - children: [ - if (institution.institutionType.isNotEmpty) - AppBadge(text: institution.institutionType), - for (final topic in institution.coveredTopics.take(3)) - AppBadge(text: topic, kind: BadgeKind.brand), - ], - ), - ], - ), - ), - const SizedBox(width: WiseSpacing.x2), - Column( - children: [ - Text( - '${institution.reportCount}', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: WiseColors.primary, - ), - ), - Text('份研报', style: Theme.of(context).textTheme.bodySmall), - ], - ), - ], - ), - ); - } -} diff --git a/lib/features/listen/listen_page.dart b/lib/features/listen/listen_page.dart index 4d89a4e..8493f26 100644 --- a/lib/features/listen/listen_page.dart +++ b/lib/features/listen/listen_page.dart @@ -4,8 +4,13 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../data/api/report_data_source.dart'; import '../../data/content_providers.dart'; import '../../data/models/models.dart'; +import '../../theme/app_icons.dart'; +import '../../theme/yanting_text.dart'; +import '../../theme/yanting_tokens.dart'; import '../../theme/wise_tokens.dart'; import '../../widgets/app_card.dart'; +import '../../widgets/badges.dart'; +import '../../widgets/page_header.dart'; import '../../widgets/states.dart'; class ListenPage extends HookConsumerWidget { @@ -31,57 +36,24 @@ class ListenPage extends HookConsumerWidget { icon: Icons.headphones_outlined, ); } + final current = items.first; return ListView( - padding: const EdgeInsets.all(WiseSpacing.x4), + padding: const EdgeInsets.fromLTRB( + WiseSpacing.x4, + 4, + WiseSpacing.x4, + 16, + ), children: [ - Text('全站音频解读', style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: WiseSpacing.x2), - Text( - '游客可完整收听;真实音频流待后端接入。', - style: Theme.of(context).textTheme.bodyMedium, + const PageHeader(title: '听单', subtitle: '已转音频的研报解读'), + const SectionTitle(title: '继续收听'), + _ContinueListeningCard( + item: current, + onPlay: () => onPlay(current), ), - const SizedBox(height: WiseSpacing.x4), - for (final item in items) ...[ - AppCard( - onTap: () => onPlay(item), - child: Row( - children: [ - IconButton.filled( - onPressed: () => onPlay(item), - icon: const Icon(Icons.play_arrow), - style: IconButton.styleFrom( - backgroundColor: WiseColors.primary, - foregroundColor: Colors.white, - ), - ), - const SizedBox(width: WiseSpacing.x3), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.reportTitleCn, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium, - ), - Text( - '${item.institution.nameCn} · ${formatDuration(item.durationSec)}', - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: WiseSpacing.x2), - LinearProgressIndicator( - value: 0, - minHeight: 4, - color: WiseColors.accent, - backgroundColor: WiseColors.border, - ), - ], - ), - ), - ], - ), - ), + const SectionTitle(title: '全部音频', icon: AppIcons.arrowRight), + for (final item in items.skip(1)) ...[ + _AudioListCard(item: item, onPlay: () => onPlay(item)), const SizedBox(height: WiseSpacing.x3), ], ], @@ -90,3 +62,159 @@ class ListenPage extends HookConsumerWidget { ); } } + +class _ContinueListeningCard extends StatelessWidget { + const _ContinueListeningCard({required this.item, required this.onPlay}); + + final AudioItem item; + final VoidCallback onPlay; + + @override + Widget build(BuildContext context) { + return AppCard( + onTap: onPlay, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Wrap( + spacing: 8, + runSpacing: 8, + children: [ + AppBadge(text: '研报解读', kind: BadgeKind.brand), + AppBadge(text: '音频', kind: BadgeKind.audio), + ], + ), + const SizedBox(height: 14), + Text( + item.reportTitleCn, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: YantingText.cardTitle, + ), + const SizedBox(height: 14), + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 8, + children: [ + Text( + item.institution.nameCn, + style: YantingText.meta.copyWith( + color: YantingColors.foreground, + fontWeight: FontWeight.w500, + ), + ), + Text('·', style: YantingText.meta), + Text( + '全长 ${formatDuration(item.durationSec)}', + style: YantingText.meta, + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + IconButton.filled( + onPressed: onPlay, + icon: const Icon(AppIcons.play), + style: IconButton.styleFrom( + backgroundColor: YantingColors.primary, + foregroundColor: YantingColors.primaryForeground, + fixedSize: const Size(48, 48), + ), + ), + const SizedBox(width: 13), + Expanded( + child: Column( + children: [ + LinearProgressIndicator( + value: 0.42, + minHeight: 5, + borderRadius: BorderRadius.circular(YantingRadius.pill), + backgroundColor: YantingColors.border, + color: YantingColors.primary, + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '06:01', + style: YantingText.meta.copyWith(fontSize: 12), + ), + Text( + '-08:19', + style: YantingText.meta.copyWith(fontSize: 12), + ), + ], + ), + ], + ), + ), + ], + ), + ], + ), + ); + } +} + +class _AudioListCard extends StatelessWidget { + const _AudioListCard({required this.item, required this.onPlay}); + + final AudioItem item; + final VoidCallback onPlay; + + @override + Widget build(BuildContext context) { + return AppCard( + padding: const EdgeInsets.all(14), + onTap: onPlay, + child: Row( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: YantingColors.secondary, + borderRadius: BorderRadius.circular(YantingRadius.xl), + ), + child: const Icon( + AppIcons.music, + color: YantingColors.mutedForeground, + size: 24, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.reportTitleCn, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: YantingText.listTitle, + ), + const SizedBox(height: 6), + Text( + '${item.institution.nameCn} · ${formatDuration(item.durationSec)}', + style: YantingText.meta, + ), + ], + ), + ), + const SizedBox(width: 10), + IconButton.filled( + onPressed: onPlay, + icon: const Icon(AppIcons.play), + style: IconButton.styleFrom( + backgroundColor: YantingColors.primary, + foregroundColor: YantingColors.primaryForeground, + fixedSize: const Size(44, 44), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/profile/profile_page.dart b/lib/features/profile/profile_page.dart index b4bb874..4bcd2a3 100644 --- a/lib/features/profile/profile_page.dart +++ b/lib/features/profile/profile_page.dart @@ -1,9 +1,13 @@ import 'package:flutter/material.dart'; import '../../data/api/report_data_source.dart'; +import '../../theme/app_icons.dart'; +import '../../theme/yanting_text.dart'; +import '../../theme/yanting_tokens.dart'; import '../../theme/wise_tokens.dart'; import '../../widgets/app_buttons.dart'; import '../../widgets/app_card.dart'; +import '../../widgets/page_header.dart'; import '../../widgets/sheets.dart'; import '../../widgets/states.dart'; @@ -15,63 +19,176 @@ class ProfilePage extends StatelessWidget { @override Widget build(BuildContext context) { return ListView( - padding: const EdgeInsets.all(WiseSpacing.x4), + padding: const EdgeInsets.fromLTRB(WiseSpacing.x4, 4, WiseSpacing.x4, 16), children: [ + const PageHeader(title: '我的'), AppCard( - color: WiseColors.secondary200, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + color: YantingColors.secondary, + child: Row( children: [ - Text('游客', style: Theme.of(context).textTheme.headlineSmall), - const SizedBox(height: WiseSpacing.x2), - Text('浏览、阅读和完整收听不需要登录。收藏、历史同步和保存听单等待 auth 接口接入。', style: Theme.of(context).textTheme.bodyMedium), - const SizedBox(height: WiseSpacing.x4), - AppButton( - label: '登录后保存个人状态', - icon: Icons.login, - onPressed: () => showLoginSheet(context), + CircleAvatar( + radius: 27, + backgroundColor: YantingColors.background, + foregroundColor: YantingColors.mutedForeground, + child: const Icon(AppIcons.user, size: 28), + ), + const SizedBox(width: 15), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '未登录', + style: YantingText.cardTitle.copyWith(fontSize: 18), + ), + const SizedBox(height: 5), + Text( + '登录后同步收藏、历史和听单', + style: YantingText.meta.copyWith(height: 1.5), + ), + ], + ), ), ], ), ), - const SizedBox(height: WiseSpacing.x4), - _ProfileRow(icon: Icons.favorite_border, title: '收藏研报', subtitle: '登录后同步收藏', onTap: () => showLoginSheet(context, reason: '登录后保存到你的收藏')), - _ProfileRow(icon: Icons.history, title: '浏览历史', subtitle: '本地历史占位,服务端同步待接入', onTap: () => showAppToast(context, '历史同步接口待接入')), - _ProfileRow(icon: Icons.playlist_add_check, title: '保存听单', subtitle: '登录后保存到你的听单', onTap: () => showLoginSheet(context, reason: '登录后保存到你的听单')), - _ProfileRow(icon: Icons.open_in_new, title: '了解研值相关服务', subtitle: '外跳前提示风险边界', onTap: () => showOutboundSheet(context, title: '研值相关服务')), + const SizedBox(height: WiseSpacing.x3), + AppButton( + label: '登录 / 注册', + expand: true, + onPressed: () => showLoginSheet(context), + ), + const SizedBox(height: 18), + _MenuGroup( + children: [ + _MenuRow( + icon: AppIcons.history, + title: '本地浏览记录', + trailing: '0 条 · 本地临时', + onTap: () => showAppToast(context, '历史同步接口待接入'), + ), + ], + ), + const SizedBox(height: WiseSpacing.x3), + _MenuGroup( + children: [ + _MenuRow( + icon: AppIcons.settings, + title: '设置', + onTap: () => showAppToast(context, '设置待接入'), + ), + _MenuRow( + icon: AppIcons.fileList, + title: '用户协议', + onTap: () => showOutboundSheet(context, title: '用户协议'), + ), + _MenuRow( + icon: AppIcons.shield, + title: '隐私政策', + onTap: () => showOutboundSheet(context, title: '隐私政策'), + ), + ], + ), + const SizedBox(height: WiseSpacing.x3), + AppCard( + color: YantingColors.secondary, + onTap: () => showOutboundSheet(context, title: '相关服务'), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + '了解相关服务', + style: YantingText.body.copyWith( + color: YantingColors.foreground, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 3), + const Icon(AppIcons.arrowRight, size: 18), + ], + ), + const SizedBox(height: 6), + Text( + '与你关注主题相关的延伸服务,内容不构成投资建议。', + style: YantingText.meta.copyWith(height: 1.5), + ), + ], + ), + ), + const SizedBox(height: 22), + Text( + '研听 · 全球机构研报中文解读\n登录不阻断游客完整收听第一期 · 内容不构成投资建议', + textAlign: TextAlign.center, + style: YantingText.meta.copyWith(fontSize: 12, height: 1.7), + ), ], ); } } -class _ProfileRow extends StatelessWidget { - const _ProfileRow({required this.icon, required this.title, required this.subtitle, required this.onTap}); +class _MenuGroup extends StatelessWidget { + const _MenuGroup({required this.children}); + + final List children; + + @override + Widget build(BuildContext context) { + return AppCard( + padding: EdgeInsets.zero, + child: Column(children: children), + ); + } +} + +class _MenuRow extends StatelessWidget { + const _MenuRow({ + required this.icon, + required this.title, + required this.onTap, + this.trailing, + }); final IconData icon; final String title; - final String subtitle; + final String? trailing; final VoidCallback onTap; @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: WiseSpacing.x3), - child: AppCard( - onTap: onTap, + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15), child: Row( children: [ - Icon(icon, color: WiseColors.primary), - const SizedBox(width: WiseSpacing.x3), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: Theme.of(context).textTheme.titleMedium), - Text(subtitle, style: Theme.of(context).textTheme.bodySmall), - ], + Icon(icon, size: 20, color: YantingColors.foreground), + const SizedBox(width: 13), + Expanded(child: Text(title, style: YantingText.body)), + if (trailing != null) + DecoratedBox( + decoration: BoxDecoration( + color: YantingColors.secondary, + borderRadius: BorderRadius.circular(YantingRadius.pill), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 3, + ), + child: Text( + trailing!, + style: YantingText.meta.copyWith(fontSize: 11.5), + ), + ), + ) + else + const Icon( + AppIcons.arrowRight, + color: YantingColors.mutedForeground, + size: 20, ), - ), - const Icon(Icons.chevron_right, color: WiseColors.textTertiary), ], ), ), diff --git a/lib/features/reports/reports_page.dart b/lib/features/reports/reports_page.dart index 3a7df9f..0312caf 100644 --- a/lib/features/reports/reports_page.dart +++ b/lib/features/reports/reports_page.dart @@ -6,10 +6,14 @@ import '../../data/api/report_data_source.dart'; import '../../data/content_providers.dart'; import '../../data/models/models.dart'; import '../../routing/app_routes.dart'; +import '../../theme/app_icons.dart'; +import '../../theme/yanting_text.dart'; +import '../../theme/yanting_tokens.dart'; import '../../theme/wise_tokens.dart'; import '../../widgets/app_buttons.dart'; import '../../widgets/badges.dart'; import '../../widgets/mini_player.dart'; +import '../../widgets/page_header.dart'; import '../../widgets/states.dart'; import '../shared/report_card_widget.dart'; @@ -28,7 +32,12 @@ class ReportsPage extends HookConsumerWidget { final ReportDataSource dataSource; final void Function(AudioItem item) onPlay; final PlayerStateModel player; - final void Function(String audioId, String reportId, String title, int durationSec)? + final void Function( + String audioId, + String reportId, + String title, + int durationSec, + )? onStartModuleAudio; final VoidCallback? onToggleAudio; final void Function(int delta)? onSeekAudio; @@ -59,23 +68,27 @@ class ReportsPage extends HookConsumerWidget { ); return ListView( - padding: const EdgeInsets.all(WiseSpacing.x4), + padding: const EdgeInsets.fromLTRB( + WiseSpacing.x4, + 4, + WiseSpacing.x4, + 16, + ), children: [ + const PageHeader(title: '研报', subtitle: '全部已发布研报解读'), TextField( decoration: InputDecoration( hintText: '搜索标题、机构或主题', - prefixIcon: const Icon(Icons.search), + prefixIcon: const Icon(AppIcons.search), suffixIcon: currentQuery.isEmpty ? null : IconButton( onPressed: () => query.value = '', icon: const Icon(Icons.close), ), - filled: true, - fillColor: WiseColors.surface, border: OutlineInputBorder( - borderRadius: BorderRadius.circular(WiseRadius.pill), - borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(YantingRadius.md), + borderSide: const BorderSide(color: YantingColors.input), ), ), onChanged: (value) => query.value = value.trim(), @@ -85,30 +98,34 @@ class ReportsPage extends HookConsumerWidget { children: [ AppButton( label: '筛选', - icon: Icons.tune, + icon: AppIcons.filter, kind: AppButtonKind.ghost, onPressed: items.isEmpty ? null : () => _openFilterSheet( - context, - items: items, - topic: topic, - ), + context, + items: items, + topic: topic, + ), + ), + const SizedBox(width: WiseSpacing.x2), + AppButton( + label: '最新', + icon: AppIcons.sort, + kind: AppButtonKind.ghost, + onPressed: () {}, ), const SizedBox(width: WiseSpacing.x2), AppChip( - label: '有音频', + label: '音频', selected: currentHasAudio, onTap: () => hasAudio.value = !currentHasAudio, ), + const Spacer(), + Text('共 ${filtered.length} 篇', style: YantingText.meta), ], ), const SizedBox(height: WiseSpacing.x3), - Text( - '共 ${filtered.length} 篇研报解读${currentQuery.isNotEmpty || currentTopic.isNotEmpty || currentHasAudio ? '(已筛选)' : ''}', - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: WiseSpacing.x3), if (filtered.isEmpty) EmptyState( title: currentQuery.isNotEmpty ? '未找到相关研报' : '当前筛选下暂无研报', diff --git a/lib/features/shared/report_card_widget.dart b/lib/features/shared/report_card_widget.dart index e1f7f18..5e8962e 100644 --- a/lib/features/shared/report_card_widget.dart +++ b/lib/features/shared/report_card_widget.dart @@ -1,7 +1,11 @@ import 'package:flutter/material.dart'; import '../../data/models/models.dart'; +import '../../theme/app_icons.dart'; +import '../../theme/yanting_text.dart'; +import '../../theme/yanting_tokens.dart'; import '../../theme/wise_tokens.dart'; +import '../../widgets/app_buttons.dart'; import '../../widgets/app_card.dart'; import '../../widgets/badges.dart'; @@ -32,9 +36,13 @@ class ReportCardWidget extends StatelessWidget { children: [ AppBadge(text: report.interpretationLabel, kind: BadgeKind.brand), if (report.hasAudio) - const AppBadge(text: '音频', icon: Icons.graphic_eq, kind: BadgeKind.audio), + const AppBadge( + text: '音频', + icon: AppIcons.play, + kind: BadgeKind.audio, + ), if (report.sourceTier.isNotEmpty) - AppBadge(text: report.sourceTier, icon: Icons.verified_outlined, kind: BadgeKind.tier), + AppBadge(text: report.sourceTier, kind: BadgeKind.tier), for (final topic in report.topics.take(3)) AppBadge(text: topic), ], ), @@ -44,8 +52,8 @@ class ReportCardWidget extends StatelessWidget { maxLines: hero ? 3 : 2, overflow: TextOverflow.ellipsis, style: hero - ? Theme.of(context).textTheme.titleLarge - : Theme.of(context).textTheme.titleMedium, + ? YantingText.sectionTitle.copyWith(fontSize: 21, height: 1.4) + : YantingText.cardTitle, ), if (report.oneLiner.isNotEmpty) ...[ const SizedBox(height: WiseSpacing.x2), @@ -53,31 +61,44 @@ class ReportCardWidget extends StatelessWidget { report.oneLiner, maxLines: 2, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium, + style: YantingText.body.copyWith( + color: YantingColors.mutedForeground, + ), ), ], const SizedBox(height: WiseSpacing.x3), - Row( + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 8, + runSpacing: 8, children: [ - Expanded( - child: InkWell( - onTap: onInstitutionTap, - child: Text( - '${report.institution.nameCn}${report.releasedAt == null ? '' : ' · ${formatDate(report.releasedAt)}'}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall, + InkWell( + onTap: onInstitutionTap, + child: Text( + report.institution.nameCn, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: YantingText.meta.copyWith( + color: YantingColors.foreground, + fontWeight: FontWeight.w500, ), ), ), - if (report.hasAudio) - TextButton.icon( - onPressed: onPlayTap, - icon: const Icon(Icons.play_circle_outline, size: 18), - label: const Text('听研报'), - ), + if (report.releasedAt != null) ...[ + const _MetaDot(), + Text(formatDate(report.releasedAt), style: YantingText.meta), + ], ], ), + if (report.hasAudio) ...[ + const SizedBox(height: 14), + AppButton( + label: '听研报', + icon: AppIcons.play, + kind: hero ? AppButtonKind.primary : AppButtonKind.accent, + onPressed: onPlayTap, + ), + ], ], ); return hero @@ -85,3 +106,19 @@ class ReportCardWidget extends StatelessWidget { : AppCard(onTap: onTap, child: child); } } + +class _MetaDot extends StatelessWidget { + const _MetaDot(); + + @override + Widget build(BuildContext context) { + return Container( + width: 3, + height: 3, + decoration: const BoxDecoration( + color: YantingColors.mutedForeground, + shape: BoxShape.circle, + ), + ); + } +} diff --git a/lib/features/shell_page.dart b/lib/features/shell_page.dart index 6da08b7..030fe9c 100644 --- a/lib/features/shell_page.dart +++ b/lib/features/shell_page.dart @@ -4,8 +4,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../routing/app_routes.dart'; import '../data/providers.dart'; -import '../theme/app_icons.dart'; import '../theme/wise_tokens.dart'; +import '../widgets/bottom_tab_bar.dart'; import '../widgets/mini_player.dart'; class ShellPage extends ConsumerWidget { @@ -39,7 +39,7 @@ class ShellPage extends ConsumerWidget { ), ), body: ColoredBox( - color: WiseColors.canvas, + color: Theme.of(context).scaffoldBackgroundColor, child: Stack(children: [Positioned.fill(child: child)]), ), bottomNavigationBar: SafeArea( @@ -48,54 +48,10 @@ class ShellPage extends ConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ MiniPlayer(player: player, onToggle: controller.toggleAudio), - Container( - height: 64, - padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), - decoration: const BoxDecoration( - color: WiseColors.canvas, - border: Border( - top: BorderSide(color: Color(0x11000000), width: 0.5), - ), - ), - child: Row( - children: List.generate(_tabs.length, (index) { - final tab = _tabs[index]; - final active = index == safeIndex; - return Expanded( - child: InkWell( - onTap: () => context.go(tab.path), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - tab.icon, - size: 20, - color: active - ? WiseColors.ink - : WiseColors.textTertiary, - ), - const SizedBox(height: 4), - Text( - tab.label, - style: - (Theme.of(context).textTheme.labelLarge ?? - const TextStyle()) - .copyWith( - color: active - ? WiseColors.ink - : WiseColors.textTertiary, - fontFamily: 'Inter', - fontSize: 12, - letterSpacing: 0.72, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ); - }), - ), + BottomTabBar( + items: yantingBottomTabItems, + selectedIndex: safeIndex, + onSelected: (index) => context.go(_tabs[index].path), ), ], ), @@ -105,17 +61,15 @@ class ShellPage extends ConsumerWidget { } class _TabItem { - const _TabItem({required this.label, required this.path, required this.icon}); + const _TabItem({required this.path}); - final String label; final String path; - final IconData icon; } const List<_TabItem> _tabs = [ - _TabItem(label: '推荐', path: AppRoutes.home, icon: AppIcons.sparkle), - _TabItem(label: '研报', path: AppRoutes.reports, icon: AppIcons.article), - _TabItem(label: '机构', path: AppRoutes.institutions, icon: AppIcons.bank), - _TabItem(label: '听单', path: AppRoutes.listen, icon: AppIcons.headphones), - _TabItem(label: '我的', path: AppRoutes.profile, icon: AppIcons.user), + _TabItem(path: AppRoutes.home), + _TabItem(path: AppRoutes.reports), + _TabItem(path: AppRoutes.institutions), + _TabItem(path: AppRoutes.listen), + _TabItem(path: AppRoutes.profile), ]; diff --git a/lib/theme/app_icons.dart b/lib/theme/app_icons.dart index c149ebf..2961cb0 100644 --- a/lib/theme/app_icons.dart +++ b/lib/theme/app_icons.dart @@ -1,10 +1,45 @@ import 'package:flutter/widgets.dart'; -import 'package:phosphor_flutter/phosphor_flutter.dart'; +import 'package:remixicon/remixicon.dart'; abstract final class AppIcons { - static const IconData sparkle = PhosphorIconsRegular.sparkle; - static const IconData article = PhosphorIconsRegular.article; - static const IconData bank = PhosphorIconsRegular.bank; - static const IconData headphones = PhosphorIconsRegular.headphones; - static const IconData user = PhosphorIconsRegular.user; + static const IconData sparkle = Remix.star_line; + static const IconData sparkleFill = Remix.star_fill; + static const IconData article = Remix.article_line; + static const IconData articleFill = Remix.article_fill; + static const IconData bank = Remix.bank_line; + static const IconData bankFill = Remix.bank_fill; + static const IconData headphones = Remix.headphone_line; + static const IconData headphonesFill = Remix.headphone_fill; + static const IconData user = Remix.user_3_line; + static const IconData userFill = Remix.user_3_fill; + + static const IconData search = Remix.search_line; + static const IconData filter = Remix.equalizer_line; + static const IconData sort = Remix.arrow_down_line; + static const IconData arrowRight = Remix.arrow_right_s_line; + static const IconData arrowLeft = Remix.arrow_left_s_line; + static const IconData play = Remix.play_fill; + static const IconData pause = Remix.pause_fill; + static const IconData playCircle = Remix.play_circle_fill; + static const IconData heart = Remix.heart_3_line; + static const IconData heartFill = Remix.heart_3_fill; + static const IconData externalLink = Remix.external_link_line; + static const IconData warning = Remix.error_warning_line; + static const IconData music = Remix.music_2_line; + static const IconData disc = Remix.disc_line; + static const IconData history = Remix.history_line; + static const IconData settings = Remix.settings_3_line; + static const IconData fileList = Remix.file_list_3_line; + static const IconData shield = Remix.shield_check_line; + + static IconData tabIcon(int index, {required bool selected}) { + return switch (index) { + 0 => selected ? sparkleFill : sparkle, + 1 => selected ? articleFill : article, + 2 => selected ? bankFill : bank, + 3 => selected ? headphonesFill : headphones, + 4 => selected ? userFill : user, + _ => selected ? sparkleFill : sparkle, + }; + } } diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index e5b1025..a4844fb 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -1,98 +1,100 @@ import 'package:flutter/material.dart'; +import 'yanting_text.dart'; +import 'yanting_tokens.dart'; import 'wise_tokens.dart'; ThemeData buildAppTheme() { final scheme = ColorScheme.fromSeed( - seedColor: WiseColors.primary, - primary: WiseColors.primary, - secondary: WiseColors.secondary, - tertiary: WiseColors.accent, - surface: WiseColors.surface, + seedColor: YantingColors.primary, + primary: YantingColors.primary, + onPrimary: YantingColors.primaryForeground, + secondary: YantingColors.secondary, + onSecondary: YantingColors.secondaryForeground, + tertiary: YantingColors.link, + surface: YantingColors.card, + onSurface: YantingColors.foreground, + error: YantingColors.destructive, + outline: YantingColors.border, ); return ThemeData( useMaterial3: true, colorScheme: scheme, - fontFamily: 'Inter', - scaffoldBackgroundColor: WiseColors.canvas, + fontFamily: YantingText.fontFamily, + fontFamilyFallback: YantingText.fontFallback, + scaffoldBackgroundColor: YantingColors.background, appBarTheme: const AppBarTheme( - backgroundColor: WiseColors.canvas, - foregroundColor: WiseColors.primary, + backgroundColor: YantingColors.background, + foregroundColor: YantingColors.foreground, elevation: 0, centerTitle: false, - titleTextStyle: TextStyle( - color: WiseColors.primary, - fontSize: 22, - fontWeight: FontWeight.w800, - ), + titleTextStyle: YantingText.sectionTitle, ), cardTheme: const CardThemeData( - color: WiseColors.surface, + color: YantingColors.card, elevation: 0, margin: EdgeInsets.zero, shape: RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(WiseRadius.md)), + side: BorderSide(color: YantingColors.border), + ), + ), + dividerTheme: const DividerThemeData( + color: YantingColors.border, + thickness: 1, + space: 1, + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: YantingColors.background, + hintStyle: YantingText.body.copyWith( + color: YantingColors.mutedForeground, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(YantingRadius.md), + borderSide: const BorderSide(color: YantingColors.input), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(YantingRadius.md), + borderSide: const BorderSide(color: YantingColors.input), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(YantingRadius.md), + borderSide: const BorderSide(color: YantingColors.foreground), ), ), navigationBarTheme: NavigationBarThemeData( - backgroundColor: WiseColors.surface, - indicatorColor: WiseColors.secondary200, + backgroundColor: YantingColors.background, + indicatorColor: Colors.transparent, labelTextStyle: WidgetStateProperty.resolveWith( - (states) => TextStyle( + (states) => YantingText.meta.copyWith( color: states.contains(WidgetState.selected) - ? WiseColors.primary - : WiseColors.textTertiary, + ? YantingColors.foreground + : YantingColors.mutedForeground, fontSize: 11, - fontWeight: FontWeight.w700, + fontWeight: states.contains(WidgetState.selected) + ? FontWeight.w600 + : FontWeight.w400, ), ), iconTheme: WidgetStateProperty.resolveWith( (states) => IconThemeData( color: states.contains(WidgetState.selected) - ? WiseColors.primary - : WiseColors.textTertiary, + ? YantingColors.foreground + : YantingColors.mutedForeground, ), ), ), textTheme: const TextTheme( - headlineSmall: TextStyle( - color: WiseColors.ink, - fontSize: 26, - height: 1.18, - fontWeight: FontWeight.w800, - ), - titleLarge: TextStyle( - color: WiseColors.ink, - fontSize: 21, - height: 1.22, - fontWeight: FontWeight.w800, - ), - titleMedium: TextStyle( - color: WiseColors.ink, - fontSize: 17, - height: 1.25, - fontWeight: FontWeight.w800, - ), - bodyLarge: TextStyle( - color: WiseColors.ink, - fontSize: 16, - height: 1.55, - ), - bodyMedium: TextStyle( - color: WiseColors.ink700, - fontSize: 14, - height: 1.5, - ), - bodySmall: TextStyle( - color: WiseColors.textSecondary, - fontSize: 12, - height: 1.45, - ), - labelSmall: TextStyle( - color: WiseColors.textSecondary, - fontSize: 11, - fontWeight: FontWeight.w700, - ), + headlineSmall: YantingText.appTitle, + titleLarge: YantingText.sectionTitle, + titleMedium: YantingText.cardTitle, + bodyLarge: YantingText.body, + bodyMedium: YantingText.sub, + bodySmall: YantingText.meta, + labelLarge: YantingText.chip, + labelSmall: YantingText.badge, ), ); } diff --git a/lib/theme/wise_tokens.dart b/lib/theme/wise_tokens.dart index bfe3402..ef010af 100644 --- a/lib/theme/wise_tokens.dart +++ b/lib/theme/wise_tokens.dart @@ -1,39 +1,41 @@ import 'package:flutter/material.dart'; +import 'yanting_tokens.dart'; + final class WiseColors { - static const primary = Color(0xFF163300); - static const primarySoft = Color(0xFF1F4708); - static const secondary = Color(0xFF9FE870); - static const secondary200 = Color(0xFFE2F6D5); - static const accent = Color(0xFF00A2DD); - static const canvas = Color(0xFFF4F6F3); - static const ink = Color(0xFF0E0F0C); - static const ink700 = Color(0xFF454745); - static const textSecondary = Color(0xFF5D7079); - static const textTertiary = Color(0xFF768E9C); - static const surface = Colors.white; - static const border = Color(0x1A000000); - static const positive = Color(0xFF008026); - static const warning = Color(0xFF9A6500); - static const negative = Color(0xFFCF2929); + static const primary = YantingColors.foreground; + static const primarySoft = YantingColors.primaryForeground; + static const secondary = YantingColors.primary; + static const secondary200 = YantingColors.brandSoft; + static const accent = YantingColors.link; + static const canvas = YantingColors.background; + static const ink = YantingColors.foreground; + static const ink700 = YantingColors.secondaryForeground; + static const textSecondary = YantingColors.mutedForeground; + static const textTertiary = YantingColors.mutedForeground; + static const surface = YantingColors.card; + static const border = YantingColors.border; + static const positive = YantingColors.chart2; + static const warning = Color(0xFF9A6A00); + static const negative = YantingColors.destructive; } final class WiseSpacing { - static const x1 = 4.0; - static const x2 = 8.0; - static const x3 = 12.0; - static const x4 = 16.0; - static const x5 = 20.0; - static const x6 = 24.0; - static const x8 = 32.0; - static const x10 = 40.0; + static const x1 = YantingSpacing.x1; + static const x2 = YantingSpacing.x2; + static const x3 = YantingSpacing.cardGap; + static const x4 = YantingSpacing.screenX; + static const x5 = YantingSpacing.screenX; + static const x6 = YantingSpacing.x6; + static const x8 = YantingSpacing.x8; + static const x10 = YantingSpacing.x10; } final class WiseRadius { - static const sm = 10.0; - static const md = 16.0; + static const sm = YantingRadius.sm; + static const md = YantingRadius.xl; static const lg = 24.0; - static const pill = 999.0; + static const pill = YantingRadius.pill; } final class WiseMotion { @@ -43,26 +45,12 @@ final class WiseMotion { } final class WiseShadows { - static const card = [ - BoxShadow( - color: Color(0x14000000), - blurRadius: 20, - offset: Offset(0, 6), - ), - ]; - static const elevated = [ - BoxShadow( - color: Color(0x24000000), - blurRadius: 32, - offset: Offset(0, 10), - ), - ]; + static const card = []; + static const elevated = []; } const wiseFontStack = [ - 'Inter', - '-apple-system', - 'BlinkMacSystemFont', + 'DM Sans', 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', diff --git a/lib/theme/yanting_text.dart b/lib/theme/yanting_text.dart new file mode 100644 index 0000000..60fa04a --- /dev/null +++ b/lib/theme/yanting_text.dart @@ -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, + ); +} diff --git a/lib/theme/yanting_tokens.dart b/lib/theme/yanting_tokens.dart new file mode 100644 index 0000000..b8ec279 --- /dev/null +++ b/lib/theme/yanting_tokens.dart @@ -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()]; +} diff --git a/lib/widgets/app_buttons.dart b/lib/widgets/app_buttons.dart index dbc8972..34b5b13 100644 --- a/lib/widgets/app_buttons.dart +++ b/lib/widgets/app_buttons.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; -import '../theme/wise_tokens.dart'; +import '../theme/yanting_text.dart'; +import '../theme/yanting_tokens.dart'; class AppButton extends StatelessWidget { const AppButton({ @@ -21,10 +22,26 @@ class AppButton extends StatelessWidget { @override Widget build(BuildContext context) { final colors = switch (kind) { - AppButtonKind.primary => (WiseColors.secondary, WiseColors.primary), - AppButtonKind.dark => (WiseColors.primary, Colors.white), - AppButtonKind.accent => (WiseColors.accent, Colors.white), - AppButtonKind.ghost => (WiseColors.surface, WiseColors.primary), + AppButtonKind.primary => ( + YantingColors.primary, + YantingColors.primaryForeground, + Colors.transparent, + ), + AppButtonKind.dark => ( + YantingColors.foreground, + YantingColors.background, + Colors.transparent, + ), + AppButtonKind.accent => ( + YantingColors.brandSoft, + YantingColors.primaryForeground, + Colors.transparent, + ), + AppButtonKind.ghost => ( + YantingColors.background, + YantingColors.foreground, + YantingColors.border, + ), }; final child = FilledButton.icon( onPressed: onPressed, @@ -33,10 +50,16 @@ class AppButton extends StatelessWidget { style: FilledButton.styleFrom( backgroundColor: colors.$1, foregroundColor: colors.$2, - disabledBackgroundColor: WiseColors.border, - disabledForegroundColor: WiseColors.textTertiary, + disabledBackgroundColor: YantingColors.border, + disabledForegroundColor: YantingColors.mutedForeground, minimumSize: Size(expand ? double.infinity : 0, 44), - shape: const StadiumBorder(), + textStyle: YantingText.body.copyWith(fontWeight: FontWeight.w600), + side: colors.$3 == Colors.transparent + ? BorderSide.none + : BorderSide(color: colors.$3), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(YantingRadius.md), + ), ), ); return expand ? SizedBox(width: double.infinity, child: child) : child; diff --git a/lib/widgets/app_card.dart b/lib/widgets/app_card.dart index 62a105e..3e6bbd5 100644 --- a/lib/widgets/app_card.dart +++ b/lib/widgets/app_card.dart @@ -1,13 +1,14 @@ import 'package:flutter/material.dart'; -import '../theme/wise_tokens.dart'; +import '../theme/yanting_tokens.dart'; class AppCard extends StatelessWidget { const AppCard({ required this.child, this.onTap, - this.padding = const EdgeInsets.all(WiseSpacing.x4), - this.color = WiseColors.surface, + this.padding = const EdgeInsets.all(YantingSpacing.cardPadding), + this.color = YantingColors.card, + this.borderColor = YantingColors.border, super.key, }); @@ -15,22 +16,34 @@ class AppCard extends StatelessWidget { final VoidCallback? onTap; final EdgeInsetsGeometry padding; final Color color; + final Color borderColor; @override Widget build(BuildContext context) { + final decoration = BoxDecoration( + color: color, + border: Border.all(color: borderColor), + borderRadius: BorderRadius.circular(YantingRadius.xl), + ); final content = DecoratedBox( decoration: BoxDecoration( color: color, - borderRadius: BorderRadius.circular(WiseRadius.md), - boxShadow: WiseShadows.card, + border: Border.all(color: borderColor), + borderRadius: BorderRadius.circular(YantingRadius.xl), ), child: Padding(padding: padding, child: child), ); if (onTap == null) return content; - return InkWell( - borderRadius: BorderRadius.circular(WiseRadius.md), - onTap: onTap, - child: content, + return Material( + color: Colors.transparent, + child: Ink( + 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) { return AppCard( onTap: onTap, - color: WiseColors.secondary200, - padding: const EdgeInsets.all(WiseSpacing.x5), + color: YantingColors.brandSoft, + borderColor: YantingColors.brandSoftBorder, + padding: const EdgeInsets.all(YantingSpacing.cardPadding), child: child, ); } diff --git a/lib/widgets/badges.dart b/lib/widgets/badges.dart index 964818e..e10abd1 100644 --- a/lib/widgets/badges.dart +++ b/lib/widgets/badges.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; -import '../theme/wise_tokens.dart'; +import '../theme/yanting_text.dart'; +import '../theme/yanting_tokens.dart'; class AppBadge extends StatelessWidget { const AppBadge({ @@ -17,19 +18,42 @@ class AppBadge extends StatelessWidget { @override Widget build(BuildContext context) { final colors = switch (kind) { - BadgeKind.brand => (WiseColors.secondary200, WiseColors.primarySoft), - BadgeKind.audio => (const Color(0x1F00A2DD), WiseColors.accent), - BadgeKind.tier => (const Color(0x1A008026), WiseColors.positive), - BadgeKind.warning => (const Color(0x209A6500), WiseColors.warning), - BadgeKind.neutral => (const Color(0x1286A7BD), WiseColors.textSecondary), + BadgeKind.brand => ( + YantingColors.primary, + YantingColors.primaryForeground, + Colors.transparent, + ), + BadgeKind.audio => ( + YantingColors.secondary, + YantingColors.secondaryForeground, + Colors.transparent, + ), + BadgeKind.tier => ( + YantingColors.background, + YantingColors.mutedForeground, + YantingColors.border, + ), + BadgeKind.warning => ( + YantingColors.background, + YantingColors.destructive, + YantingColors.border, + ), + BadgeKind.neutral => ( + YantingColors.secondary, + YantingColors.secondaryForeground, + Colors.transparent, + ), }; return DecoratedBox( decoration: BoxDecoration( color: colors.$1, - borderRadius: BorderRadius.circular(WiseRadius.pill), + border: colors.$3 == Colors.transparent + ? null + : Border.all(color: colors.$3), + borderRadius: BorderRadius.circular(YantingRadius.sm), ), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3), child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -39,7 +63,9 @@ class AppBadge extends StatelessWidget { ], Text( text, - style: Theme.of(context).textTheme.labelSmall?.copyWith(color: colors.$2), + style: + (Theme.of(context).textTheme.labelSmall ?? YantingText.badge) + .copyWith(color: colors.$2, letterSpacing: 0), ), ], ), @@ -64,16 +90,33 @@ class AppChip extends StatelessWidget { @override Widget build(BuildContext context) { - return ActionChip( - onPressed: onTap, - label: Text(label), - labelStyle: TextStyle( - color: selected ? Colors.white : WiseColors.textSecondary, - fontWeight: FontWeight.w700, + final background = selected + ? YantingColors.foreground + : YantingColors.secondary; + final foreground = selected + ? YantingColors.background + : YantingColors.secondaryForeground; + return Material( + color: Colors.transparent, + child: Ink( + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(YantingRadius.pill), + ), + child: InkWell( + borderRadius: BorderRadius.circular(YantingRadius.pill), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 17, vertical: 9), + child: Text( + label, + style: + (Theme.of(context).textTheme.labelLarge ?? YantingText.chip) + .copyWith(color: foreground, letterSpacing: 0), + ), + ), + ), ), - backgroundColor: selected ? WiseColors.primary : WiseColors.surface, - side: const BorderSide(color: WiseColors.border), - shape: const StadiumBorder(), ); } } diff --git a/lib/widgets/bottom_tab_bar.dart b/lib/widgets/bottom_tab_bar.dart new file mode 100644 index 0000000..72b1518 --- /dev/null +++ b/lib/widgets/bottom_tab_bar.dart @@ -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 items; + final int selectedIndex; + final ValueChanged 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, + ), +]; diff --git a/lib/widgets/institution_card.dart b/lib/widgets/institution_card.dart new file mode 100644 index 0000000..c9f29d8 --- /dev/null +++ b/lib/widgets/institution_card.dart @@ -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, + ), + ); + } +} diff --git a/lib/widgets/mini_player.dart b/lib/widgets/mini_player.dart index 1f4ae54..31cfc15 100644 --- a/lib/widgets/mini_player.dart +++ b/lib/widgets/mini_player.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import '../data/models/models.dart'; +import '../theme/app_icons.dart'; +import '../theme/yanting_text.dart'; +import '../theme/yanting_tokens.dart'; import '../theme/wise_tokens.dart'; import 'app_card.dart'; @@ -47,11 +50,7 @@ class PlayerStateModel { } class MiniPlayer extends StatelessWidget { - const MiniPlayer({ - required this.player, - required this.onToggle, - super.key, - }); + const MiniPlayer({required this.player, required this.onToggle, super.key}); final PlayerStateModel player; final VoidCallback onToggle; @@ -59,54 +58,87 @@ class MiniPlayer extends StatelessWidget { @override Widget build(BuildContext context) { if (!player.hasAudio) return const SizedBox.shrink(); - final ratio = player.durationSec == 0 ? 0.0 : player.positionSec / player.durationSec; - return Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), - child: AppCard( - padding: const EdgeInsets.all(12), - color: WiseColors.primary, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + final ratio = player.durationSec == 0 + ? 0.0 + : player.positionSec / player.durationSec; + return DecoratedBox( + decoration: const BoxDecoration( + color: YantingColors.secondary, + border: Border(top: BorderSide(color: YantingColors.border)), + ), + child: Stack( + children: [ + 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: [ - IconButton.filled( - onPressed: onToggle, - icon: Icon(player.playing ? Icons.pause : Icons.play_arrow), - style: IconButton.styleFrom( - backgroundColor: WiseColors.secondary, - foregroundColor: WiseColors.primary, + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: YantingColors.primary, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + AppIcons.disc, + color: YantingColors.primaryForeground, + size: 20, ), ), - const SizedBox(width: WiseSpacing.x2), + const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ Text( player.title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w800), + style: YantingText.meta.copyWith( + color: YantingColors.foreground, + fontWeight: FontWeight.w600, + fontFeatures: null, + ), ), + const SizedBox(height: 2), Text( '${formatDuration(player.positionSec)} / ${formatDuration(player.durationSec)} · ${player.speed.toStringAsFixed(1)}x', - style: const TextStyle(color: Color(0xCCFFFFFF), fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: YantingText.meta.copyWith(fontSize: 11), ), ], ), ), + IconButton( + onPressed: onToggle, + icon: Icon( + player.playing ? AppIcons.pause : AppIcons.playCircle, + size: player.playing ? 24 : 28, + ), + color: YantingColors.foreground, + visualDensity: VisualDensity.compact, + ), ], ), - const SizedBox(height: WiseSpacing.x2), - LinearProgressIndicator( - value: ratio.clamp(0, 1), - minHeight: 4, - backgroundColor: const Color(0x33FFFFFF), - color: WiseColors.secondary, - ), - ], - ), + ), + ], ), ); } @@ -138,51 +170,126 @@ class PlayerCard extends StatelessWidget { final position = active ? player.positionSec : 0; final ratio = durationSec == 0 ? 0.0 : position / durationSec; return AppCard( - color: WiseColors.secondary200, + color: YantingColors.secondary, + borderColor: YantingColors.border, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('音频解读', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: WiseSpacing.x2), - Text(title, style: Theme.of(context).textTheme.bodyMedium), - const SizedBox(height: WiseSpacing.x3), + Text('音频解读', style: YantingText.listTitle), + const SizedBox(height: 6), + Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: YantingText.meta.copyWith(fontSize: 12.5), + ), + const SizedBox(height: 16), LinearProgressIndicator( value: ratio.clamp(0, 1), - minHeight: 6, - backgroundColor: Colors.white, - color: WiseColors.accent, + minHeight: 4, + borderRadius: BorderRadius.circular(YantingRadius.pill), + backgroundColor: YantingColors.border, + color: YantingColors.primary, ), const SizedBox(height: WiseSpacing.x2), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(formatDuration(position), style: Theme.of(context).textTheme.bodySmall), - Text(formatDuration(durationSec), style: Theme.of(context).textTheme.bodySmall), - ], - ), - const SizedBox(height: WiseSpacing.x3), - Row( - children: [ - IconButton.outlined(onPressed: () => onSeek(-15), icon: const Icon(Icons.replay_10)), - IconButton.filled( - onPressed: active ? onToggle : onStart, - icon: Icon(active && player.playing ? Icons.pause : Icons.play_arrow), - style: IconButton.styleFrom( - backgroundColor: WiseColors.primary, - foregroundColor: Colors.white, - ), + Text( + formatDuration(position), + style: YantingText.meta.copyWith(fontSize: 11), + ), + Text( + formatDuration(durationSec), + style: YantingText.meta.copyWith(fontSize: 11), ), - 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( '真实音频流待 /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, + ), + ), + ); + } +} diff --git a/lib/widgets/page_header.dart b/lib/widgets/page_header.dart new file mode 100644 index 0000000..60b0900 --- /dev/null +++ b/lib/widgets/page_header.dart @@ -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), + ], + ], + ), + ); + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index c12f4fd..0a14241 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -1,6 +1,9 @@ export 'app_buttons.dart'; export 'app_card.dart'; export 'badges.dart'; +export 'bottom_tab_bar.dart'; +export 'institution_card.dart'; export 'mini_player.dart'; +export 'page_header.dart'; export 'sheets.dart'; export 'states.dart'; diff --git a/pubspec.lock b/pubspec.lock index 914dd91..4584279 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -221,6 +221,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + remixicon: + dependency: "direct main" + description: + name: remixicon + sha256: "4b8e334b78b0fbf05fb7abe1b48f3c3df9e4a11ab767e3f3e7f1cc36dc1e046e" + url: "https://pub.dev" + source: hosted + version: "4.9.3" riverpod: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e2652bb..d56ef5c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,7 @@ dependencies: go_router: ^16.2.4 hooks_riverpod: ^2.6.1 phosphor_flutter: ^2.1.0 + remixicon: ^4.9.3 dev_dependencies: flutter_test: