fix:按照shadcn_ui对着demo_shadcn对齐

This commit is contained in:
jingyun
2026-06-05 15:04:39 +08:00
parent 9727b906c6
commit c5288f397d
29 changed files with 1425 additions and 642 deletions
+1
View File
@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig" #include "Generated.xcconfig"
+1
View File
@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig" #include "Generated.xcconfig"
+43
View File
@@ -0,0 +1,43 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Runner' do
use_frameworks!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
end
end
+18 -115
View File
@@ -1,8 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../routing/app_router.dart'; import '../routing/app_router.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import '../theme/yanting_text.dart';
import '../theme/yanting_shad_theme.dart';
class ReportNotebooklmApp extends ConsumerWidget { class ReportNotebooklmApp extends ConsumerWidget {
const ReportNotebooklmApp({super.key}); const ReportNotebooklmApp({super.key});
@@ -10,125 +14,24 @@ class ReportNotebooklmApp extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(routerProvider); final router = ref.watch(routerProvider);
return MaterialApp.router( final dmSansStyle = GoogleFonts.dmSans().copyWith(
fontFamilyFallback: YantingText.fontFallback,
);
return ShadApp.router(
title: '研听', title: '研听',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: buildAppTheme(), theme: buildYantingShadTheme(),
scrollBehavior: const WhitespaceStretchScrollBehavior(), darkTheme: buildYantingDarkShadTheme(),
routerConfig: router, routerConfig: router,
); scrollBehavior: const ShadScrollBehavior(),
} materialThemeBuilder: (context, theme) => buildAppTheme(),
} builder: (context, child) {
return DefaultTextStyle.merge(
class WhitespaceStretchScrollBehavior extends MaterialScrollBehavior { style: TextStyle(fontFamilyFallback: dmSansStyle.fontFamilyFallback),
const WhitespaceStretchScrollBehavior(); child: child ?? const SizedBox.shrink(),
@override
Widget buildOverscrollIndicator(
BuildContext context,
Widget child,
ScrollableDetails details,
) {
return _WhitespaceStretchIndicator(child: child);
}
}
class _WhitespaceStretchIndicator extends StatefulWidget {
const _WhitespaceStretchIndicator({required this.child});
final Widget child;
@override
State<_WhitespaceStretchIndicator> createState() =>
_WhitespaceStretchIndicatorState();
}
class _WhitespaceStretchIndicatorState
extends State<_WhitespaceStretchIndicator>
with SingleTickerProviderStateMixin {
static const double _maxStretch = 64;
static const double _dragResistance = 0.38;
late final AnimationController _offsetController =
AnimationController.unbounded(vsync: this)..addListener(_onTick);
@override
Widget build(BuildContext context) {
return NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: ClipRect(
child: Transform.translate(
offset: Offset(0, _offsetController.value),
child: widget.child,
),
),
);
}
@override
void dispose() {
_offsetController.dispose();
super.dispose();
}
bool _handleScrollNotification(ScrollNotification notification) {
if (notification.metrics.axis != Axis.vertical) {
return false;
}
if (notification is OverscrollNotification) {
final overscroll = notification.overscroll;
final atTop =
notification.metrics.pixels <= notification.metrics.minScrollExtent;
final atBottom =
notification.metrics.pixels >= notification.metrics.maxScrollExtent;
if (atTop && overscroll < 0) {
_setOffset(
(_offsetController.value - overscroll * _dragResistance).clamp(
0,
_maxStretch,
),
); );
} else if (atBottom && overscroll > 0) { },
_setOffset(
(_offsetController.value - overscroll * _dragResistance).clamp(
-_maxStretch,
0,
),
);
}
}
if (notification is ScrollUpdateNotification &&
notification.dragDetails == null) {
_releaseOffset();
}
if (notification is ScrollEndNotification) {
_releaseOffset();
}
return false;
}
void _setOffset(num next) {
if (next == _offsetController.value) {
return;
}
_offsetController.stop();
_offsetController.value = next.toDouble();
}
void _releaseOffset() {
if (_offsetController.value == 0) {
return;
}
_offsetController.animateTo(
0,
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
); );
} }
void _onTick() {
if (mounted) {
setState(() {});
}
}
} }
@@ -1,16 +1,18 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../data/api/report_data_source.dart'; import '../../../data/api/report_data_source.dart';
import '../../../data/models/models.dart'; import '../../../data/models/models.dart';
import '../../../theme/app_icons.dart'; import '../../../theme/app_icons.dart';
import '../../../theme/yanting_text.dart'; import '../../../theme/yanting_text.dart';
import '../../../theme/yanting_tokens.dart'; import '../../../theme/yanting_tokens.dart';
import '../../../theme/wise_tokens.dart'; import '../../../widgets/app_buttons.dart';
import '../../../widgets/app_card.dart'; import '../../../widgets/app_card.dart';
import '../../../widgets/badges.dart'; import '../../../widgets/badges.dart';
import '../../../widgets/mini_player.dart'; import '../../../widgets/mini_player.dart';
import '../../../widgets/states.dart';
typedef StartModuleAudio = typedef StartModuleAudio =
void Function( void Function(
@@ -53,7 +55,7 @@ class ModuleRendererRegistry {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_ModuleHeader(module: module), _ModuleHeader(module: module),
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: YantingSpacing.x3),
_contentFor( _contentFor(
context, context,
type: module.type, type: module.type,
@@ -69,13 +71,14 @@ class ModuleRendererRegistry {
compact: module.renderMode != 'inline', compact: module.renderMode != 'inline',
), ),
if (module.hasDetailPage) ...[ if (module.hasDetailPage) ...[
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: YantingSpacing.x3),
Align( Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: TextButton.icon( child: AppButton(
onPressed: openDetail, onPressed: openDetail,
icon: const Icon(AppIcons.externalLink), icon: AppIcons.externalLink,
label: const Text('查看详情'), kind: AppButtonKind.ghost,
label: '查看详情',
), ),
), ),
], ],
@@ -180,19 +183,31 @@ class ModuleDetailPage extends HookConsumerWidget {
[dataSource, reportId, module.id, retryCount.value], [dataSource, reportId, module.id, retryCount.value],
); );
final snapshot = useFuture(future); final snapshot = useFuture(future);
final theme = ShadTheme.of(context);
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(module.titleCn)), backgroundColor: theme.colorScheme.background,
appBar: AppBar(
backgroundColor: theme.colorScheme.background,
surfaceTintColor: Colors.transparent,
elevation: 0,
title: Text(module.titleCn),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1),
child: ColoredBox(
color: theme.colorScheme.border,
child: const SizedBox(height: 1, width: double.infinity),
),
),
),
body: snapshot.connectionState != ConnectionState.done body: snapshot.connectionState != ConnectionState.done
? const Center(child: CircularProgressIndicator()) ? const LoadingState()
: snapshot.hasError : snapshot.hasError
? Center( ? Center(
child: TextButton( child: AppButton(
onPressed: () => retryCount.value++, onPressed: () => retryCount.value++,
child: Text( kind: AppButtonKind.ghost,
snapshot.error.toString(), label: snapshot.error.toString(),
textAlign: TextAlign.center,
),
), ),
) )
: _ModuleDetailContent( : _ModuleDetailContent(
@@ -218,13 +233,18 @@ class _ModuleDetailContent extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListView( return ListView(
padding: const EdgeInsets.fromLTRB(WiseSpacing.x4, 4, WiseSpacing.x4, 16), padding: const EdgeInsets.fromLTRB(
YantingSpacing.x4,
4,
YantingSpacing.x4,
16,
),
children: [ children: [
Text( Text(
detail.titleCn, detail.titleCn,
style: YantingText.sectionTitle.copyWith(fontSize: 21), style: YantingText.sectionTitle.copyWith(fontSize: 21),
), ),
const SizedBox(height: WiseSpacing.x2), const SizedBox(height: YantingSpacing.x2),
AppCard( AppCard(
child: registry.page( child: registry.page(
context, context,
@@ -233,7 +253,7 @@ class _ModuleDetailContent extends StatelessWidget {
report: report, report: report,
), ),
), ),
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: YantingSpacing.x3),
Text('缓存版本 ${detail.cacheVersion}', style: YantingText.meta), Text('缓存版本 ${detail.cacheVersion}', style: YantingText.meta),
], ],
); );
@@ -283,10 +303,10 @@ class _BasicInfo extends StatelessWidget {
), ),
style: YantingText.body, style: YantingText.body,
), ),
const SizedBox(height: WiseSpacing.x2), const SizedBox(height: YantingSpacing.x2),
Wrap( Wrap(
spacing: WiseSpacing.x2, spacing: YantingSpacing.x2,
runSpacing: WiseSpacing.x2, runSpacing: YantingSpacing.x2,
children: [ children: [
for (final topic in topics) AppBadge(text: topic), for (final topic in topics) AppBadge(text: topic),
if (report?.releasedAt != null) if (report?.releasedAt != null)
@@ -312,8 +332,8 @@ class _CoreInsights extends StatelessWidget {
children: [ children: [
for (final point in points) for (final point in points)
Container( Container(
margin: const EdgeInsets.only(bottom: WiseSpacing.x3), margin: const EdgeInsets.only(bottom: YantingSpacing.x3),
padding: const EdgeInsets.all(WiseSpacing.x3), padding: const EdgeInsets.all(YantingSpacing.x3),
decoration: BoxDecoration( decoration: BoxDecoration(
color: YantingColors.background, color: YantingColors.background,
border: Border.all(color: YantingColors.border), border: Border.all(color: YantingColors.border),
@@ -326,7 +346,7 @@ class _CoreInsights extends StatelessWidget {
text: _kindLabel(asString(point['kind'])), text: _kindLabel(asString(point['kind'])),
kind: _kindBadge(asString(point['kind'])), kind: _kindBadge(asString(point['kind'])),
), ),
const SizedBox(height: WiseSpacing.x1), const SizedBox(height: YantingSpacing.x1),
Text(asString(point['text']), style: YantingText.body), Text(asString(point['text']), style: YantingText.body),
], ],
), ),
@@ -351,9 +371,9 @@ class _SourceCompliance extends StatelessWidget {
if (asString(payload['source_note']).isNotEmpty) if (asString(payload['source_note']).isNotEmpty)
Text(asString(payload['source_note']), style: YantingText.body), Text(asString(payload['source_note']), style: YantingText.body),
if (institution != null) ...[ if (institution != null) ...[
const SizedBox(height: WiseSpacing.x4), const SizedBox(height: YantingSpacing.x4),
Text('发布机构', style: YantingText.cardTitle.copyWith(fontSize: 17)), Text('发布机构', style: YantingText.cardTitle.copyWith(fontSize: 17)),
const SizedBox(height: WiseSpacing.x2), const SizedBox(height: YantingSpacing.x2),
_InfoLine(label: '机构名称', value: institution.nameCn), _InfoLine(label: '机构名称', value: institution.nameCn),
if (institution.nameEn.isNotEmpty) if (institution.nameEn.isNotEmpty)
_InfoLine(label: '英文名称', value: institution.nameEn), _InfoLine(label: '英文名称', value: institution.nameEn),
@@ -377,10 +397,10 @@ class _SourceCompliance extends StatelessWidget {
_InfoLine(label: '说明', value: institution.introCn), _InfoLine(label: '说明', value: institution.introCn),
], ],
if (asString(payload['copyright_cn']).isNotEmpty) ...[ if (asString(payload['copyright_cn']).isNotEmpty) ...[
const SizedBox(height: WiseSpacing.x4), const SizedBox(height: YantingSpacing.x4),
Text(asString(payload['copyright_cn']), style: YantingText.meta), Text(asString(payload['copyright_cn']), style: YantingText.meta),
], ],
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: YantingSpacing.x3),
DecoratedBox( DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: YantingColors.background, color: YantingColors.background,
@@ -388,7 +408,7 @@ class _SourceCompliance extends StatelessWidget {
borderRadius: BorderRadius.circular(YantingRadius.md), borderRadius: BorderRadius.circular(YantingRadius.md),
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.all(WiseSpacing.x3), padding: const EdgeInsets.all(YantingSpacing.x3),
child: Text( child: Text(
asString(payload['disclaimer'], '本内容为公开/授权研报的结构化解读,不构成投资建议。'), asString(payload['disclaimer'], '本内容为公开/授权研报的结构化解读,不构成投资建议。'),
style: YantingText.meta.copyWith(color: YantingColors.warning), style: YantingText.meta.copyWith(color: YantingColors.warning),
@@ -410,7 +430,7 @@ class _InfoLine extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (value.isEmpty) return const SizedBox.shrink(); if (value.isEmpty) return const SizedBox.shrink();
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x2), padding: const EdgeInsets.only(bottom: YantingSpacing.x2),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -420,7 +440,7 @@ class _InfoLine extends StatelessWidget {
color: YantingColors.mutedForeground, color: YantingColors.mutedForeground,
), ),
), ),
const SizedBox(height: WiseSpacing.x1), const SizedBox(height: YantingSpacing.x1),
Text(value, style: YantingText.body), Text(value, style: YantingText.body),
], ],
), ),
@@ -480,7 +500,7 @@ class _InstitutionModule extends StatelessWidget {
return Row( return Row(
children: [ children: [
const Icon(AppIcons.bank, color: YantingColors.foreground), const Icon(AppIcons.bank, color: YantingColors.foreground),
const SizedBox(width: WiseSpacing.x2), const SizedBox(width: YantingSpacing.x2),
Expanded(child: Text(name, style: YantingText.body)), Expanded(child: Text(name, style: YantingText.body)),
Text( Text(
'${asInt(payload['report_count'], report?.institution.reportCount ?? 0)}', '${asInt(payload['report_count'], report?.institution.reportCount ?? 0)}',
@@ -510,12 +530,12 @@ class _SectionsModule extends StatelessWidget {
children: [ children: [
if (summary.isNotEmpty) Text(summary, style: YantingText.body), if (summary.isNotEmpty) Text(summary, style: YantingText.body),
for (final section in sections) ...[ for (final section in sections) ...[
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: YantingSpacing.x3),
Text( Text(
asString(section['heading']), asString(section['heading']),
style: YantingText.cardTitle.copyWith(fontSize: 17), style: YantingText.cardTitle.copyWith(fontSize: 17),
), ),
const SizedBox(height: WiseSpacing.x1), const SizedBox(height: YantingSpacing.x1),
Text(asString(section['body']), style: YantingText.body), Text(asString(section['body']), style: YantingText.body),
], ],
], ],
@@ -538,8 +558,8 @@ class _KeyDataModule extends StatelessWidget {
children: [ children: [
for (final row in rows) for (final row in rows)
Container( Container(
margin: const EdgeInsets.only(bottom: WiseSpacing.x3), margin: const EdgeInsets.only(bottom: YantingSpacing.x3),
padding: const EdgeInsets.all(WiseSpacing.x3), padding: const EdgeInsets.all(YantingSpacing.x3),
decoration: BoxDecoration( decoration: BoxDecoration(
color: YantingColors.secondary, color: YantingColors.secondary,
borderRadius: BorderRadius.circular(YantingRadius.md), borderRadius: BorderRadius.circular(YantingRadius.md),
@@ -562,7 +582,7 @@ class _KeyDataModule extends StatelessWidget {
], ],
), ),
), ),
const SizedBox(width: WiseSpacing.x2), const SizedBox(width: YantingSpacing.x2),
Text( Text(
_valueWithUnit(row), _valueWithUnit(row),
textAlign: TextAlign.right, textAlign: TextAlign.right,
@@ -635,10 +655,10 @@ class _TimelineEntry extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(width: WiseSpacing.x2), const SizedBox(width: YantingSpacing.x2),
Expanded( Expanded(
child: Padding( child: Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x3), padding: const EdgeInsets.only(bottom: YantingSpacing.x3),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -650,12 +670,12 @@ class _TimelineEntry extends StatelessWidget {
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
const SizedBox(height: WiseSpacing.x1), const SizedBox(height: YantingSpacing.x1),
Text( Text(
asString(event['event']), asString(event['event']),
style: YantingText.cardTitle.copyWith(fontSize: 17), style: YantingText.cardTitle.copyWith(fontSize: 17),
), ),
const SizedBox(height: WiseSpacing.x1), const SizedBox(height: YantingSpacing.x1),
Text(asString(event['impact']), style: YantingText.body), Text(asString(event['impact']), style: YantingText.body),
], ],
), ),
@@ -695,10 +715,10 @@ class _StudyGuideModule extends StatelessWidget {
], ],
), ),
if (glossary.isNotEmpty) ...[ if (glossary.isNotEmpty) ...[
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: YantingSpacing.x3),
Wrap( Wrap(
spacing: WiseSpacing.x2, spacing: YantingSpacing.x2,
runSpacing: WiseSpacing.x2, runSpacing: YantingSpacing.x2,
children: [ children: [
for (final item in glossary) for (final item in glossary)
AppBadge( AppBadge(
@@ -730,10 +750,10 @@ class _StructureGraphModule extends StatelessWidget {
asString(payload['root']), asString(payload['root']),
style: YantingText.cardTitle.copyWith(fontSize: 17), style: YantingText.cardTitle.copyWith(fontSize: 17),
), ),
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: YantingSpacing.x3),
for (final node in nodes) for (final node in nodes)
Padding( Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x4), padding: const EdgeInsets.only(bottom: YantingSpacing.x4),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -741,10 +761,10 @@ class _StructureGraphModule extends StatelessWidget {
asString(node['label']), asString(node['label']),
style: YantingText.cardTitle.copyWith(fontSize: 17), style: YantingText.cardTitle.copyWith(fontSize: 17),
), ),
const SizedBox(height: WiseSpacing.x1), const SizedBox(height: YantingSpacing.x1),
for (final child in asStringList(node['children'])) for (final child in asStringList(node['children']))
Padding( Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x1), padding: const EdgeInsets.only(bottom: YantingSpacing.x1),
child: Text(child, style: YantingText.body), child: Text(child, style: YantingText.body),
), ),
], ],
@@ -770,7 +790,7 @@ class _RelatedSourcesModule extends StatelessWidget {
children: [ children: [
for (final item in items) for (final item in items)
Padding( Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x4), padding: const EdgeInsets.only(bottom: YantingSpacing.x4),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -778,7 +798,7 @@ class _RelatedSourcesModule extends StatelessWidget {
asString(item['title']), asString(item['title']),
style: YantingText.cardTitle.copyWith(fontSize: 17), style: YantingText.cardTitle.copyWith(fontSize: 17),
), ),
const SizedBox(height: WiseSpacing.x1), const SizedBox(height: YantingSpacing.x1),
Text( Text(
asString(item['summary_cn'], asString(item['source_name'])), asString(item['summary_cn'], asString(item['source_name'])),
style: YantingText.body, style: YantingText.body,
@@ -809,7 +829,7 @@ class _DifferentiatedViewModule extends StatelessWidget {
children: [ children: [
for (final item in items) for (final item in items)
Padding( Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x4), padding: const EdgeInsets.only(bottom: YantingSpacing.x4),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -817,7 +837,7 @@ class _DifferentiatedViewModule extends StatelessWidget {
asString(item['topic']), asString(item['topic']),
style: YantingText.cardTitle.copyWith(fontSize: 17), style: YantingText.cardTitle.copyWith(fontSize: 17),
), ),
const SizedBox(height: WiseSpacing.x2), const SizedBox(height: YantingSpacing.x2),
if (asString(item['consensus_view']).isNotEmpty) ...[ if (asString(item['consensus_view']).isNotEmpty) ...[
Text( Text(
'常见观点', '常见观点',
@@ -825,12 +845,12 @@ class _DifferentiatedViewModule extends StatelessWidget {
color: YantingColors.mutedForeground, color: YantingColors.mutedForeground,
), ),
), ),
const SizedBox(height: WiseSpacing.x1), const SizedBox(height: YantingSpacing.x1),
Text( Text(
asString(item['consensus_view']), asString(item['consensus_view']),
style: YantingText.body, style: YantingText.body,
), ),
const SizedBox(height: WiseSpacing.x2), const SizedBox(height: YantingSpacing.x2),
], ],
if (asString(item['report_position']).isNotEmpty) ...[ if (asString(item['report_position']).isNotEmpty) ...[
Text( Text(
@@ -839,7 +859,7 @@ class _DifferentiatedViewModule extends StatelessWidget {
color: YantingColors.foreground, color: YantingColors.foreground,
), ),
), ),
const SizedBox(height: WiseSpacing.x1), const SizedBox(height: YantingSpacing.x1),
Text( Text(
asString(item['report_position']), asString(item['report_position']),
style: YantingText.body, style: YantingText.body,
@@ -877,8 +897,8 @@ class _WeaknessesModule extends StatelessWidget {
for (final item in items) for (final item in items)
Padding( Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
top: WiseSpacing.x3, top: YantingSpacing.x3,
bottom: WiseSpacing.x2, bottom: YantingSpacing.x2,
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -887,20 +907,20 @@ class _WeaknessesModule extends StatelessWidget {
asString(item['topic']), asString(item['topic']),
style: YantingText.cardTitle.copyWith(fontSize: 17), style: YantingText.cardTitle.copyWith(fontSize: 17),
), ),
const SizedBox(height: WiseSpacing.x1), const SizedBox(height: YantingSpacing.x1),
Text(asString(item['weakness']), style: YantingText.body), Text(asString(item['weakness']), style: YantingText.body),
], ],
), ),
), ),
if (verificationNotes.isNotEmpty || counterEvidence.isNotEmpty) ...[ if (verificationNotes.isNotEmpty || counterEvidence.isNotEmpty) ...[
const SizedBox(height: WiseSpacing.x2), const SizedBox(height: YantingSpacing.x2),
DecoratedBox( DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0x109A6500), color: const Color(0x109A6500),
borderRadius: BorderRadius.circular(WiseRadius.sm), borderRadius: BorderRadius.circular(YantingRadius.sm),
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.all(WiseSpacing.x3), padding: const EdgeInsets.all(YantingSpacing.x3),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -910,13 +930,13 @@ class _WeaknessesModule extends StatelessWidget {
color: YantingColors.warning, color: YantingColors.warning,
), ),
), ),
const SizedBox(height: WiseSpacing.x1), const SizedBox(height: YantingSpacing.x1),
for (final note for (final note
in verificationNotes.isNotEmpty in verificationNotes.isNotEmpty
? verificationNotes ? verificationNotes
: counterEvidence) : counterEvidence)
Padding( Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x1), padding: const EdgeInsets.only(bottom: YantingSpacing.x1),
child: Text(note, style: YantingText.meta), child: Text(note, style: YantingText.meta),
), ),
], ],
@@ -947,7 +967,7 @@ class _Preview extends StatelessWidget {
if (headline.isNotEmpty) Text(headline, style: YantingText.body), if (headline.isNotEmpty) Text(headline, style: YantingText.body),
for (final item in highlights.take(3)) for (final item in highlights.take(3))
Padding( Padding(
padding: const EdgeInsets.only(top: WiseSpacing.x1), padding: const EdgeInsets.only(top: YantingSpacing.x1),
child: Text('$item', style: YantingText.meta), child: Text('$item', style: YantingText.meta),
), ),
], ],
@@ -988,7 +1008,7 @@ class _FallbackModule extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
AppBadge(text: '未知模块:$type', kind: BadgeKind.warning), AppBadge(text: '未知模块:$type', kind: BadgeKind.warning),
const SizedBox(height: WiseSpacing.x2), const SizedBox(height: YantingSpacing.x2),
_Preview(payload: payload), _Preview(payload: payload),
], ],
); );
+33 -14
View File
@@ -1,13 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../data/api/report_data_source.dart'; import '../../data/api/report_data_source.dart';
import '../../data/models/models.dart'; import '../../data/models/models.dart';
import '../../theme/app_icons.dart'; import '../../theme/app_icons.dart';
import '../../theme/yanting_text.dart'; import '../../theme/yanting_text.dart';
import '../../theme/yanting_tokens.dart'; import '../../theme/yanting_tokens.dart';
import '../../theme/wise_tokens.dart';
import '../../widgets/app_buttons.dart'; import '../../widgets/app_buttons.dart';
import '../../widgets/app_card.dart'; import '../../widgets/app_card.dart';
import '../../widgets/badges.dart'; import '../../widgets/badges.dart';
@@ -52,9 +52,23 @@ class ReportDetailPage extends HookConsumerWidget {
]); ]);
final snapshot = useFuture(detailFuture); final snapshot = useFuture(detailFuture);
const registry = ModuleRendererRegistry(); const registry = ModuleRendererRegistry();
final theme = ShadTheme.of(context);
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('研报详情')), backgroundColor: theme.colorScheme.background,
appBar: AppBar(
backgroundColor: theme.colorScheme.background,
surfaceTintColor: Colors.transparent,
elevation: 0,
title: const Text('研报详情'),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1),
child: ColoredBox(
color: theme.colorScheme.border,
child: const SizedBox(height: 1, width: double.infinity),
),
),
),
body: snapshot.connectionState != ConnectionState.done body: snapshot.connectionState != ConnectionState.done
? const LoadingState() ? const LoadingState()
: snapshot.hasError : snapshot.hasError
@@ -106,7 +120,12 @@ class _ReportDetailContent extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListView( return ListView(
padding: const EdgeInsets.fromLTRB(WiseSpacing.x4, 4, WiseSpacing.x4, 16), padding: const EdgeInsets.fromLTRB(
YantingSpacing.x4,
4,
YantingSpacing.x4,
16,
),
children: [ children: [
AppCard( AppCard(
color: YantingColors.brandSoft, color: YantingColors.brandSoft,
@@ -115,8 +134,8 @@ class _ReportDetailContent extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Wrap( Wrap(
spacing: WiseSpacing.x2, spacing: YantingSpacing.x2,
runSpacing: WiseSpacing.x2, runSpacing: YantingSpacing.x2,
children: [ children: [
AppBadge( AppBadge(
text: detail.interpretationLabel, text: detail.interpretationLabel,
@@ -134,7 +153,7 @@ class _ReportDetailContent extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: YantingSpacing.x3),
Text( Text(
detail.titleCn, detail.titleCn,
maxLines: 3, maxLines: 3,
@@ -142,10 +161,10 @@ class _ReportDetailContent extends StatelessWidget {
style: YantingText.sectionTitle.copyWith(fontSize: 21), style: YantingText.sectionTitle.copyWith(fontSize: 21),
), ),
if (detail.oneLiner.isNotEmpty) ...[ if (detail.oneLiner.isNotEmpty) ...[
const SizedBox(height: WiseSpacing.x2), const SizedBox(height: YantingSpacing.x2),
Text(detail.oneLiner, style: YantingText.body), Text(detail.oneLiner, style: YantingText.body),
], ],
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: YantingSpacing.x3),
Text( Text(
'${detail.institution.nameCn} · ${formatDate(detail.releasedAt)}', '${detail.institution.nameCn} · ${formatDate(detail.releasedAt)}',
style: YantingText.meta, style: YantingText.meta,
@@ -153,11 +172,11 @@ class _ReportDetailContent extends StatelessWidget {
], ],
), ),
), ),
const SizedBox(height: WiseSpacing.x4), const SizedBox(height: YantingSpacing.x4),
_ActionBar(detail: detail), _ActionBar(detail: detail),
const SizedBox(height: WiseSpacing.x4), const SizedBox(height: YantingSpacing.x4),
_Toc(modules: detail.modules), _Toc(modules: detail.modules),
const SizedBox(height: WiseSpacing.x4), const SizedBox(height: YantingSpacing.x4),
for (final module in detail.modules) ...[ for (final module in detail.modules) ...[
registry.card( registry.card(
context: context, context: context,
@@ -170,7 +189,7 @@ class _ReportDetailContent extends StatelessWidget {
onSeekAudio: onSeekAudio, onSeekAudio: onSeekAudio,
onSpeed: onSpeed, onSpeed: onSpeed,
), ),
const SizedBox(height: WiseSpacing.x4), const SizedBox(height: YantingSpacing.x4),
], ],
], ],
); );
@@ -194,7 +213,7 @@ class _ActionBar extends StatelessWidget {
onPressed: () => showLoginSheet(context, reason: '登录后保存到你的收藏'), onPressed: () => showLoginSheet(context, reason: '登录后保存到你的收藏'),
), ),
), ),
const SizedBox(width: WiseSpacing.x2), const SizedBox(width: YantingSpacing.x2),
Expanded( Expanded(
child: AppButton( child: AppButton(
label: '原文', label: '原文',
@@ -222,7 +241,7 @@ class _Toc extends StatelessWidget {
children: [ children: [
for (final module in modules) for (final module in modules)
Padding( Padding(
padding: const EdgeInsets.only(right: WiseSpacing.x2), padding: const EdgeInsets.only(right: YantingSpacing.x2),
child: AppBadge(text: module.titleCn, kind: BadgeKind.brand), child: AppBadge(text: module.titleCn, kind: BadgeKind.brand),
), ),
], ],
+10 -7
View File
@@ -1,12 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../data/api/report_data_source.dart'; import '../../data/api/report_data_source.dart';
import '../../data/content_providers.dart'; import '../../data/content_providers.dart';
import '../../data/models/models.dart'; import '../../data/models/models.dart';
import '../../routing/app_routes.dart'; import '../../routing/app_routes.dart';
import '../../theme/wise_tokens.dart'; import '../../theme/yanting_tokens.dart';
import '../../widgets/badges.dart'; import '../../widgets/badges.dart';
import '../../widgets/mini_player.dart'; import '../../widgets/mini_player.dart';
import '../../widgets/page_header.dart'; import '../../widgets/page_header.dart';
@@ -66,9 +67,9 @@ class FeedPage extends HookConsumerWidget {
} }
return ListView( return ListView(
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
WiseSpacing.x4, YantingSpacing.screenX,
4, 4,
WiseSpacing.x4, YantingSpacing.screenX,
16, 16,
), ),
children: [ children: [
@@ -79,7 +80,7 @@ class FeedPage extends HookConsumerWidget {
children: [ children: [
for (final t in topics) for (final t in topics)
Padding( Padding(
padding: const EdgeInsets.only(right: WiseSpacing.x2), padding: const EdgeInsets.only(right: YantingSpacing.x2),
child: AppChip( child: AppChip(
label: t, label: t,
selected: t == currentTopic, selected: t == currentTopic,
@@ -89,7 +90,9 @@ class FeedPage extends HookConsumerWidget {
], ],
), ),
), ),
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: YantingSpacing.x3),
const ShadSeparator.horizontal(),
const SizedBox(height: YantingSpacing.x3),
if (visible.isEmpty) if (visible.isEmpty)
const EmptyState( const EmptyState(
title: '暂无可推荐的研报解读', title: '暂无可推荐的研报解读',
@@ -112,7 +115,7 @@ class FeedPage extends HookConsumerWidget {
), ),
onPlayTap: () => _playFromReport(onPlay, visible.first), onPlayTap: () => _playFromReport(onPlay, visible.first),
), ),
const SizedBox(height: WiseSpacing.x5), const SizedBox(height: YantingSpacing.x6),
const SectionTitle(title: '最新解读', icon: Icons.chevron_right), const SectionTitle(title: '最新解读', icon: Icons.chevron_right),
for (final report in visible.skip(1)) ...[ for (final report in visible.skip(1)) ...[
ReportCardWidget( ReportCardWidget(
@@ -129,7 +132,7 @@ class FeedPage extends HookConsumerWidget {
), ),
onPlayTap: () => _playFromReport(onPlay, report), onPlayTap: () => _playFromReport(onPlay, report),
), ),
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: YantingSpacing.x3),
], ],
], ],
], ],
+221
View File
@@ -0,0 +1,221 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../data/api/report_data_source.dart';
import '../../data/content_providers.dart';
import '../../data/models/models.dart';
import '../../routing/app_routes.dart';
import '../../theme/app_icons.dart';
import '../../theme/yanting_text.dart';
import '../../theme/yanting_tokens.dart';
import '../../widgets/app_card.dart';
import '../../widgets/badges.dart';
import '../../widgets/mini_player.dart';
import '../../widgets/page_header.dart';
import '../../widgets/states.dart';
import '../shared/report_card_widget.dart';
class HomePage extends HookConsumerWidget {
const HomePage({
required this.dataSource,
required this.onPlay,
this.player = const PlayerStateModel(),
this.onStartModuleAudio,
this.onToggleAudio,
this.onSeekAudio,
this.onSpeed,
super.key,
});
final ReportDataSource dataSource;
final void Function(AudioItem item) onPlay;
final PlayerStateModel player;
final void Function(
String audioId,
String reportId,
String title,
int durationSec,
)?
onStartModuleAudio;
final VoidCallback? onToggleAudio;
final void Function(int delta)? onSeekAudio;
final VoidCallback? onSpeed;
@override
Widget build(BuildContext context, WidgetRef ref) {
final snapshot = ref.watch(recommendedReportsProvider);
return snapshot.when(
loading: () => const LoadingState(),
error: (error, _) => ErrorState(
message: error.toString(),
onRetry: () => ref.invalidate(recommendedReportsProvider),
),
data: (items) {
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
YantingSpacing.screenX,
4,
YantingSpacing.screenX,
16,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const PageHeader(title: '研听', subtitle: '全球机构研报中文解读'),
const SectionTitle(title: '推荐'),
if (items.isEmpty)
const EmptyState(title: '暂无可推荐的研报解读', message: '稍后再来看看最新内容')
else
ReportCardWidget(
report: items.first,
hero: true,
onTap: () => openReportDetail(
context,
dataSource,
items.first,
player: player,
onStartAudio: onStartModuleAudio,
onToggleAudio: onToggleAudio,
onSeekAudio: onSeekAudio,
onSpeed: onSpeed,
),
onPlayTap: () => _playFromReport(onPlay, items.first),
),
const SizedBox(height: YantingSpacing.x6),
for (final item in _directoryItems)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _DirectoryCard(item: item),
),
],
),
);
},
);
}
}
class _DirectoryItem {
const _DirectoryItem({
required this.title,
required this.subtitle,
required this.icon,
required this.path,
});
final String title;
final String subtitle;
final IconData icon;
final String path;
}
class _DirectoryCard extends StatelessWidget {
const _DirectoryCard({required this.item});
final _DirectoryItem item;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return AppCard(
onTap: () => context.push(item.path),
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: theme.colorScheme.secondary,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
item.icon,
size: 20,
color: theme.colorScheme.secondaryForeground,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(item.title, style: YantingText.listTitle),
),
if (item.title == '推荐') ...[
const SizedBox(width: 8),
const AppBadge(text: '首页', kind: BadgeKind.tier),
],
],
),
const SizedBox(height: 2),
Text(item.subtitle, style: YantingText.meta),
],
),
),
Icon(
LucideIcons.chevronRight,
size: 16,
color: theme.colorScheme.mutedForeground,
),
],
),
);
}
}
const _directoryItems = [
_DirectoryItem(
title: '推荐',
subtitle: '主题筛选后的重点研报解读',
icon: AppIcons.sparkle,
path: AppRoutes.home,
),
_DirectoryItem(
title: '研报',
subtitle: '搜索、筛选和浏览全部研报',
icon: AppIcons.article,
path: AppRoutes.reports,
),
_DirectoryItem(
title: '机构',
subtitle: '按机构查看来源与覆盖主题',
icon: AppIcons.bank,
path: AppRoutes.institutions,
),
_DirectoryItem(
title: '听单',
subtitle: '继续收听音频解读',
icon: AppIcons.headphones,
path: AppRoutes.listen,
),
_DirectoryItem(
title: '我的',
subtitle: '登录、收藏与合规说明',
icon: AppIcons.user,
path: AppRoutes.profile,
),
];
void _playFromReport(
void Function(AudioItem item) onPlay,
ReportCardModel report,
) {
onPlay(
AudioItem(
audioId: 'local_${report.id}',
reportId: report.id,
titleCn: report.titleCn,
reportTitleCn: report.titleCn,
durationSec: 180,
institution: report.institution,
),
);
}
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../data/api/report_data_source.dart'; import '../../data/api/report_data_source.dart';
import '../../data/models/models.dart'; import '../../data/models/models.dart';
@@ -8,7 +9,6 @@ import '../../routing/app_routes.dart';
import '../../theme/app_icons.dart'; import '../../theme/app_icons.dart';
import '../../theme/yanting_text.dart'; import '../../theme/yanting_text.dart';
import '../../theme/yanting_tokens.dart'; import '../../theme/yanting_tokens.dart';
import '../../theme/wise_tokens.dart';
import '../../widgets/app_buttons.dart'; import '../../widgets/app_buttons.dart';
import '../../widgets/app_card.dart'; import '../../widgets/app_card.dart';
import '../../widgets/badges.dart'; import '../../widgets/badges.dart';
@@ -35,9 +35,23 @@ class InstitutionDetailPage extends HookConsumerWidget {
[dataSource, institutionId, retryCount.value], [dataSource, institutionId, retryCount.value],
); );
final snapshot = useFuture(future); final snapshot = useFuture(future);
final theme = ShadTheme.of(context);
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('机构主页')), backgroundColor: theme.colorScheme.background,
appBar: AppBar(
backgroundColor: theme.colorScheme.background,
surfaceTintColor: Colors.transparent,
elevation: 0,
title: const Text('机构主页'),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1),
child: ColoredBox(
color: theme.colorScheme.border,
child: const SizedBox(height: 1, width: double.infinity),
),
),
),
body: snapshot.connectionState != ConnectionState.done body: snapshot.connectionState != ConnectionState.done
? const LoadingState() ? const LoadingState()
: snapshot.hasError : snapshot.hasError
@@ -65,7 +79,12 @@ class _InstitutionDetailContent extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListView( return ListView(
padding: const EdgeInsets.fromLTRB(WiseSpacing.x4, 4, WiseSpacing.x4, 16), padding: const EdgeInsets.fromLTRB(
YantingSpacing.x4,
4,
YantingSpacing.x4,
16,
),
children: [ children: [
AppCard( AppCard(
color: YantingColors.brandSoft, color: YantingColors.brandSoft,
@@ -116,26 +135,26 @@ class _InstitutionDetailContent extends StatelessWidget {
], ],
), ),
), ),
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: YantingSpacing.x3),
if (item.introCn.isNotEmpty) if (item.introCn.isNotEmpty)
AppCard(child: Text(item.introCn, style: YantingText.body)), AppCard(child: Text(item.introCn, style: YantingText.body)),
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: YantingSpacing.x3),
if (item.credibilityNote.isNotEmpty) if (item.credibilityNote.isNotEmpty)
AppCard( AppCard(
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Icon(AppIcons.shield, color: YantingColors.chart2), const Icon(AppIcons.shield, color: YantingColors.chart2),
const SizedBox(width: WiseSpacing.x2), const SizedBox(width: YantingSpacing.x2),
Expanded( Expanded(
child: Text(item.credibilityNote, style: YantingText.body), child: Text(item.credibilityNote, style: YantingText.body),
), ),
], ],
), ),
), ),
const SizedBox(height: WiseSpacing.x5), const SizedBox(height: YantingSpacing.x6),
Text('最新研报', style: YantingText.sectionTitle.copyWith(fontSize: 21)), Text('最新研报', style: YantingText.sectionTitle.copyWith(fontSize: 21)),
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: YantingSpacing.x3),
if (item.recentReports.isEmpty) if (item.recentReports.isEmpty)
const EmptyState( const EmptyState(
title: '机构暂无研报', title: '机构暂无研报',
@@ -148,7 +167,7 @@ class _InstitutionDetailContent extends StatelessWidget {
report: report, report: report,
onTap: () => openReportDetail(context, dataSource, report), onTap: () => openReportDetail(context, dataSource, report),
), ),
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: YantingSpacing.x3),
], ],
AppButton( AppButton(
label: '了解相关服务', label: '了解相关服务',
@@ -1,10 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../data/api/report_data_source.dart'; import '../../data/api/report_data_source.dart';
import '../../data/content_providers.dart'; import '../../data/content_providers.dart';
import '../../routing/app_routes.dart'; import '../../routing/app_routes.dart';
import '../../theme/wise_tokens.dart'; import '../../theme/yanting_tokens.dart';
import '../../widgets/institution_card.dart'; import '../../widgets/institution_card.dart';
import '../../widgets/page_header.dart'; import '../../widgets/page_header.dart';
import '../../widgets/states.dart'; import '../../widgets/states.dart';
@@ -35,20 +36,23 @@ class InstitutionsPage extends HookConsumerWidget {
} }
return ListView( return ListView(
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
WiseSpacing.x4, YantingSpacing.screenX,
4, 4,
WiseSpacing.x4, YantingSpacing.screenX,
16, 16,
), ),
children: [ children: [
const PageHeader(title: '机构', subtitle: '可获取研报的机构'), const PageHeader(title: '机构', subtitle: '可获取研报的机构'),
const SizedBox(height: YantingSpacing.x3),
const ShadSeparator.horizontal(),
const SizedBox(height: YantingSpacing.x3),
for (final item in sorted) ...[ for (final item in sorted) ...[
InstitutionCard( InstitutionCard(
institution: item, institution: item,
onTap: () => onTap: () =>
openInstitutionDetail(context, dataSource, item.id), openInstitutionDetail(context, dataSource, item.id),
), ),
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: YantingSpacing.x3),
], ],
], ],
); );
+15 -25
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../data/api/report_data_source.dart'; import '../../data/api/report_data_source.dart';
import '../../data/content_providers.dart'; import '../../data/content_providers.dart';
@@ -7,7 +8,6 @@ import '../../data/models/models.dart';
import '../../theme/app_icons.dart'; import '../../theme/app_icons.dart';
import '../../theme/yanting_text.dart'; import '../../theme/yanting_text.dart';
import '../../theme/yanting_tokens.dart'; import '../../theme/yanting_tokens.dart';
import '../../theme/wise_tokens.dart';
import '../../widgets/app_card.dart'; import '../../widgets/app_card.dart';
import '../../widgets/badges.dart'; import '../../widgets/badges.dart';
import '../../widgets/page_header.dart'; import '../../widgets/page_header.dart';
@@ -39,9 +39,9 @@ class ListenPage extends HookConsumerWidget {
final current = items.first; final current = items.first;
return ListView( return ListView(
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
WiseSpacing.x4, YantingSpacing.screenX,
4, 4,
WiseSpacing.x4, YantingSpacing.screenX,
16, 16,
), ),
children: [ children: [
@@ -52,9 +52,11 @@ class ListenPage extends HookConsumerWidget {
onPlay: () => onPlay(current), onPlay: () => onPlay(current),
), ),
const SectionTitle(title: '全部音频', icon: AppIcons.arrowRight), const SectionTitle(title: '全部音频', icon: AppIcons.arrowRight),
const ShadSeparator.horizontal(),
const SizedBox(height: YantingSpacing.x3),
for (final item in items.skip(1)) ...[ for (final item in items.skip(1)) ...[
_AudioListCard(item: item, onPlay: () => onPlay(item)), _AudioListCard(item: item, onPlay: () => onPlay(item)),
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: YantingSpacing.x3),
], ],
], ],
); );
@@ -113,26 +115,17 @@ class _ContinueListeningCard extends StatelessWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(
children: [ children: [
IconButton.filled( ShadButton(
onPressed: onPlay, onPressed: onPlay,
icon: const Icon(AppIcons.play), width: 48,
style: IconButton.styleFrom( height: 48,
backgroundColor: YantingColors.primary, child: const Icon(AppIcons.play, size: 18),
foregroundColor: YantingColors.primaryForeground,
fixedSize: const Size(48, 48),
),
), ),
const SizedBox(width: 13), const SizedBox(width: 13),
Expanded( Expanded(
child: Column( child: Column(
children: [ children: [
LinearProgressIndicator( const ShadProgress(value: 0.42),
value: 0.42,
minHeight: 5,
borderRadius: BorderRadius.circular(YantingRadius.pill),
backgroundColor: YantingColors.border,
color: YantingColors.primary,
),
const SizedBox(height: 8), const SizedBox(height: 8),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -204,14 +197,11 @@ class _AudioListCard extends StatelessWidget {
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
IconButton.filled( ShadButton(
onPressed: onPlay, onPressed: onPlay,
icon: const Icon(AppIcons.play), width: 44,
style: IconButton.styleFrom( height: 44,
backgroundColor: YantingColors.primary, child: const Icon(AppIcons.play, size: 16),
foregroundColor: YantingColors.primaryForeground,
fixedSize: const Size(44, 44),
),
), ),
], ],
), ),
+9 -5
View File
@@ -4,7 +4,6 @@ import '../../data/api/report_data_source.dart';
import '../../theme/app_icons.dart'; import '../../theme/app_icons.dart';
import '../../theme/yanting_text.dart'; import '../../theme/yanting_text.dart';
import '../../theme/yanting_tokens.dart'; import '../../theme/yanting_tokens.dart';
import '../../theme/wise_tokens.dart';
import '../../widgets/app_buttons.dart'; import '../../widgets/app_buttons.dart';
import '../../widgets/app_card.dart'; import '../../widgets/app_card.dart';
import '../../widgets/page_header.dart'; import '../../widgets/page_header.dart';
@@ -19,7 +18,12 @@ class ProfilePage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListView( return ListView(
padding: const EdgeInsets.fromLTRB(WiseSpacing.x4, 4, WiseSpacing.x4, 16), padding: const EdgeInsets.fromLTRB(
YantingSpacing.screenX,
4,
YantingSpacing.screenX,
16,
),
children: [ children: [
const PageHeader(title: '我的'), const PageHeader(title: '我的'),
AppCard( AppCard(
@@ -52,7 +56,7 @@ class ProfilePage extends StatelessWidget {
], ],
), ),
), ),
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: YantingSpacing.x3),
AppButton( AppButton(
label: '登录 / 注册', label: '登录 / 注册',
expand: true, expand: true,
@@ -69,7 +73,7 @@ class ProfilePage extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: YantingSpacing.x3),
_MenuGroup( _MenuGroup(
children: [ children: [
_MenuRow( _MenuRow(
@@ -89,7 +93,7 @@ class ProfilePage extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: YantingSpacing.x3),
AppCard( AppCard(
color: YantingColors.secondary, color: YantingColors.secondary,
onTap: () => showOutboundSheet(context, title: '相关服务'), onTap: () => showOutboundSheet(context, title: '相关服务'),
+173 -131
View File
@@ -1,17 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../data/api/report_data_source.dart'; import '../../data/api/report_data_source.dart';
import '../../data/content_providers.dart'; import '../../data/content_providers.dart';
import '../../data/models/models.dart'; import '../../data/models/models.dart';
import '../../routing/app_routes.dart'; import '../../routing/app_routes.dart';
import '../../theme/app_icons.dart';
import '../../theme/yanting_text.dart'; import '../../theme/yanting_text.dart';
import '../../theme/yanting_tokens.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/mini_player.dart';
import '../../widgets/page_header.dart'; import '../../widgets/page_header.dart';
import '../../widgets/states.dart'; import '../../widgets/states.dart';
@@ -45,6 +42,8 @@ class ReportsPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final theme = ShadTheme.of(context);
final searchController = useTextEditingController();
final query = useState(''); final query = useState('');
final topic = useState(''); final topic = useState('');
final hasAudio = useState(false); final hasAudio = useState(false);
@@ -67,104 +66,127 @@ class ReportsPage extends HookConsumerWidget {
hasAudio: currentHasAudio, hasAudio: currentHasAudio,
); );
return ListView( return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
WiseSpacing.x4, YantingSpacing.screenX,
4, 4,
WiseSpacing.x4, YantingSpacing.screenX,
16, 16,
), ),
children: [ child: Column(
const PageHeader(title: '研报', subtitle: '全部已发布研报解读'), crossAxisAlignment: CrossAxisAlignment.stretch,
TextField( children: [
decoration: InputDecoration( const PageHeader(title: '研报', subtitle: '全部已发布研报解读'),
hintText: '搜索标题、机构或主题', ShadInput(
prefixIcon: const Icon(AppIcons.search), controller: searchController,
suffixIcon: currentQuery.isEmpty placeholder: const Text('搜索标题、机构或主题'),
leading: const Padding(
padding: EdgeInsets.only(right: 8),
child: Icon(LucideIcons.search, size: 16),
),
trailing: currentQuery.isEmpty
? null ? null
: IconButton( : Padding(
onPressed: () => query.value = '', padding: const EdgeInsets.only(left: 8),
icon: const Icon(Icons.close), child: ShadButton.ghost(
), size: ShadButtonSize.sm,
border: OutlineInputBorder( onPressed: () {
borderRadius: BorderRadius.circular(YantingRadius.md), searchController.clear();
borderSide: const BorderSide(color: YantingColors.input), query.value = '';
), },
), child: const Icon(LucideIcons.x, size: 16),
onChanged: (value) => query.value = value.trim(),
),
const SizedBox(height: WiseSpacing.x3),
Row(
children: [
AppButton(
label: '筛选',
icon: AppIcons.filter,
kind: AppButtonKind.ghost,
onPressed: items.isEmpty
? null
: () => _openFilterSheet(
context,
items: items,
topic: topic,
), ),
), ),
const SizedBox(width: WiseSpacing.x2), onChanged: (value) => query.value = value.trim(),
AppButton( ),
label: '最新', const SizedBox(height: YantingSpacing.x3),
icon: AppIcons.sort, Wrap(
kind: AppButtonKind.ghost, spacing: YantingSpacing.x2,
onPressed: () {}, runSpacing: YantingSpacing.x2,
), children: [
const SizedBox(width: WiseSpacing.x2), ShadButton.outline(
AppChip( onPressed: items.isEmpty
label: '音频', ? null
selected: currentHasAudio, : () => _openFilterSheet(
onTap: () => hasAudio.value = !currentHasAudio, context,
), items: items,
const Spacer(), topic: topic,
Text('${filtered.length}', style: YantingText.meta), ),
], leading: const Icon(
), LucideIcons.slidersHorizontal,
const SizedBox(height: WiseSpacing.x3), size: 16,
if (filtered.isEmpty) ),
EmptyState( child: const Text('筛选'),
title: currentQuery.isNotEmpty ? '未找到相关研报' : '当前筛选下暂无研报',
message: currentQuery.isNotEmpty ? '换个关键词试试' : '调整筛选条件后再试',
actionLabel: '清除筛选',
onAction: () {
query.value = '';
topic.value = '';
hasAudio.value = false;
},
)
else
for (final report in filtered) ...[
ReportCardWidget(
report: report,
onTap: () => openReportDetail(
context,
dataSource,
report,
player: player,
onStartAudio: onStartModuleAudio,
onToggleAudio: onToggleAudio,
onSeekAudio: onSeekAudio,
onSpeed: onSpeed,
), ),
onPlayTap: () => onPlay( ShadButton.outline(
AudioItem( onPressed: () {},
audioId: 'local_${report.id}', leading: const Icon(LucideIcons.arrowUpDown, size: 16),
reportId: report.id, child: const Text('最新'),
titleCn: report.titleCn, ),
reportTitleCn: report.titleCn, ShadBadge.secondary(
durationSec: 180, onPressed: () => hasAudio.value = !currentHasAudio,
institution: report.institution, backgroundColor: currentHasAudio
? theme.colorScheme.foreground
: theme.colorScheme.secondary,
foregroundColor: currentHasAudio
? theme.colorScheme.background
: theme.colorScheme.secondaryForeground,
hoverBackgroundColor: currentHasAudio
? theme.colorScheme.foreground.withValues(alpha: 0.9)
: theme.colorScheme.border,
child: const Text('音频'),
),
],
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: Text('${filtered.length}', style: YantingText.meta),
),
const SizedBox(height: YantingSpacing.x3),
const ShadSeparator.horizontal(),
const SizedBox(height: YantingSpacing.x3),
if (filtered.isEmpty)
EmptyState(
title: currentQuery.isNotEmpty ? '未找到相关研报' : '当前筛选下暂无研报',
message: currentQuery.isNotEmpty ? '换个关键词试试' : '调整筛选条件后再试',
actionLabel: '清除筛选',
onAction: () {
searchController.clear();
query.value = '';
topic.value = '';
hasAudio.value = false;
},
)
else
for (final report in filtered) ...[
ReportCardWidget(
report: report,
onTap: () => openReportDetail(
context,
dataSource,
report,
player: player,
onStartAudio: onStartModuleAudio,
onToggleAudio: onToggleAudio,
onSeekAudio: onSeekAudio,
onSpeed: onSpeed,
),
onPlayTap: () => onPlay(
AudioItem(
audioId: 'local_${report.id}',
reportId: report.id,
titleCn: report.titleCn,
reportTitleCn: report.titleCn,
durationSec: 180,
institution: report.institution,
),
), ),
), ),
), const SizedBox(height: YantingSpacing.x3),
const SizedBox(height: WiseSpacing.x3), ],
], ],
], ),
); );
}, },
); );
@@ -194,45 +216,65 @@ void _openFilterSheet(
required ValueNotifier<String> topic, required ValueNotifier<String> topic,
}) { }) {
final topics = {for (final item in items) ...item.topics}.toList(); final topics = {for (final item in items) ...item.topics}.toList();
showModalBottomSheet<void>( showShadSheet<void>(
context: context, context: context,
showDragHandle: true, side: ShadSheetSide.bottom,
shape: const RoundedRectangleBorder( builder: (context) {
borderRadius: BorderRadius.vertical(top: Radius.circular(WiseRadius.lg)), final theme = ShadTheme.of(context);
), final selectedBackground = theme.colorScheme.foreground;
builder: (context) => Padding( final selectedForeground = theme.colorScheme.background;
padding: const EdgeInsets.fromLTRB(20, 4, 20, 28), final unselectedBackground = theme.colorScheme.secondary;
child: Column( final unselectedForeground = theme.colorScheme.secondaryForeground;
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, return ShadSheet(
children: [ title: const Text('筛选研报'),
Text('筛选研报', style: Theme.of(context).textTheme.titleLarge), description: const Text('按主题快速收窄列表。'),
const SizedBox(height: WiseSpacing.x3), child: Column(
Wrap( mainAxisSize: MainAxisSize.min,
spacing: WiseSpacing.x2, crossAxisAlignment: CrossAxisAlignment.start,
runSpacing: WiseSpacing.x2, children: [
children: [ Wrap(
AppChip( spacing: YantingSpacing.x2,
label: '全部主题', runSpacing: YantingSpacing.x2,
selected: topic.value.isEmpty, children: [
onTap: () => topic.value = '', ShadBadge.secondary(
), onPressed: () => topic.value = '',
for (final t in topics) backgroundColor: topic.value.isEmpty
AppChip( ? selectedBackground
label: t, : unselectedBackground,
selected: topic.value == t, foregroundColor: topic.value.isEmpty
onTap: () => topic.value = t, ? selectedForeground
: unselectedForeground,
hoverBackgroundColor: topic.value.isEmpty
? selectedBackground.withValues(alpha: 0.9)
: theme.colorScheme.border,
child: const Text('全部主题'),
), ),
], for (final t in topics)
), ShadBadge.secondary(
const SizedBox(height: WiseSpacing.x4), onPressed: () => topic.value = t,
AppButton( backgroundColor: topic.value == t
label: '完成', ? selectedBackground
expand: true, : unselectedBackground,
onPressed: () => Navigator.pop(context), foregroundColor: topic.value == t
), ? selectedForeground
], : unselectedForeground,
), hoverBackgroundColor: topic.value == t
), ? selectedBackground.withValues(alpha: 0.9)
: theme.colorScheme.border,
child: Text(t),
),
],
),
const SizedBox(height: 12),
ShadButton(
width: double.infinity,
onPressed: () => Navigator.pop(context),
child: const Text('完成'),
),
],
),
);
},
); );
} }
+26 -13
View File
@@ -1,10 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../routing/app_routes.dart';
import '../data/providers.dart'; import '../data/providers.dart';
import '../theme/wise_tokens.dart'; import '../routing/app_routes.dart';
import '../theme/yanting_text.dart';
import '../widgets/bottom_tab_bar.dart'; import '../widgets/bottom_tab_bar.dart';
import '../widgets/mini_player.dart'; import '../widgets/mini_player.dart';
@@ -16,30 +17,42 @@ class ShellPage extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final theme = ShadTheme.of(context);
final player = ref.watch(audioPlayerControllerProvider); final player = ref.watch(audioPlayerControllerProvider);
final controller = ref.read(audioPlayerControllerProvider.notifier); final controller = ref.read(audioPlayerControllerProvider.notifier);
final canPop = GoRouter.of(context).canPop();
final selectedIndex = _tabs.indexWhere((tab) => tab.path == currentPath); final selectedIndex = _tabs.indexWhere((tab) => tab.path == currentPath);
final safeIndex = selectedIndex < 0 ? 0 : selectedIndex; final safeIndex = selectedIndex < 0 ? 0 : selectedIndex;
return Scaffold( return Scaffold(
backgroundColor: theme.colorScheme.background,
appBar: AppBar( appBar: AppBar(
title: const Column( backgroundColor: theme.colorScheme.background,
surfaceTintColor: Colors.transparent,
elevation: 0,
leading: canPop
? ShadIconButton.ghost(
onPressed: () => context.pop(),
icon: const Icon(LucideIcons.chevronLeft, size: 18),
)
: null,
title: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('研听'), Text('研听', style: YantingText.listTitle),
Text( Text('全球机构研报中文解读', style: YantingText.meta.copyWith(fontSize: 12)),
'全球机构研报中文解读',
style: TextStyle(
fontSize: 12,
color: WiseColors.textSecondary,
fontWeight: FontWeight.w500,
),
),
], ],
), ),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1),
child: ColoredBox(
color: theme.colorScheme.border,
child: const SizedBox(height: 1, width: double.infinity),
),
),
), ),
body: ColoredBox( body: ColoredBox(
color: Theme.of(context).scaffoldBackgroundColor, color: theme.colorScheme.background,
child: Stack(children: [Positioned.fill(child: child)]), child: Stack(children: [Positioned.fill(child: child)]),
), ),
bottomNavigationBar: SafeArea( bottomNavigationBar: SafeArea(
+28 -2
View File
@@ -1,17 +1,18 @@
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../data/providers.dart'; import '../data/providers.dart';
import '../features/detail/report_detail_page.dart'; import '../features/detail/report_detail_page.dart';
import '../features/feed/feed_page.dart'; import '../features/feed/feed_page.dart';
import '../features/home/home_page.dart';
import '../features/institutions/institution_detail_page.dart'; import '../features/institutions/institution_detail_page.dart';
import '../features/institutions/institutions_page.dart'; import '../features/institutions/institutions_page.dart';
import '../features/listen/listen_page.dart'; import '../features/listen/listen_page.dart';
import '../features/profile/profile_page.dart'; import '../features/profile/profile_page.dart';
import '../features/reports/reports_page.dart'; import '../features/reports/reports_page.dart';
import '../features/shell_page.dart'; import '../features/shell_page.dart';
import '../theme/wise_tokens.dart';
import 'app_routes.dart'; import 'app_routes.dart';
final routerProvider = Provider<GoRouter>((ref) { final routerProvider = Provider<GoRouter>((ref) {
@@ -46,6 +47,28 @@ final routerProvider = Provider<GoRouter>((ref) {
), ),
), ),
), ),
GoRoute(
path: AppRoutes.homeFeed,
builder: (context, state) => _TabSurface(
child: Consumer(
builder: (context, ref, _) {
final player = ref.watch(audioPlayerControllerProvider);
final controller = ref.read(
audioPlayerControllerProvider.notifier,
);
return HomePage(
dataSource: dataSource,
onPlay: controller.startFromItem,
player: player,
onStartModuleAudio: controller.startModuleAudio,
onToggleAudio: controller.toggleAudio,
onSeekAudio: controller.seekAudio,
onSpeed: controller.cycleSpeed,
);
},
),
),
),
GoRoute( GoRoute(
path: AppRoutes.reports, path: AppRoutes.reports,
builder: (context, state) => _TabSurface( builder: (context, state) => _TabSurface(
@@ -142,6 +165,9 @@ class _TabSurface extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ColoredBox(color: WiseColors.canvas, child: child); return ColoredBox(
color: ShadTheme.of(context).colorScheme.background,
child: child,
);
} }
} }
+2 -6
View File
@@ -7,6 +7,7 @@ import '../widgets/mini_player.dart';
abstract final class AppRoutes { abstract final class AppRoutes {
static const home = '/'; static const home = '/';
static const homeFeed = '/feed';
static const reports = '/reports'; static const reports = '/reports';
static const institutions = '/institutions'; static const institutions = '/institutions';
static const listen = '/listen'; static const listen = '/listen';
@@ -53,12 +54,7 @@ void openReportDetail(
ReportDataSource dataSource, ReportDataSource dataSource,
ReportCardModel report, { ReportCardModel report, {
PlayerStateModel player = const PlayerStateModel(), PlayerStateModel player = const PlayerStateModel(),
void Function( void Function(String audioId, String reportId, String title, int durationSec)?
String audioId,
String reportId,
String title,
int durationSec,
)?
onStartAudio, onStartAudio,
VoidCallback? onToggleAudio, VoidCallback? onToggleAudio,
void Function(int delta)? onSeekAudio, void Function(int delta)? onSeekAudio,
+12 -2
View File
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'yanting_text.dart'; import 'yanting_text.dart';
import 'yanting_tokens.dart'; import 'yanting_tokens.dart';
import 'wise_tokens.dart';
ThemeData buildAppTheme() { ThemeData buildAppTheme() {
final scheme = ColorScheme.fromSeed( final scheme = ColorScheme.fromSeed(
@@ -29,13 +28,14 @@ ThemeData buildAppTheme() {
elevation: 0, elevation: 0,
centerTitle: false, centerTitle: false,
titleTextStyle: YantingText.sectionTitle, titleTextStyle: YantingText.sectionTitle,
surfaceTintColor: Colors.transparent,
), ),
cardTheme: const CardThemeData( cardTheme: const CardThemeData(
color: YantingColors.card, color: YantingColors.card,
elevation: 0, elevation: 0,
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(WiseRadius.md)), borderRadius: BorderRadius.all(Radius.circular(YantingRadius.xl)),
side: BorderSide(color: YantingColors.border), side: BorderSide(color: YantingColors.border),
), ),
), ),
@@ -64,6 +64,16 @@ ThemeData buildAppTheme() {
borderSide: const BorderSide(color: YantingColors.foreground), borderSide: const BorderSide(color: YantingColors.foreground),
), ),
), ),
snackBarTheme: SnackBarThemeData(
backgroundColor: YantingColors.foreground,
contentTextStyle: YantingText.body.copyWith(
color: YantingColors.background,
),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(YantingRadius.md),
),
),
navigationBarTheme: NavigationBarThemeData( navigationBarTheme: NavigationBarThemeData(
backgroundColor: YantingColors.background, backgroundColor: YantingColors.background,
indicatorColor: Colors.transparent, indicatorColor: Colors.transparent,
+3
View File
@@ -1,2 +1,5 @@
export 'app_theme.dart'; export 'app_theme.dart';
export 'yanting_shad_theme.dart';
export 'yanting_text.dart';
export 'yanting_tokens.dart';
export 'wise_tokens.dart'; export 'wise_tokens.dart';
+130
View File
@@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'yanting_text.dart';
import 'yanting_tokens.dart';
const _lightShadColors = ShadColorScheme(
background: YantingColors.background,
foreground: YantingColors.foreground,
card: YantingColors.card,
cardForeground: YantingColors.foreground,
popover: YantingColors.card,
popoverForeground: YantingColors.foreground,
primary: YantingColors.primary,
primaryForeground: YantingColors.primaryForeground,
secondary: YantingColors.secondary,
secondaryForeground: YantingColors.secondaryForeground,
muted: YantingColors.muted,
mutedForeground: YantingColors.mutedForeground,
accent: YantingColors.brandSoft,
accentForeground: YantingColors.primaryForeground,
destructive: YantingColors.destructive,
destructiveForeground: YantingColors.background,
border: YantingColors.border,
input: YantingColors.input,
ring: YantingColors.primary,
selection: YantingColors.foreground,
custom: {
'brandSoft': YantingColors.brandSoft,
'brandSoftBorder': YantingColors.brandSoftBorder,
'link': YantingColors.link,
'chart2': YantingColors.chart2,
'warning': YantingColors.warning,
},
);
const _darkShadColors = ShadColorScheme(
background: Color(0xFF0F0F0F),
foreground: Color(0xFFF0F0F0),
card: Color(0xFF1A1A1A),
cardForeground: Color(0xFFF0F0F0),
popover: Color(0xFF1A1A1A),
popoverForeground: Color(0xFFF0F0F0),
primary: YantingColors.primary,
primaryForeground: Color(0xFF0F1A00),
secondary: Color(0xFF1F1F23),
secondaryForeground: Color(0xFFF0F0F0),
muted: Color(0xFF1A1A1A),
mutedForeground: Color(0xFFA1A1AA),
accent: Color(0xFF1C2B00),
accentForeground: YantingColors.primary,
destructive: YantingColors.destructive,
destructiveForeground: YantingColors.background,
border: Color(0xFF2A2A2A),
input: Color(0xFF2A2A2A),
ring: YantingColors.primary,
selection: Color(0xFFF0F0F0),
custom: {
'brandSoft': Color(0xFF1C2B00),
'brandSoftBorder': Color(0xFF304800),
'link': YantingColors.link,
'chart2': YantingColors.chart2,
'warning': YantingColors.warning,
},
);
ShadThemeData buildYantingShadTheme() {
return ShadThemeData(
brightness: Brightness.light,
colorScheme: _lightShadColors,
radius: BorderRadius.circular(YantingRadius.base),
cardTheme: ShadCardTheme(
padding: const EdgeInsets.all(YantingSpacing.cardPadding),
radius: BorderRadius.circular(YantingRadius.xl),
border: ShadBorder.all(color: YantingColors.border),
shadows: const [],
),
textTheme: ShadTextTheme(
family: YantingText.fontFamily,
h1Large: YantingText.appTitle,
h1: YantingText.appTitle,
h2: YantingText.sectionTitle,
h3: YantingText.cardTitle,
h4: YantingText.listTitle,
p: YantingText.body,
blockquote: YantingText.body,
table: YantingText.meta,
list: YantingText.body,
lead: YantingText.sub,
large: YantingText.cardTitle,
small: YantingText.badge,
muted: YantingText.meta,
googleFontBuilder: GoogleFonts.dmSans,
),
);
}
ShadThemeData buildYantingDarkShadTheme() {
return ShadThemeData(
brightness: Brightness.dark,
colorScheme: _darkShadColors,
radius: BorderRadius.circular(YantingRadius.base),
cardTheme: ShadCardTheme(
padding: const EdgeInsets.all(YantingSpacing.cardPadding),
radius: BorderRadius.circular(YantingRadius.xl),
border: ShadBorder.all(color: _darkShadColors.border),
shadows: const [],
),
textTheme: ShadTextTheme(
family: YantingText.fontFamily,
h1Large: YantingText.appTitle.copyWith(color: _darkShadColors.foreground),
h1: YantingText.appTitle.copyWith(color: _darkShadColors.foreground),
h2: YantingText.sectionTitle.copyWith(color: _darkShadColors.foreground),
h3: YantingText.cardTitle.copyWith(color: _darkShadColors.foreground),
h4: YantingText.listTitle.copyWith(color: _darkShadColors.foreground),
p: YantingText.body.copyWith(color: _darkShadColors.foreground),
blockquote: YantingText.body.copyWith(
color: _darkShadColors.mutedForeground,
),
table: YantingText.meta.copyWith(color: _darkShadColors.mutedForeground),
list: YantingText.body.copyWith(color: _darkShadColors.foreground),
lead: YantingText.sub.copyWith(color: _darkShadColors.mutedForeground),
large: YantingText.cardTitle.copyWith(color: _darkShadColors.foreground),
small: YantingText.badge.copyWith(color: _darkShadColors.mutedForeground),
muted: YantingText.meta.copyWith(color: _darkShadColors.mutedForeground),
googleFontBuilder: GoogleFonts.dmSans,
),
);
}
+7 -7
View File
@@ -2,20 +2,20 @@ import 'package:flutter/material.dart';
abstract final class YantingColors { abstract final class YantingColors {
static const background = Color(0xFFFFFFFF); static const background = Color(0xFFFFFFFF);
static const foreground = Color(0xFF171717); static const foreground = Color(0xFF1A1A1A);
static const card = Color(0xFFFFFFFF); static const card = Color(0xFFFFFFFF);
static const primary = Color(0xFFA3E635); static const primary = Color(0xFF95E300);
static const primaryForeground = Color(0xFF3F6212); static const primaryForeground = Color(0xFF365314);
static const secondary = Color(0xFFF4F4F5); static const secondary = Color(0xFFF4F4F5);
static const secondaryForeground = Color(0xFF27272A); static const secondaryForeground = Color(0xFF27272A);
static const muted = Color(0xFFF5F5F5); static const muted = Color(0xFFF7F7F7);
static const mutedForeground = Color(0xFF737373); static const mutedForeground = Color(0xFF71717A);
static const border = Color(0xFFE5E5E5); static const border = Color(0xFFE5E5E5);
static const input = Color(0xFFE5E5E5); static const input = Color(0xFFE5E5E5);
static const destructive = Color(0xFFEF4444); static const destructive = Color(0xFFEF4444);
static const warning = Color(0xFF9A6A00); static const warning = Color(0xFF9A6500);
static const chart2 = Color(0xFF84CC16); static const chart2 = Color(0xFF84CC16);
static const brandSoft = Color(0xFFEEFBD8); static const brandSoft = Color(0xFFECFCCB);
static const brandSoftBorder = Color(0xFFD6F5A8); static const brandSoftBorder = Color(0xFFD6F5A8);
static const link = Color(0xFF2563EB); static const link = Color(0xFF2563EB);
static const canvas = background; static const canvas = background;
+73 -37
View File
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../theme/yanting_text.dart';
import '../theme/yanting_tokens.dart'; import '../theme/yanting_tokens.dart';
class AppButton extends StatelessWidget { class AppButton extends StatelessWidget {
@@ -21,48 +21,84 @@ class AppButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = switch (kind) { final leading = icon == null ? null : Icon(icon, size: 16);
AppButtonKind.primary => ( final width = expand ? double.infinity : null;
YantingColors.primary, final child = Text(label);
YantingColors.primaryForeground,
Colors.transparent, return switch (kind) {
AppButtonKind.primary => ShadButton(
width: width,
onPressed: onPressed,
leading: leading,
child: child,
), ),
AppButtonKind.dark => ( AppButtonKind.dark => ShadButton(
YantingColors.foreground, width: width,
YantingColors.background, onPressed: onPressed,
Colors.transparent, leading: leading,
backgroundColor: YantingColors.foreground,
foregroundColor: YantingColors.background,
hoverBackgroundColor: YantingColors.foreground.withValues(alpha: 0.9),
child: child,
), ),
AppButtonKind.accent => ( AppButtonKind.accent => ShadButton.secondary(
YantingColors.brandSoft, width: width,
YantingColors.primaryForeground, onPressed: onPressed,
Colors.transparent, leading: leading,
backgroundColor: YantingColors.brandSoft,
foregroundColor: YantingColors.primaryForeground,
hoverBackgroundColor: YantingColors.brandSoftBorder,
child: child,
), ),
AppButtonKind.ghost => ( AppButtonKind.ghost => ShadButton.outline(
YantingColors.background, width: width,
YantingColors.foreground, onPressed: onPressed,
YantingColors.border, leading: leading,
child: child,
), ),
}; };
final child = FilledButton.icon( }
onPressed: onPressed, }
icon: icon == null ? const SizedBox.shrink() : Icon(icon, size: 18),
label: Text(label), class AppIconButton extends StatelessWidget {
style: FilledButton.styleFrom( const AppIconButton({
backgroundColor: colors.$1, required this.icon,
foregroundColor: colors.$2, required this.onPressed,
disabledBackgroundColor: YantingColors.border, this.kind = AppButtonKind.ghost,
disabledForegroundColor: YantingColors.mutedForeground, super.key,
minimumSize: Size(expand ? double.infinity : 0, 44), });
textStyle: YantingText.body.copyWith(fontWeight: FontWeight.w600),
side: colors.$3 == Colors.transparent final IconData icon;
? BorderSide.none final VoidCallback? onPressed;
: BorderSide(color: colors.$3), final AppButtonKind kind;
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(YantingRadius.md), @override
), Widget build(BuildContext context) {
final iconWidget = Icon(icon, size: 16);
return switch (kind) {
AppButtonKind.primary => ShadIconButton(
onPressed: onPressed,
icon: iconWidget,
), ),
); AppButtonKind.dark => ShadIconButton(
return expand ? SizedBox(width: double.infinity, child: child) : child; onPressed: onPressed,
backgroundColor: YantingColors.foreground,
foregroundColor: YantingColors.background,
hoverBackgroundColor: YantingColors.foreground.withValues(alpha: 0.9),
icon: iconWidget,
),
AppButtonKind.accent => ShadIconButton.secondary(
onPressed: onPressed,
backgroundColor: YantingColors.brandSoft,
foregroundColor: YantingColors.primaryForeground,
hoverBackgroundColor: YantingColors.brandSoftBorder,
icon: iconWidget,
),
AppButtonKind.ghost => ShadIconButton.outline(
onPressed: onPressed,
icon: iconWidget,
),
};
} }
} }
+19 -18
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../theme/yanting_tokens.dart'; import '../theme/yanting_tokens.dart';
@@ -20,29 +21,29 @@ class AppCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final decoration = BoxDecoration( final theme = ShadTheme.of(context);
color: color, final radius = BorderRadius.circular(YantingRadius.xl);
border: Border.all(color: borderColor), final content = ShadCard(
borderRadius: BorderRadius.circular(YantingRadius.xl), padding: padding,
); backgroundColor: color,
final content = DecoratedBox( radius: radius,
decoration: BoxDecoration( border: ShadBorder.all(color: borderColor),
color: color, shadows: const [],
border: Border.all(color: borderColor), child: child,
borderRadius: BorderRadius.circular(YantingRadius.xl),
),
child: Padding(padding: padding, child: child),
); );
if (onTap == null) return content; if (onTap == null) return content;
return Material( return Material(
color: Colors.transparent, color: Colors.transparent,
child: Ink( borderRadius: radius,
decoration: decoration, child: InkWell(
child: InkWell( borderRadius: radius,
borderRadius: BorderRadius.circular(YantingRadius.xl), splashColor: theme.colorScheme.mutedForeground.withValues(alpha: 0.08),
onTap: onTap, highlightColor: theme.colorScheme.mutedForeground.withValues(
child: Padding(padding: padding, child: child), alpha: 0.04,
), ),
onTap: onTap,
child: content,
), ),
); );
} }
+49 -77
View File
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../theme/yanting_text.dart';
import '../theme/yanting_tokens.dart'; import '../theme/yanting_tokens.dart';
class AppBadge extends StatelessWidget { class AppBadge extends StatelessWidget {
@@ -17,60 +17,45 @@ class AppBadge extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = switch (kind) { final child = Row(
BadgeKind.brand => ( mainAxisSize: MainAxisSize.min,
YantingColors.primary, children: [
YantingColors.primaryForeground, if (icon != null) ...[Icon(icon, size: 12), const SizedBox(width: 4)],
Colors.transparent, Text(text),
],
);
final shape = RoundedRectangleBorder(
borderRadius: BorderRadius.circular(YantingRadius.sm),
side: kind == BadgeKind.tier || kind == BadgeKind.warning
? const BorderSide(color: YantingColors.border)
: BorderSide.none,
);
return switch (kind) {
BadgeKind.brand => ShadBadge(
shape: shape,
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
child: child,
), ),
BadgeKind.audio => ( BadgeKind.audio || BadgeKind.neutral => ShadBadge.secondary(
YantingColors.secondary, shape: shape,
YantingColors.secondaryForeground, padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
Colors.transparent, child: child,
), ),
BadgeKind.tier => ( BadgeKind.tier => ShadBadge.outline(
YantingColors.background, shape: shape,
YantingColors.mutedForeground, padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
YantingColors.border, foregroundColor: YantingColors.mutedForeground,
child: child,
), ),
BadgeKind.warning => ( BadgeKind.warning => ShadBadge.destructive(
YantingColors.background, shape: shape,
YantingColors.destructive, padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
YantingColors.border, backgroundColor: YantingColors.background,
), foregroundColor: YantingColors.destructive,
BadgeKind.neutral => ( child: child,
YantingColors.secondary,
YantingColors.secondaryForeground,
Colors.transparent,
), ),
}; };
return DecoratedBox(
decoration: BoxDecoration(
color: colors.$1,
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: 3),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 14, color: colors.$2),
const SizedBox(width: 4),
],
Text(
text,
style:
(Theme.of(context).textTheme.labelSmall ?? YantingText.badge)
.copyWith(color: colors.$2, letterSpacing: 0),
),
],
),
),
);
} }
} }
@@ -90,33 +75,20 @@ class AppChip extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final background = selected return ShadBadge.secondary(
? YantingColors.foreground onPressed: onTap,
: YantingColors.secondary; shape: const StadiumBorder(),
final foreground = selected padding: const EdgeInsets.symmetric(horizontal: 17, vertical: 9),
? YantingColors.background backgroundColor: selected
: YantingColors.secondaryForeground; ? YantingColors.foreground
return Material( : YantingColors.secondary,
color: Colors.transparent, hoverBackgroundColor: selected
child: Ink( ? YantingColors.foreground.withValues(alpha: 0.9)
decoration: BoxDecoration( : YantingColors.border,
color: background, foregroundColor: selected
borderRadius: BorderRadius.circular(YantingRadius.pill), ? YantingColors.background
), : YantingColors.secondaryForeground,
child: InkWell( child: Text(label),
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),
),
),
),
),
); );
} }
} }
+14 -35
View File
@@ -1,10 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../data/models/models.dart'; import '../data/models/models.dart';
import '../theme/app_icons.dart'; import '../theme/app_icons.dart';
import '../theme/yanting_text.dart'; import '../theme/yanting_text.dart';
import '../theme/yanting_tokens.dart'; import '../theme/yanting_tokens.dart';
import '../theme/wise_tokens.dart'; import '../theme/wise_tokens.dart';
import 'app_buttons.dart';
import 'app_card.dart'; import 'app_card.dart';
class PlayerStateModel { class PlayerStateModel {
@@ -126,14 +128,12 @@ class MiniPlayer extends StatelessWidget {
], ],
), ),
), ),
IconButton( ShadIconButton.ghost(
onPressed: onToggle, onPressed: onToggle,
icon: Icon( icon: Icon(
player.playing ? AppIcons.pause : AppIcons.playCircle, player.playing ? AppIcons.pause : AppIcons.playCircle,
size: player.playing ? 24 : 28, size: player.playing ? 24 : 28,
), ),
color: YantingColors.foreground,
visualDensity: VisualDensity.compact,
), ),
], ],
), ),
@@ -184,13 +184,7 @@ class PlayerCard extends StatelessWidget {
style: YantingText.meta.copyWith(fontSize: 12.5), style: YantingText.meta.copyWith(fontSize: 12.5),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
LinearProgressIndicator( ShadProgress(value: ratio.clamp(0, 1)),
value: ratio.clamp(0, 1),
minHeight: 4,
borderRadius: BorderRadius.circular(YantingRadius.pill),
backgroundColor: YantingColors.border,
color: YantingColors.primary,
),
const SizedBox(height: WiseSpacing.x2), const SizedBox(height: WiseSpacing.x2),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -216,18 +210,15 @@ class PlayerCard extends StatelessWidget {
children: [ children: [
_SkipButton(label: '-15', onPressed: () => onSeek(-15)), _SkipButton(label: '-15', onPressed: () => onSeek(-15)),
const SizedBox(width: 26), const SizedBox(width: 26),
IconButton.filled( SizedBox(
onPressed: active ? onToggle : onStart, width: 56,
icon: Icon( height: 56,
active && player.playing child: AppIconButton(
kind: AppButtonKind.primary,
onPressed: active ? onToggle : onStart,
icon: active && player.playing
? AppIcons.pause ? AppIcons.pause
: AppIcons.play, : AppIcons.play,
size: 28,
),
style: IconButton.styleFrom(
backgroundColor: YantingColors.primary,
foregroundColor: YantingColors.primaryForeground,
fixedSize: const Size(56, 56),
), ),
), ),
const SizedBox(width: 26), const SizedBox(width: 26),
@@ -236,22 +227,10 @@ class PlayerCard extends StatelessWidget {
), ),
Align( Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: TextButton( child: ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: onSpeed, onPressed: onSpeed,
style: TextButton.styleFrom( child: Text('${player.speed.toStringAsFixed(1)}x'),
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,
),
),
), ),
), ),
], ],
+25 -46
View File
@@ -1,27 +1,22 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../theme/wise_tokens.dart';
import 'app_buttons.dart'; import 'app_buttons.dart';
import 'states.dart'; import 'states.dart';
Future<void> showLoginSheet(BuildContext context, {String reason = '登录后保存当前动作'}) { Future<void> showLoginSheet(
return showModalBottomSheet<void>( BuildContext context, {
String reason = '登录后保存当前动作',
}) {
return showShadSheet<void>(
context: context, context: context,
showDragHandle: true, side: ShadSheetSide.bottom,
backgroundColor: WiseColors.surface, builder: (context) => ShadSheet(
shape: const RoundedRectangleBorder( title: const Text('登录研听'),
borderRadius: BorderRadius.vertical(top: Radius.circular(WiseRadius.lg)), description: Text(reason),
),
builder: (context) => Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 28),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('登录研听', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: WiseSpacing.x2),
Text(reason, style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: WiseSpacing.x4),
AppButton( AppButton(
label: '使用手机号继续', label: '使用手机号继续',
icon: Icons.phone_iphone, icon: Icons.phone_iphone,
@@ -31,7 +26,7 @@ Future<void> showLoginSheet(BuildContext context, {String reason = '登录后保
showAppToast(context, '登录接口待接入,已保留当前页面'); showAppToast(context, '登录接口待接入,已保留当前页面');
}, },
), ),
const SizedBox(height: WiseSpacing.x2), const SizedBox(height: 8),
AppButton( AppButton(
label: '微信 / Apple 登录占位', label: '微信 / Apple 登录占位',
icon: Icons.account_circle_outlined, icon: Icons.account_circle_outlined,
@@ -49,37 +44,21 @@ Future<void> showLoginSheet(BuildContext context, {String reason = '登录后保
} }
Future<void> showOutboundSheet(BuildContext context, {required String title}) { Future<void> showOutboundSheet(BuildContext context, {required String title}) {
return showModalBottomSheet<void>( return showShadSheet<void>(
context: context, context: context,
showDragHandle: true, side: ShadSheetSide.bottom,
backgroundColor: WiseColors.surface, builder: (context) => ShadSheet(
shape: const RoundedRectangleBorder( title: const Text('即将打开外部服务'),
borderRadius: BorderRadius.vertical(top: Radius.circular(WiseRadius.lg)), description: Text('$title\n外跳仅用于了解原文或相关服务,本内容不构成投资建议。'),
), child: AppButton(
builder: (context) => Padding( label: '确认并记录占位事件',
padding: const EdgeInsets.fromLTRB(20, 4, 20, 28), icon: Icons.open_in_new,
child: Column( kind: AppButtonKind.accent,
mainAxisSize: MainAxisSize.min, expand: true,
crossAxisAlignment: CrossAxisAlignment.start, onPressed: () {
children: [ Navigator.pop(context);
Text('即将打开外部服务', style: Theme.of(context).textTheme.titleLarge), showAppToast(context, '外跳事件接口待接入');
const SizedBox(height: WiseSpacing.x2), },
Text(
'$title\n外跳仅用于了解原文或相关服务,本内容不构成投资建议。',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: WiseSpacing.x4),
AppButton(
label: '确认并记录占位事件',
icon: Icons.open_in_new,
kind: AppButtonKind.accent,
expand: true,
onPressed: () {
Navigator.pop(context);
showAppToast(context, '外跳事件接口待接入');
},
),
],
), ),
), ),
); );
+70 -24
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../theme/wise_tokens.dart'; import '../theme/yanting_tokens.dart';
import 'app_buttons.dart'; import 'app_buttons.dart';
import 'app_card.dart'; import 'app_card.dart';
@@ -12,9 +13,10 @@ class LoadingState extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListView.separated( return ListView.separated(
padding: const EdgeInsets.all(WiseSpacing.x4), padding: const EdgeInsets.all(YantingSpacing.screenX),
itemCount: 4, itemCount: 4,
separatorBuilder: (_, _) => const SizedBox(height: WiseSpacing.x3), separatorBuilder: (_, _) =>
const SizedBox(height: YantingSpacing.cardGap),
itemBuilder: (context, index) => const SkeletonCard(), itemBuilder: (context, index) => const SkeletonCard(),
); );
} }
@@ -30,11 +32,11 @@ class SkeletonCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: const [ children: const [
SkeletonLine(width: 96), SkeletonLine(width: 96),
SizedBox(height: WiseSpacing.x3), SizedBox(height: YantingSpacing.cardGap),
SkeletonLine(width: double.infinity, height: 18), SkeletonLine(width: double.infinity, height: 18),
SizedBox(height: WiseSpacing.x2), SizedBox(height: YantingSpacing.x2),
SkeletonLine(width: 240), SkeletonLine(width: 240),
SizedBox(height: WiseSpacing.x3), SizedBox(height: YantingSpacing.cardGap),
SkeletonLine(width: 160), SkeletonLine(width: 160),
], ],
), ),
@@ -50,12 +52,58 @@ class SkeletonLine extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( final theme = ShadTheme.of(context);
return _PulsingSkeleton(
width: width, width: width,
height: height, height: height,
decoration: BoxDecoration( color: theme.colorScheme.muted,
color: WiseColors.border, );
borderRadius: BorderRadius.circular(WiseRadius.pill), }
}
class _PulsingSkeleton extends StatefulWidget {
const _PulsingSkeleton({
required this.color,
required this.width,
required this.height,
});
final Color color;
final double width;
final double height;
@override
State<_PulsingSkeleton> createState() => _PulsingSkeletonState();
}
class _PulsingSkeletonState extends State<_PulsingSkeleton>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
)..repeat(reverse: true);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return FadeTransition(
opacity: Tween<double>(
begin: 0.4,
end: 1,
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)),
child: Container(
width: widget.width,
height: widget.height,
decoration: BoxDecoration(
color: widget.color,
borderRadius: theme.radius,
),
), ),
); );
} }
@@ -79,24 +127,29 @@ class EmptyState extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return Center( return Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(WiseSpacing.x6), padding: const EdgeInsets.all(YantingSpacing.x6),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(icon, size: 42, color: WiseColors.primary), Icon(icon, size: 42, color: theme.colorScheme.foreground),
const SizedBox(height: WiseSpacing.x3), const SizedBox(height: YantingSpacing.cardGap),
Text(title, style: Theme.of(context).textTheme.titleMedium), Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: WiseSpacing.x2), const SizedBox(height: YantingSpacing.x2),
Text( Text(
message, message,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.bodyMedium,
), ),
if (actionLabel != null) ...[ if (actionLabel != null) ...[
const SizedBox(height: WiseSpacing.x4), const SizedBox(height: YantingSpacing.x4),
AppButton(label: actionLabel!, onPressed: onAction, kind: AppButtonKind.ghost), AppButton(
label: actionLabel!,
onPressed: onAction,
kind: AppButtonKind.ghost,
),
], ],
], ],
), ),
@@ -124,12 +177,5 @@ class ErrorState extends StatelessWidget {
} }
void showAppToast(BuildContext context, String message) { void showAppToast(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar( ShadToaster.of(context).show(ShadToast(title: Text(message)));
SnackBar(
content: Text(message),
behavior: SnackBarBehavior.floating,
backgroundColor: WiseColors.primary,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(WiseRadius.md)),
),
);
} }
+321 -1
View File
@@ -1,6 +1,14 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async: async:
dependency: transitive dependency: transitive
description: description:
@@ -17,6 +25,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.2"
boxy:
dependency: transitive
description:
name: boxy
sha256: "42ccafe13b2893878042acc5b7e2446025328e11a3197b0bb78db42ff76aa3f0"
url: "https://pub.dev"
source: hosted
version: "2.3.0"
characters: characters:
dependency: transitive dependency: transitive
description: description:
@@ -41,6 +57,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" version: "1.19.1"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
cupertino_icons: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -49,6 +73,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.9" version: "1.0.9"
extended_image:
dependency: transitive
description:
name: extended_image
sha256: f6cbb1d798f51262ed1a3d93b4f1f2aa0d76128df39af18ecb77fa740f88b2e0
url: "https://pub.dev"
source: hosted
version: "10.0.1"
extended_image_library:
dependency: transitive
description:
name: extended_image_library
sha256: "1f9a24d3a00c2633891c6a7b5cab2807999eb2d5b597e5133b63f49d113811fe"
url: "https://pub.dev"
source: hosted
version: "5.0.1"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@@ -57,11 +97,27 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.3" version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_animate:
dependency: transitive
description:
name: flutter_animate
sha256: "7befe2d3252728afb77aecaaea1dec88a89d35b9b1d2eea6d04479e8af9117b5"
url: "https://pub.dev"
source: hosted
version: "4.5.2"
flutter_hooks: flutter_hooks:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -91,6 +147,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.6.1" version: "2.6.1"
flutter_shaders:
dependency: transitive
description:
name: flutter_shaders
sha256: "34794acadd8275d971e02df03afee3dee0f98dbfb8c4837082ad0034f612a3e2"
url: "https://pub.dev"
source: hosted
version: "0.1.3"
flutter_svg:
dependency: transitive
description:
name: flutter_svg
sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f"
url: "https://pub.dev"
source: hosted
version: "2.3.0"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -109,6 +181,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "16.3.0" version: "16.3.0"
google_fonts:
dependency: "direct main"
description:
name: google_fonts
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
url: "https://pub.dev"
source: hosted
version: "6.3.3"
hooks_riverpod: hooks_riverpod:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -125,6 +205,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.6.0" version: "1.6.0"
http_client_helper:
dependency: transitive
description:
name: http_client_helper
sha256: "8a9127650734da86b5c73760de2b404494c968a3fd55602045ffec789dac3cb1"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
http_parser: http_parser:
dependency: transitive dependency: transitive
description: description:
@@ -141,6 +229,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.20.2" version: "0.20.2"
jni:
dependency: transitive
description:
name: jni
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
url: "https://pub.dev"
source: hosted
version: "1.0.0"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@@ -181,6 +293,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.0" version: "1.3.0"
lucide_icons_flutter:
dependency: transitive
description:
name: lucide_icons_flutter
sha256: "7c5dc01a32a9905ae34e2d84224e92d6d0c42acf8926df9e01c35a1446bf1b69"
url: "https://pub.dev"
source: hosted
version: "3.1.14+2"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
@@ -205,6 +325,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.16.0" version: "1.16.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path: path:
dependency: transitive dependency: transitive
description: description:
@@ -213,6 +341,70 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" version: "1.9.1"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.dev"
source: hosted
version: "2.3.1"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4"
url: "https://pub.dev"
source: hosted
version: "2.5.1"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
phosphor_flutter: phosphor_flutter:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -221,6 +413,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.0" version: "2.1.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
remixicon: remixicon:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -237,11 +445,43 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.6.1" version: "2.6.1"
serial_csv:
dependency: transitive
description:
name: serial_csv
sha256: "2d62bb70cb3ce7251383fc86ea9aae1298ab1e57af6ef4e93b6a9751c5c268dd"
url: "https://pub.dev"
source: hosted
version: "0.5.2"
shadcn_ui:
dependency: "direct main"
description:
name: shadcn_ui
sha256: "6c06f2bcebd8734b9ed0bf3f63ef5c71981573d5664923589b0302f8280e7eaf"
url: "https://pub.dev"
source: hosted
version: "0.53.6"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
slang:
dependency: transitive
description:
name: slang
sha256: "46e929158c2f563994c4d1fce5819cfa13e18b164941473d2553bcddcf387c31"
url: "https://pub.dev"
source: hosted
version: "4.15.0"
slang_flutter:
dependency: transitive
description:
name: slang_flutter
sha256: "0eb6348416a296f1bd940fe02669bcd2df5c5cfdabf893b98e448df8b7ecf4ac"
url: "https://pub.dev"
source: hosted
version: "4.15.0"
source_span: source_span:
dependency: transitive dependency: transitive
description: description:
@@ -298,6 +538,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.6" version: "0.7.6"
theme_extensions_builder_annotation:
dependency: transitive
description:
name: theme_extensions_builder_annotation
sha256: "75f28ac85d396d143d111a47c1395b01f3be41b7135f37bd51512921944e4206"
url: "https://pub.dev"
source: hosted
version: "7.3.0"
two_dimensional_scrollables:
dependency: transitive
description:
name: two_dimensional_scrollables
sha256: "4f25bd42783626c5f2810333418727455397195acb61e53710e638a6a98e0e5e"
url: "https://pub.dev"
source: hosted
version: "0.5.2"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -306,6 +562,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
universal_image:
dependency: transitive
description:
name: universal_image
sha256: "2eae13df84a47960cc4148fec88f09031ea64cbf667b4131ff2c907dd7b7c6d1"
url: "https://pub.dev"
source: hosted
version: "1.0.12"
vector_graphics:
dependency: transitive
description:
name: vector_graphics
sha256: "2306c03da2ba81724afeb589c351ebbc0aa7d86005925be8f8735856dbe5e42d"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
vector_graphics_codec:
dependency: transitive
description:
name: vector_graphics_codec
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
url: "https://pub.dev"
source: hosted
version: "1.1.13"
vector_graphics_compiler:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: b9b3f391857781aa96acacef96066f2f49b4cd03cf9fce3ca4d8da2ef5ea129e
url: "https://pub.dev"
source: hosted
version: "1.2.3"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@@ -322,6 +610,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.2.0" version: "15.2.0"
watcher:
dependency: transitive
description:
name: watcher
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
web: web:
dependency: transitive dependency: transitive
description: description:
@@ -330,6 +626,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks: sdks:
dart: ">=3.9.0 <4.0.0" dart: ">=3.9.0 <4.0.0"
flutter: ">=3.32.0" flutter: ">=3.35.6"
+2
View File
@@ -39,8 +39,10 @@ dependencies:
flutter_hooks: ^0.21.3+1 flutter_hooks: ^0.21.3+1
go_router: ^16.2.4 go_router: ^16.2.4
hooks_riverpod: ^2.6.1 hooks_riverpod: ^2.6.1
google_fonts: ^6.2.1
phosphor_flutter: ^2.1.0 phosphor_flutter: ^2.1.0
remixicon: ^4.9.3 remixicon: ^4.9.3
shadcn_ui: ^0.53.6
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
+1 -1
View File
@@ -24,7 +24,7 @@ void main() {
await tester.tap(find.text('报告摘要').last); await tester.tap(find.text('报告摘要').last);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('报告摘要'), findsOneWidget); expect(find.text('报告摘要'), findsWidgets);
expect(find.text('需求结构'), findsOneWidget); expect(find.text('需求结构'), findsOneWidget);
}); });
} }