fix:对比原型增加功能交互
This commit is contained in:
@@ -5,6 +5,8 @@ import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../../data/api/report_data_source.dart';
|
||||
import '../../data/models/models.dart';
|
||||
import '../../data/providers.dart';
|
||||
import '../../data/state/app_interaction_state.dart';
|
||||
import '../../theme/app_icons.dart';
|
||||
import '../../theme/yanting_text.dart';
|
||||
import '../../theme/yanting_tokens.dart';
|
||||
@@ -197,35 +199,132 @@ class _ReportDetailContent extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionBar extends StatelessWidget {
|
||||
class _ActionBar extends ConsumerWidget {
|
||||
const _ActionBar({required this.detail});
|
||||
|
||||
final ReportDetail detail;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final auth = ref.watch(authControllerProvider);
|
||||
final profile = ref.watch(profileControllerProvider);
|
||||
final isFavorite = profile.favorites.contains(detail.id);
|
||||
final isSavedListen = profile.savedListens.contains(detail.id);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AppButton(
|
||||
label: '收藏',
|
||||
icon: AppIcons.heart,
|
||||
label: isFavorite ? '已收藏' : '收藏',
|
||||
icon: isFavorite ? AppIcons.heartFill : AppIcons.heart,
|
||||
kind: AppButtonKind.ghost,
|
||||
onPressed: () => showLoginSheet(context, reason: '登录后保存到你的收藏'),
|
||||
onPressed: () => _runLoginRequiredAction(
|
||||
context,
|
||||
ref,
|
||||
auth,
|
||||
PendingLoginAction(
|
||||
action: LoginRequiredAction.favorite,
|
||||
reportId: detail.id,
|
||||
contextText: '登录后保存到你的收藏',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: YantingSpacing.x2),
|
||||
if (detail.hasAudio) ...[
|
||||
Expanded(
|
||||
child: AppButton(
|
||||
label: isSavedListen ? '已存听单' : '听单',
|
||||
icon: isSavedListen
|
||||
? AppIcons.headphonesFill
|
||||
: AppIcons.headphones,
|
||||
kind: AppButtonKind.ghost,
|
||||
onPressed: () => _runLoginRequiredAction(
|
||||
context,
|
||||
ref,
|
||||
auth,
|
||||
PendingLoginAction(
|
||||
action: LoginRequiredAction.saveListen,
|
||||
reportId: detail.id,
|
||||
contextText: '登录后保存到你的听单',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: YantingSpacing.x2),
|
||||
],
|
||||
Expanded(
|
||||
child: AppButton(
|
||||
label: '原文',
|
||||
icon: AppIcons.externalLink,
|
||||
kind: AppButtonKind.ghost,
|
||||
onPressed: () => showOutboundSheet(context, title: detail.titleCn),
|
||||
onPressed: () => _showSourceSheet(context, ref),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _runLoginRequiredAction(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
AuthState auth,
|
||||
PendingLoginAction action,
|
||||
) {
|
||||
if (auth.loggedIn) {
|
||||
_applyPendingAction(ref, action);
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(authControllerProvider.notifier).requireLogin(action);
|
||||
showLoginSheet(
|
||||
context,
|
||||
reason: action.contextText,
|
||||
onPhoneLogin: () => _loginAndApply(ref, LoginMethod.phone),
|
||||
onSecondaryLogin: () => _loginAndApply(ref, LoginMethod.wechat),
|
||||
);
|
||||
}
|
||||
|
||||
void _loginAndApply(WidgetRef ref, LoginMethod method) {
|
||||
ref.read(authControllerProvider.notifier).login(method).then((pending) {
|
||||
if (pending != null) {
|
||||
_applyPendingAction(ref, pending);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _applyPendingAction(WidgetRef ref, PendingLoginAction action) {
|
||||
final controller = ref.read(profileControllerProvider.notifier);
|
||||
switch (action.action) {
|
||||
case LoginRequiredAction.favorite:
|
||||
controller.toggleFavorite(action.reportId);
|
||||
case LoginRequiredAction.saveListen:
|
||||
controller.toggleSavedListen(action.reportId);
|
||||
}
|
||||
}
|
||||
|
||||
void _showSourceSheet(BuildContext context, WidgetRef ref) {
|
||||
final targetUrl = asString(
|
||||
detail.source['url'],
|
||||
asString(
|
||||
detail.source['source_url'],
|
||||
asString(detail.source['original_url']),
|
||||
),
|
||||
);
|
||||
showOutboundSheet(
|
||||
context,
|
||||
title: detail.titleCn,
|
||||
onConfirm: () => ref
|
||||
.read(outboundRepositoryProvider)
|
||||
.recordOutbound(
|
||||
OutboundEvent(
|
||||
scene: 'report_source',
|
||||
refId: detail.id,
|
||||
targetUrl: targetUrl.isEmpty ? null : targetUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Toc extends StatelessWidget {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../data/api/report_data_source.dart';
|
||||
import '../../data/content_providers.dart';
|
||||
import '../../data/models/models.dart';
|
||||
import '../../data/providers.dart';
|
||||
import '../../routing/app_routes.dart';
|
||||
import '../../theme/yanting_tokens.dart';
|
||||
import '../../widgets/badges.dart';
|
||||
@@ -41,28 +41,25 @@ class FeedPage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final topic = useState('全部');
|
||||
final snapshot = ref.watch(recommendedReportsProvider);
|
||||
final currentTopic = ref.watch(recommendTopicProvider);
|
||||
final snapshot = ref.watch(recommendedByTopicProvider);
|
||||
const topics = ['全部', '宏观', '贵金属', '大宗', '能源', '跨资产', '央行'];
|
||||
|
||||
return snapshot.when(
|
||||
loading: () => const LoadingState(),
|
||||
error: (error, _) => ErrorState(
|
||||
message: error.toString(),
|
||||
onRetry: () => ref.invalidate(recommendedReportsProvider),
|
||||
onRetry: () => ref.invalidate(recommendedByTopicProvider),
|
||||
),
|
||||
data: (items) {
|
||||
final currentTopic = topic.value;
|
||||
final topics = [
|
||||
'全部',
|
||||
...{for (final item in items) ...item.topics},
|
||||
];
|
||||
final visible = currentTopic == '全部'
|
||||
? items
|
||||
: items
|
||||
.where((item) => item.topics.contains(currentTopic))
|
||||
.toList();
|
||||
if (items.isEmpty) {
|
||||
return const EmptyState(title: '暂无可推荐的研报解读', message: '稍后再来看看最新内容');
|
||||
return EmptyState(
|
||||
title: currentTopic == '全部' ? '暂无可推荐的研报解读' : '当前主题暂无内容',
|
||||
message: currentTopic == '全部' ? '稍后再来看看最新内容' : '换个主题,或去研报页看看全部内容',
|
||||
icon: currentTopic == '全部'
|
||||
? Icons.inbox_outlined
|
||||
: Icons.filter_alt_off,
|
||||
);
|
||||
}
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
@@ -83,41 +80,44 @@ class FeedPage extends HookConsumerWidget {
|
||||
child: AppChip(
|
||||
label: t,
|
||||
selected: t == currentTopic,
|
||||
onTap: () => topic.value = t,
|
||||
onTap: () =>
|
||||
ref.read(recommendTopicProvider.notifier).select(t),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: YantingSpacing.cardGap),
|
||||
if (visible.isEmpty)
|
||||
const EmptyState(
|
||||
title: '暂无可推荐的研报解读',
|
||||
message: '换个主题,或去研报页看看全部内容',
|
||||
icon: Icons.filter_alt_off,
|
||||
)
|
||||
else ...[
|
||||
ReportCardWidget(
|
||||
report: visible.first,
|
||||
hero: true,
|
||||
onTap: () => openReportDetail(
|
||||
ReportCardWidget(
|
||||
report: items.first,
|
||||
hero: true,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(profileControllerProvider.notifier)
|
||||
.addHistory(items.first.id);
|
||||
openReportDetail(
|
||||
context,
|
||||
dataSource,
|
||||
visible.first,
|
||||
items.first,
|
||||
player: player,
|
||||
onStartAudio: onStartModuleAudio,
|
||||
onToggleAudio: onToggleAudio,
|
||||
onSeekAudio: onSeekAudio,
|
||||
onSpeed: onSpeed,
|
||||
),
|
||||
onPlayTap: () => _playFromReport(onPlay, visible.first),
|
||||
),
|
||||
const SizedBox(height: YantingSpacing.sectionGap),
|
||||
const SectionTitle(title: '最新解读', icon: Icons.chevron_right),
|
||||
for (final report in visible.skip(1)) ...[
|
||||
ReportCardWidget(
|
||||
report: report,
|
||||
onTap: () => openReportDetail(
|
||||
);
|
||||
},
|
||||
onPlayTap: () => _playFromReport(onPlay, items.first),
|
||||
),
|
||||
const SizedBox(height: YantingSpacing.sectionGap),
|
||||
const SectionTitle(title: '最新解读', icon: Icons.chevron_right),
|
||||
for (final report in items.skip(1)) ...[
|
||||
ReportCardWidget(
|
||||
report: report,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(profileControllerProvider.notifier)
|
||||
.addHistory(report.id);
|
||||
openReportDetail(
|
||||
context,
|
||||
dataSource,
|
||||
report,
|
||||
@@ -126,11 +126,11 @@ class FeedPage extends HookConsumerWidget {
|
||||
onToggleAudio: onToggleAudio,
|
||||
onSeekAudio: onSeekAudio,
|
||||
onSpeed: onSpeed,
|
||||
),
|
||||
onPlayTap: () => _playFromReport(onPlay, report),
|
||||
),
|
||||
const SizedBox(height: YantingSpacing.x3),
|
||||
],
|
||||
);
|
||||
},
|
||||
onPlayTap: () => _playFromReport(onPlay, report),
|
||||
),
|
||||
const SizedBox(height: YantingSpacing.x3),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
@@ -5,6 +5,8 @@ import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../../data/api/report_data_source.dart';
|
||||
import '../../data/models/models.dart';
|
||||
import '../../data/providers.dart';
|
||||
import '../../data/state/app_interaction_state.dart';
|
||||
import '../../routing/app_routes.dart';
|
||||
import '../../theme/app_icons.dart';
|
||||
import '../../theme/yanting_text.dart';
|
||||
@@ -67,7 +69,7 @@ class InstitutionDetailPage extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _InstitutionDetailContent extends StatelessWidget {
|
||||
class _InstitutionDetailContent extends ConsumerWidget {
|
||||
const _InstitutionDetailContent({
|
||||
required this.item,
|
||||
required this.dataSource,
|
||||
@@ -77,7 +79,7 @@ class _InstitutionDetailContent extends StatelessWidget {
|
||||
final ReportDataSource dataSource;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
YantingSpacing.x4,
|
||||
@@ -165,7 +167,12 @@ class _InstitutionDetailContent extends StatelessWidget {
|
||||
for (final report in item.recentReports) ...[
|
||||
ReportCardWidget(
|
||||
report: report,
|
||||
onTap: () => openReportDetail(context, dataSource, report),
|
||||
onTap: () {
|
||||
ref
|
||||
.read(profileControllerProvider.notifier)
|
||||
.addHistory(report.id);
|
||||
openReportDetail(context, dataSource, report);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: YantingSpacing.x3),
|
||||
],
|
||||
@@ -174,7 +181,19 @@ class _InstitutionDetailContent extends StatelessWidget {
|
||||
icon: AppIcons.externalLink,
|
||||
kind: AppButtonKind.ghost,
|
||||
expand: true,
|
||||
onPressed: () => showOutboundSheet(context, title: item.nameCn),
|
||||
onPressed: () => showOutboundSheet(
|
||||
context,
|
||||
title: item.nameCn,
|
||||
onConfirm: () => ref
|
||||
.read(outboundRepositoryProvider)
|
||||
.recordOutbound(
|
||||
OutboundEvent(
|
||||
scene: 'institution_service',
|
||||
refId: item.id,
|
||||
targetUrl: item.websiteUrl.isEmpty ? null : item.websiteUrl,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 '../../data/providers.dart';
|
||||
import '../../theme/app_icons.dart';
|
||||
import '../../theme/yanting_text.dart';
|
||||
import '../../theme/yanting_tokens.dart';
|
||||
@@ -49,12 +50,15 @@ class ListenPage extends HookConsumerWidget {
|
||||
const SectionTitle(title: '继续收听'),
|
||||
_ContinueListeningCard(
|
||||
item: current,
|
||||
onPlay: () => onPlay(current),
|
||||
onPlay: () => _playAndRemember(ref, current),
|
||||
),
|
||||
const SectionTitle(title: '全部音频', icon: AppIcons.arrowRight),
|
||||
const SizedBox(height: YantingSpacing.cardGap),
|
||||
for (final item in items.skip(1)) ...[
|
||||
_AudioListCard(item: item, onPlay: () => onPlay(item)),
|
||||
_AudioListCard(
|
||||
item: item,
|
||||
onPlay: () => _playAndRemember(ref, item),
|
||||
),
|
||||
const SizedBox(height: YantingSpacing.x3),
|
||||
],
|
||||
],
|
||||
@@ -62,6 +66,11 @@ class ListenPage extends HookConsumerWidget {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _playAndRemember(WidgetRef ref, AudioItem item) {
|
||||
ref.read(profileControllerProvider.notifier).addHistory(item.reportId);
|
||||
onPlay(item);
|
||||
}
|
||||
}
|
||||
|
||||
class _ContinueListeningCard extends StatelessWidget {
|
||||
@@ -98,13 +107,13 @@ class _ContinueListeningCard extends StatelessWidget {
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text(
|
||||
item.institution.nameCn,
|
||||
style: YantingText.meta.copyWith(
|
||||
color: colors.foreground,
|
||||
fontWeight: FontWeight.w500,
|
||||
Text(
|
||||
item.institution.nameCn,
|
||||
style: YantingText.meta.copyWith(
|
||||
color: colors.foreground,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text('·', style: YantingText.meta),
|
||||
Text(
|
||||
'全长 ${formatDuration(item.durationSec)}',
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
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 '../../theme/app_icons.dart';
|
||||
import '../../data/content_providers.dart';
|
||||
import '../../data/models/models.dart';
|
||||
import '../../data/providers.dart';
|
||||
import '../../data/state/app_interaction_state.dart';
|
||||
import '../../routing/app_routes.dart';
|
||||
import '../../theme/app_icons.dart';
|
||||
import '../../theme/yanting_text.dart';
|
||||
import '../../theme/yanting_tokens.dart';
|
||||
import '../../widgets/app_buttons.dart';
|
||||
@@ -12,15 +17,34 @@ import '../../widgets/app_card.dart';
|
||||
import '../../widgets/page_header.dart';
|
||||
import '../../widgets/sheets.dart';
|
||||
import '../../widgets/states.dart';
|
||||
import '../shared/report_card_widget.dart';
|
||||
|
||||
class ProfilePage extends StatelessWidget {
|
||||
class ProfilePage extends ConsumerWidget {
|
||||
const ProfilePage({required this.dataSource, super.key});
|
||||
|
||||
final ReportDataSource dataSource;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colors = ShadTheme.of(context).colorScheme;
|
||||
final auth = ref.watch(authControllerProvider);
|
||||
final profile = ref.watch(profileControllerProvider);
|
||||
final historySnapshot = ref.watch(profileHistoryReportsProvider);
|
||||
final favoriteSnapshot = ref.watch(profileFavoriteReportsProvider);
|
||||
final savedListenSnapshot = ref.watch(profileSavedListenReportsProvider);
|
||||
final historyCount = historySnapshot.maybeWhen(
|
||||
data: (items) => items.length,
|
||||
orElse: () => profile.history.length,
|
||||
);
|
||||
final favoriteCount = favoriteSnapshot.maybeWhen(
|
||||
data: (items) => items.length,
|
||||
orElse: () => profile.favorites.length,
|
||||
);
|
||||
final savedListenCount = savedListenSnapshot.maybeWhen(
|
||||
data: (items) => items.length,
|
||||
orElse: () => profile.savedListens.length,
|
||||
);
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
YantingSpacing.screenX,
|
||||
@@ -46,7 +70,7 @@ class ProfilePage extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'未登录',
|
||||
auth.loggedIn ? '已登录' : '未登录',
|
||||
style: YantingText.cardTitle.copyWith(
|
||||
fontSize: 18,
|
||||
color: colors.foreground,
|
||||
@@ -54,7 +78,7 @@ class ProfilePage extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
'登录后同步收藏、历史和听单',
|
||||
auth.loggedIn ? '收藏、历史和听单已在本地同步' : '登录后同步收藏、历史和听单',
|
||||
style: YantingText.meta.copyWith(
|
||||
height: 1.5,
|
||||
color: colors.mutedForeground,
|
||||
@@ -68,9 +92,16 @@ class ProfilePage extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: YantingSpacing.x3),
|
||||
AppButton(
|
||||
label: '登录 / 注册',
|
||||
label: auth.loggedIn ? '退出登录' : '登录 / 注册',
|
||||
expand: true,
|
||||
onPressed: () => showLoginSheet(context),
|
||||
onPressed: auth.loggedIn
|
||||
? () => ref.read(authControllerProvider.notifier).logout()
|
||||
: () => showLoginSheet(
|
||||
context,
|
||||
reason: '登录后同步收藏、历史和听单',
|
||||
onPhoneLogin: () => _login(ref, LoginMethod.phone),
|
||||
onSecondaryLogin: () => _login(ref, LoginMethod.wechat),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_MenuGroup(
|
||||
@@ -78,8 +109,43 @@ class ProfilePage extends StatelessWidget {
|
||||
_MenuRow(
|
||||
icon: AppIcons.history,
|
||||
title: '本地浏览记录',
|
||||
trailing: '0 条 · 本地临时',
|
||||
onTap: () => showAppToast(context, '历史同步接口待接入'),
|
||||
trailing: '$historyCount 条 · 本地临时',
|
||||
onTap: () => _showProfileListSheet(
|
||||
context,
|
||||
ref,
|
||||
title: '本地浏览记录',
|
||||
snapshot: historySnapshot,
|
||||
emptyTitle: '暂无本地浏览记录',
|
||||
emptyMessage: '打开研报详情或播放音频后会出现在这里',
|
||||
),
|
||||
),
|
||||
_MenuRow(
|
||||
icon: AppIcons.heart,
|
||||
title: '我的收藏',
|
||||
trailing: '$favoriteCount 条',
|
||||
onTap: () => _showLoginAwareList(
|
||||
context,
|
||||
ref,
|
||||
auth,
|
||||
title: '我的收藏',
|
||||
snapshot: favoriteSnapshot,
|
||||
emptyTitle: '暂无收藏',
|
||||
emptyMessage: '在研报详情页点击收藏后会出现在这里',
|
||||
),
|
||||
),
|
||||
_MenuRow(
|
||||
icon: AppIcons.headphones,
|
||||
title: '已存听单',
|
||||
trailing: '$savedListenCount 条',
|
||||
onTap: () => _showLoginAwareList(
|
||||
context,
|
||||
ref,
|
||||
auth,
|
||||
title: '已存听单',
|
||||
snapshot: savedListenSnapshot,
|
||||
emptyTitle: '暂无已存听单',
|
||||
emptyMessage: '在音频研报详情页保存听单后会出现在这里',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -94,19 +160,21 @@ class ProfilePage extends StatelessWidget {
|
||||
_MenuRow(
|
||||
icon: AppIcons.fileList,
|
||||
title: '用户协议',
|
||||
onTap: () => showOutboundSheet(context, title: '用户协议'),
|
||||
onTap: () =>
|
||||
_showOutbound(context, ref, 'user_agreement', '用户协议'),
|
||||
),
|
||||
_MenuRow(
|
||||
icon: AppIcons.shield,
|
||||
title: '隐私政策',
|
||||
onTap: () => showOutboundSheet(context, title: '隐私政策'),
|
||||
onTap: () =>
|
||||
_showOutbound(context, ref, 'privacy_policy', '隐私政策'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: YantingSpacing.x3),
|
||||
AppCard(
|
||||
color: colors.secondary,
|
||||
onTap: () => showOutboundSheet(context, title: '相关服务'),
|
||||
onTap: () => _showOutbound(context, ref, 'profile_services', '相关服务'),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -143,6 +211,109 @@ class ProfilePage extends StatelessWidget {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _login(WidgetRef ref, LoginMethod method) async {
|
||||
await ref.read(authControllerProvider.notifier).login(method);
|
||||
await ref.read(profileControllerProvider.notifier).refresh();
|
||||
}
|
||||
|
||||
void _showOutbound(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String scene,
|
||||
String title,
|
||||
) {
|
||||
showOutboundSheet(
|
||||
context,
|
||||
title: title,
|
||||
onConfirm: () => ref
|
||||
.read(outboundRepositoryProvider)
|
||||
.recordOutbound(OutboundEvent(scene: scene)),
|
||||
);
|
||||
}
|
||||
|
||||
void _showLoginAwareList(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
AuthState auth, {
|
||||
required String title,
|
||||
required AsyncValue<List<ReportCardModel>> snapshot,
|
||||
required String emptyTitle,
|
||||
required String emptyMessage,
|
||||
}) {
|
||||
if (!auth.loggedIn) {
|
||||
showLoginSheet(
|
||||
context,
|
||||
reason: '登录后查看$title',
|
||||
onPhoneLogin: () => _login(ref, LoginMethod.phone),
|
||||
onSecondaryLogin: () => _login(ref, LoginMethod.wechat),
|
||||
);
|
||||
return;
|
||||
}
|
||||
_showProfileListSheet(
|
||||
context,
|
||||
ref,
|
||||
title: title,
|
||||
snapshot: snapshot,
|
||||
emptyTitle: emptyTitle,
|
||||
emptyMessage: emptyMessage,
|
||||
);
|
||||
}
|
||||
|
||||
void _showProfileListSheet(
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
required String title,
|
||||
required AsyncValue<List<ReportCardModel>> snapshot,
|
||||
required String emptyTitle,
|
||||
required String emptyMessage,
|
||||
}) {
|
||||
showShadSheet<void>(
|
||||
context: context,
|
||||
side: ShadSheetSide.bottom,
|
||||
builder: (sheetContext) => ShadSheet(
|
||||
title: Text(title),
|
||||
description: const Text('本地状态列表,真实同步接口后续接入。'),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.66,
|
||||
),
|
||||
child: snapshot.when(
|
||||
loading: () => const LoadingState(label: '正在加载列表'),
|
||||
error: (error, _) => ErrorState(message: error.toString()),
|
||||
data: (items) {
|
||||
if (items.isEmpty) {
|
||||
return EmptyState(
|
||||
title: emptyTitle,
|
||||
message: emptyMessage,
|
||||
icon: Icons.inbox_outlined,
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (_, _) =>
|
||||
const SizedBox(height: YantingSpacing.x3),
|
||||
itemBuilder: (_, index) {
|
||||
final report = items[index];
|
||||
return ReportCardWidget(
|
||||
report: report,
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
ref
|
||||
.read(profileControllerProvider.notifier)
|
||||
.addHistory(report.id);
|
||||
openReportDetail(context, dataSource, report);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MenuGroup extends StatelessWidget {
|
||||
|
||||
@@ -6,6 +6,8 @@ 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 '../../data/providers.dart';
|
||||
import '../../data/state/report_query.dart';
|
||||
import '../../routing/app_routes.dart';
|
||||
import '../../theme/yanting_text.dart';
|
||||
import '../../theme/yanting_tokens.dart';
|
||||
@@ -44,28 +46,34 @@ class ReportsPage extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = ShadTheme.of(context);
|
||||
final searchController = useTextEditingController();
|
||||
final query = useState('');
|
||||
final topic = useState('');
|
||||
final hasAudio = useState(false);
|
||||
final snapshot = ref.watch(reportsProvider);
|
||||
final query = ref.watch(reportFilterProvider);
|
||||
final snapshot = ref.watch(filteredReportsProvider);
|
||||
final allReportsSnapshot = ref.watch(reportsProvider);
|
||||
final institutionsSnapshot = ref.watch(institutionsProvider);
|
||||
final controller = ref.read(reportFilterProvider.notifier);
|
||||
final allReports = allReportsSnapshot.maybeWhen(
|
||||
data: (items) => items,
|
||||
orElse: () => const <ReportCardModel>[],
|
||||
);
|
||||
final institutions = institutionsSnapshot.maybeWhen(
|
||||
data: (items) => items,
|
||||
orElse: () => const <Institution>[],
|
||||
);
|
||||
|
||||
useEffect(() {
|
||||
if (searchController.text != query.search) {
|
||||
searchController.text = query.search;
|
||||
}
|
||||
return null;
|
||||
}, [query.search]);
|
||||
|
||||
return snapshot.when(
|
||||
loading: () => const LoadingState(label: '正在搜索研报'),
|
||||
error: (error, _) => ErrorState(
|
||||
message: error.toString(),
|
||||
onRetry: () => ref.invalidate(reportsProvider),
|
||||
onRetry: () => ref.invalidate(filteredReportsProvider),
|
||||
),
|
||||
data: (items) {
|
||||
final currentQuery = query.value;
|
||||
final currentTopic = topic.value;
|
||||
final currentHasAudio = hasAudio.value;
|
||||
final filtered = _applyFilters(
|
||||
items,
|
||||
query: currentQuery,
|
||||
topic: currentTopic,
|
||||
hasAudio: currentHasAudio,
|
||||
);
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
YantingSpacing.screenX,
|
||||
@@ -84,7 +92,7 @@ class ReportsPage extends HookConsumerWidget {
|
||||
padding: EdgeInsets.only(right: 8),
|
||||
child: Icon(LucideIcons.search, size: 16),
|
||||
),
|
||||
trailing: currentQuery.isEmpty
|
||||
trailing: query.search.isEmpty
|
||||
? null
|
||||
: Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
@@ -92,12 +100,12 @@ class ReportsPage extends HookConsumerWidget {
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: () {
|
||||
searchController.clear();
|
||||
query.value = '';
|
||||
controller.setSearch('');
|
||||
},
|
||||
child: const Icon(LucideIcons.x, size: 16),
|
||||
),
|
||||
),
|
||||
onChanged: (value) => query.value = value.trim(),
|
||||
onChanged: (value) => controller.setSearch(value.trim()),
|
||||
),
|
||||
const SizedBox(height: YantingSpacing.cardGap),
|
||||
Row(
|
||||
@@ -109,36 +117,42 @@ class ReportsPage extends HookConsumerWidget {
|
||||
runSpacing: YantingSpacing.x2,
|
||||
children: [
|
||||
ShadButton.outline(
|
||||
onPressed: items.isEmpty
|
||||
onPressed: allReports.isEmpty
|
||||
? null
|
||||
: () => _openFilterSheet(
|
||||
context,
|
||||
items: items,
|
||||
topic: topic,
|
||||
items: allReports,
|
||||
institutions: institutions,
|
||||
),
|
||||
leading: const Icon(
|
||||
LucideIcons.slidersHorizontal,
|
||||
size: 16,
|
||||
),
|
||||
child: const Text('筛选'),
|
||||
child: Text(query.hasActiveFilter ? '筛选中' : '筛选'),
|
||||
),
|
||||
ShadButton.outline(
|
||||
onPressed: () {},
|
||||
onPressed: () => controller.setSort(
|
||||
query.sort == ReportSort.latest
|
||||
? ReportSort.oldest
|
||||
: ReportSort.latest,
|
||||
),
|
||||
leading: const Icon(
|
||||
LucideIcons.arrowUpDown,
|
||||
size: 16,
|
||||
),
|
||||
child: const Text('最新'),
|
||||
child: Text(
|
||||
query.sort == ReportSort.latest ? '最新' : '最早',
|
||||
),
|
||||
),
|
||||
ShadBadge.secondary(
|
||||
onPressed: () => hasAudio.value = !currentHasAudio,
|
||||
backgroundColor: currentHasAudio
|
||||
onPressed: controller.toggleAudio,
|
||||
backgroundColor: query.hasAudio
|
||||
? theme.colorScheme.foreground
|
||||
: theme.colorScheme.secondary,
|
||||
foregroundColor: currentHasAudio
|
||||
foregroundColor: query.hasAudio
|
||||
? theme.colorScheme.background
|
||||
: theme.colorScheme.secondaryForeground,
|
||||
hoverBackgroundColor: currentHasAudio
|
||||
hoverBackgroundColor: query.hasAudio
|
||||
? theme.colorScheme.foreground.withValues(
|
||||
alpha: 0.9,
|
||||
)
|
||||
@@ -151,40 +165,40 @@ class ReportsPage extends HookConsumerWidget {
|
||||
const SizedBox(width: YantingSpacing.x2),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
child: Text(
|
||||
'共 ${filtered.length} 篇',
|
||||
style: YantingText.meta,
|
||||
),
|
||||
child: Text('共 ${items.length} 篇', style: YantingText.meta),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: YantingSpacing.cardGap),
|
||||
if (filtered.isEmpty)
|
||||
if (items.isEmpty)
|
||||
EmptyState(
|
||||
title: currentQuery.isNotEmpty ? '未找到相关研报' : '当前筛选下暂无研报',
|
||||
message: currentQuery.isNotEmpty ? '换个关键词试试' : '调整筛选条件后再试',
|
||||
title: query.search.isNotEmpty ? '未找到相关研报' : '当前筛选下暂无研报',
|
||||
message: query.search.isNotEmpty ? '换个关键词试试' : '调整筛选条件后再试',
|
||||
actionLabel: '清除筛选',
|
||||
onAction: () {
|
||||
searchController.clear();
|
||||
query.value = '';
|
||||
topic.value = '';
|
||||
hasAudio.value = false;
|
||||
controller.reset();
|
||||
},
|
||||
)
|
||||
else
|
||||
for (final report in filtered) ...[
|
||||
for (final report in items) ...[
|
||||
ReportCardWidget(
|
||||
report: report,
|
||||
onTap: () => openReportDetail(
|
||||
context,
|
||||
dataSource,
|
||||
report,
|
||||
player: player,
|
||||
onStartAudio: onStartModuleAudio,
|
||||
onToggleAudio: onToggleAudio,
|
||||
onSeekAudio: onSeekAudio,
|
||||
onSpeed: onSpeed,
|
||||
),
|
||||
onTap: () {
|
||||
ref
|
||||
.read(profileControllerProvider.notifier)
|
||||
.addHistory(report.id);
|
||||
openReportDetail(
|
||||
context,
|
||||
dataSource,
|
||||
report,
|
||||
player: player,
|
||||
onStartAudio: onStartModuleAudio,
|
||||
onToggleAudio: onToggleAudio,
|
||||
onSeekAudio: onSeekAudio,
|
||||
onSpeed: onSpeed,
|
||||
);
|
||||
},
|
||||
onPlayTap: () => onPlay(
|
||||
AudioItem(
|
||||
audioId: 'local_${report.id}',
|
||||
@@ -206,88 +220,181 @@ class ReportsPage extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
List<ReportCardModel> _applyFilters(
|
||||
List<ReportCardModel> items, {
|
||||
required String query,
|
||||
required String topic,
|
||||
required bool hasAudio,
|
||||
}) {
|
||||
return items.where((item) {
|
||||
final hay =
|
||||
'${item.titleCn} ${item.institution.nameCn} ${item.topics.join(' ')}'
|
||||
.toLowerCase();
|
||||
if (query.isNotEmpty && !hay.contains(query.toLowerCase())) return false;
|
||||
if (topic.isNotEmpty && !item.topics.contains(topic)) return false;
|
||||
if (hasAudio && !item.hasAudio) return false;
|
||||
return true;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
void _openFilterSheet(
|
||||
BuildContext context, {
|
||||
required List<ReportCardModel> items,
|
||||
required ValueNotifier<String> topic,
|
||||
required List<Institution> institutions,
|
||||
}) {
|
||||
final topics = {for (final item in items) ...item.topics}.toList();
|
||||
const demoTopics = ['宏观', '贵金属', '大宗', '能源', '跨资产', '央行'];
|
||||
final dynamicTopics = {for (final item in items) ...item.topics};
|
||||
final topics = [
|
||||
...demoTopics,
|
||||
...dynamicTopics.where((topic) => !demoTopics.contains(topic)),
|
||||
];
|
||||
final orderedInstitutions = [...institutions]
|
||||
..sort((a, b) => b.reportCount.compareTo(a.reportCount));
|
||||
|
||||
showShadSheet<void>(
|
||||
context: context,
|
||||
side: ShadSheetSide.bottom,
|
||||
builder: (context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
final selectedBackground = theme.colorScheme.foreground;
|
||||
final selectedForeground = theme.colorScheme.background;
|
||||
final unselectedBackground = theme.colorScheme.secondary;
|
||||
final unselectedForeground = theme.colorScheme.secondaryForeground;
|
||||
return Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final theme = ShadTheme.of(context);
|
||||
final query = ref.watch(reportFilterProvider);
|
||||
final controller = ref.read(reportFilterProvider.notifier);
|
||||
final selectedBackground = theme.colorScheme.foreground;
|
||||
final selectedForeground = theme.colorScheme.background;
|
||||
final unselectedBackground = theme.colorScheme.secondary;
|
||||
final unselectedForeground = theme.colorScheme.secondaryForeground;
|
||||
|
||||
return ShadSheet(
|
||||
title: const Text('筛选研报'),
|
||||
description: const Text('按主题快速收窄列表。'),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: YantingSpacing.x2,
|
||||
runSpacing: YantingSpacing.x2,
|
||||
ShadBadge option({
|
||||
required String label,
|
||||
required bool selected,
|
||||
required VoidCallback onPressed,
|
||||
}) {
|
||||
return ShadBadge.secondary(
|
||||
onPressed: onPressed,
|
||||
backgroundColor: selected
|
||||
? selectedBackground
|
||||
: unselectedBackground,
|
||||
foregroundColor: selected
|
||||
? selectedForeground
|
||||
: unselectedForeground,
|
||||
hoverBackgroundColor: selected
|
||||
? selectedBackground.withValues(alpha: 0.9)
|
||||
: theme.colorScheme.border,
|
||||
child: Text(label),
|
||||
);
|
||||
}
|
||||
|
||||
return ShadSheet(
|
||||
title: const Text('筛选研报'),
|
||||
description: const Text('按主题、机构和音频状态收窄列表。'),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ShadBadge.secondary(
|
||||
onPressed: () => topic.value = '',
|
||||
backgroundColor: topic.value.isEmpty
|
||||
? selectedBackground
|
||||
: unselectedBackground,
|
||||
foregroundColor: topic.value.isEmpty
|
||||
? selectedForeground
|
||||
: unselectedForeground,
|
||||
hoverBackgroundColor: topic.value.isEmpty
|
||||
? selectedBackground.withValues(alpha: 0.9)
|
||||
: theme.colorScheme.border,
|
||||
child: const Text('全部主题'),
|
||||
const _FilterGroupTitle('主题'),
|
||||
Wrap(
|
||||
spacing: YantingSpacing.x2,
|
||||
runSpacing: YantingSpacing.x2,
|
||||
children: [
|
||||
option(
|
||||
label: '全部主题',
|
||||
selected: query.topic == null,
|
||||
onPressed: () => controller.setTopic(null),
|
||||
),
|
||||
for (final topic in topics)
|
||||
option(
|
||||
label: topic,
|
||||
selected: query.topic == topic,
|
||||
onPressed: () => controller.setTopic(
|
||||
query.topic == topic ? null : topic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: YantingSpacing.x4),
|
||||
const _FilterGroupTitle('机构'),
|
||||
Wrap(
|
||||
spacing: YantingSpacing.x2,
|
||||
runSpacing: YantingSpacing.x2,
|
||||
children: [
|
||||
option(
|
||||
label: '全部机构',
|
||||
selected: query.institutionId == null,
|
||||
onPressed: () => controller.setInstitution(null),
|
||||
),
|
||||
for (final institution in orderedInstitutions.take(8))
|
||||
option(
|
||||
label: institution.nameCn,
|
||||
selected: query.institutionId == institution.id,
|
||||
onPressed: () => controller.setInstitution(
|
||||
query.institutionId == institution.id
|
||||
? null
|
||||
: institution.id,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: YantingSpacing.x4),
|
||||
const _FilterGroupTitle('音频'),
|
||||
Wrap(
|
||||
spacing: YantingSpacing.x2,
|
||||
runSpacing: YantingSpacing.x2,
|
||||
children: [
|
||||
option(
|
||||
label: '不限',
|
||||
selected: !query.hasAudio,
|
||||
onPressed: () {
|
||||
if (query.hasAudio) controller.toggleAudio();
|
||||
},
|
||||
),
|
||||
option(
|
||||
label: '只看音频',
|
||||
selected: query.hasAudio,
|
||||
onPressed: () {
|
||||
if (!query.hasAudio) controller.toggleAudio();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: YantingSpacing.x4),
|
||||
const _FilterGroupTitle('排序'),
|
||||
Wrap(
|
||||
spacing: YantingSpacing.x2,
|
||||
runSpacing: YantingSpacing.x2,
|
||||
children: [
|
||||
option(
|
||||
label: '最新发布',
|
||||
selected: query.sort == ReportSort.latest,
|
||||
onPressed: () => controller.setSort(ReportSort.latest),
|
||||
),
|
||||
option(
|
||||
label: '最早发布',
|
||||
selected: query.sort == ReportSort.oldest,
|
||||
onPressed: () => controller.setSort(ReportSort.oldest),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: YantingSpacing.x4),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ShadButton.outline(
|
||||
onPressed: controller.reset,
|
||||
child: const Text('重置'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: YantingSpacing.x2),
|
||||
Expanded(
|
||||
child: ShadButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('查看结果'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
for (final t in topics)
|
||||
ShadBadge.secondary(
|
||||
onPressed: () => topic.value = t,
|
||||
backgroundColor: topic.value == t
|
||||
? selectedBackground
|
||||
: unselectedBackground,
|
||||
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('完成'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _FilterGroupTitle extends StatelessWidget {
|
||||
const _FilterGroupTitle(this.label);
|
||||
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: YantingSpacing.x2),
|
||||
child: Text(label, style: YantingText.sectionTitle),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../../data/providers.dart';
|
||||
import '../../data/state/app_interaction_state.dart';
|
||||
import '../../routing/app_routes.dart';
|
||||
import '../../theme/app_icons.dart';
|
||||
import '../../theme/theme_controller.dart';
|
||||
@@ -88,13 +90,23 @@ class SettingsPage extends ConsumerWidget {
|
||||
_LinkTile(
|
||||
icon: Icons.description_outlined,
|
||||
title: '用户协议',
|
||||
onTap: () => showOutboundSheet(context, title: '用户协议'),
|
||||
onTap: () => _showOutbound(
|
||||
context,
|
||||
ref,
|
||||
'settings_user_agreement',
|
||||
'用户协议',
|
||||
),
|
||||
),
|
||||
const Divider(height: 1, thickness: 1),
|
||||
_LinkTile(
|
||||
icon: Icons.privacy_tip_outlined,
|
||||
title: '隐私政策',
|
||||
onTap: () => showOutboundSheet(context, title: '隐私政策'),
|
||||
onTap: () => _showOutbound(
|
||||
context,
|
||||
ref,
|
||||
'settings_privacy_policy',
|
||||
'隐私政策',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -130,6 +142,21 @@ class SettingsPage extends ConsumerWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showOutbound(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String scene,
|
||||
String title,
|
||||
) {
|
||||
showOutboundSheet(
|
||||
context,
|
||||
title: title,
|
||||
onConfirm: () => ref
|
||||
.read(outboundRepositoryProvider)
|
||||
.recordOutbound(OutboundEvent(scene: scene)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThemeModeTile extends StatelessWidget {
|
||||
|
||||
@@ -69,7 +69,9 @@ class _ShellPageState extends ConsumerState<ShellPage> {
|
||||
Text(
|
||||
header.title,
|
||||
textAlign: TextAlign.center,
|
||||
style: YantingText.listTitle,
|
||||
style: YantingText.listTitle.copyWith(
|
||||
color: theme.colorScheme.foreground,
|
||||
),
|
||||
),
|
||||
if (header.subtitle.isNotEmpty)
|
||||
Text(
|
||||
@@ -77,7 +79,10 @@ class _ShellPageState extends ConsumerState<ShellPage> {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: YantingText.meta.copyWith(fontSize: 12),
|
||||
style: YantingText.meta.copyWith(
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user