Files
xianyan/lib/features/discover/presentation/pages/tool/rss_reader_page.dart
2026-06-12 22:30:26 +08:00

1197 lines
38 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// ============================================================
/// 闲言APP — RSS阅读器页面
/// 创建时间: 2026-05-30
/// 更新时间: 2026-06-12
/// 作用: iOS风格RSS订阅阅读器 — 订阅源管理/文章列表/文章详情/分类筛选
/// 上次更新: 新增OPML导入导出/文章搜索栏/阅读进度同步/卡片式阅读模式;
/// 保留ConsumerStatefulWidget+Riverpod+PopScope+查看原文弹窗/
/// 网络异常标签/Shimmer骨架屏/FullScreenPhotoView/收藏/分页加载
/// ============================================================
import 'dart:convert';
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:xianyan/core/theme/app_spacing.dart';
import 'package:xianyan/core/theme/app_theme.dart';
import 'package:xianyan/core/theme/app_typography.dart';
import 'package:xianyan/core/theme/app_radius.dart';
import 'package:xianyan/core/utils/logger.dart';
import 'package:xianyan/core/utils/data/extensions.dart';
import 'package:xianyan/core/utils/data/html_utils.dart';
import 'package:xianyan/features/discover/services/rss_provider.dart';
import 'package:xianyan/features/discover/services/rss_service.dart';
import 'package:xianyan/features/discover/presentation/pages/tool/rss_add_subscription_sheet.dart';
import 'package:xianyan/features/discover/presentation/pages/tool/rss_widgets.dart';
import 'package:xianyan/features/settings/presentation/plugin_widgets/tts_player_sheet.dart';
import 'package:xianyan/shared/widgets/adaptive/adaptive_back_button.dart';
import 'package:xianyan/shared/widgets/containers/glass_container.dart';
import 'package:xianyan/shared/widgets/feedback/shimmer_placeholder.dart';
import 'package:xianyan/shared/widgets/media/full_screen_photo_view.dart';
/// ── RSS阅读器页面 ──
class RssReaderPage extends ConsumerStatefulWidget {
const RssReaderPage({super.key});
@override
ConsumerState<RssReaderPage> createState() => _RssReaderPageState();
}
class _RssReaderPageState extends ConsumerState<RssReaderPage> {
@override
Widget build(BuildContext context) {
final ext = AppTheme.ext(context);
final state = ref.watch(rssProvider);
final notifier = ref.read(rssProvider.notifier);
return CupertinoPageScaffold(
child: PopScope(
canPop: state.currentView == RssView.subscriptions,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
if (state.currentView == RssView.articleDetail) {
notifier.closeDetail();
} else if (state.currentView == RssView.articleList) {
notifier.backToSubscriptions();
}
},
child: Column(
children: [
CupertinoNavigationBar(
leading: _buildLeading(ext, state, notifier),
middle: Text(
_buildTitle(state),
style: AppTypography.title3.copyWith(color: ext.textPrimary),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: _buildTrailing(ext, state, notifier),
backgroundColor: ext.bgElevated.withValues(alpha: 0.85),
border: null,
),
if (state.currentView == RssView.articleList &&
!state.isFeedAvailable)
_buildNetworkWarningBar(),
Expanded(
child: SafeArea(
child: switch (state.currentView) {
RssView.subscriptions => _buildSubscriptionHome(
ext,
state,
notifier,
),
RssView.articleList => _buildArticleList(
ext,
state,
notifier,
),
RssView.articleDetail => _buildArticleDetail(
ext,
state,
notifier,
),
},
),
),
],
),
),
);
}
// ── 导航栏 ──
String _buildTitle(RssState state) => switch (state.currentView) {
RssView.articleDetail => state.selectedItem?.title ?? '文章详情',
RssView.articleList => state.selectedSubscription?.title ?? '📡 RSS订阅',
RssView.subscriptions => '📡 RSS订阅',
};
Widget _buildLeading(
AppThemeExtension ext,
RssState state,
RssNotifier notifier,
) {
if (state.currentView == RssView.articleDetail) {
return CupertinoButton(
padding: EdgeInsets.zero,
onPressed: notifier.closeDetail,
child: Icon(CupertinoIcons.chevron_left, color: ext.accent),
);
}
if (state.currentView == RssView.articleList) {
return CupertinoButton(
padding: EdgeInsets.zero,
onPressed: notifier.backToSubscriptions,
child: Icon(CupertinoIcons.chevron_left, color: ext.accent),
);
}
return const AdaptiveBackButton();
}
Widget _buildTrailing(
AppThemeExtension ext,
RssState state,
RssNotifier notifier,
) {
// 文章详情 — 朗读按钮
if (state.currentView == RssView.articleDetail) {
return GestureDetector(
onTap: () => _speakArticle(state),
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: ext.bgSecondary,
borderRadius: AppRadius.mdBorder,
),
child: const Center(
child: Text('🔊', style: TextStyle(fontSize: 14)),
),
),
);
}
// 文章列表 — 卡片/列表模式切换
if (state.currentView == RssView.articleList) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: notifier.toggleCardMode,
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: state.isCardMode
? ext.accent.withValues(alpha: 0.12)
: ext.bgSecondary,
borderRadius: AppRadius.mdBorder,
),
child: Icon(
state.isCardMode
? CupertinoIcons.list_bullet
: CupertinoIcons.square_grid_2x2,
size: 16,
color: state.isCardMode ? ext.accent : ext.textHint,
),
),
),
],
);
}
// 订阅源首页 — 网络标识 + 更多菜单 + 添加按钮
return Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildNetworkBadge(ext),
const SizedBox(width: AppSpacing.xs),
// 更多菜单OPML导入导出
_buildMoreMenu(ext, notifier),
const SizedBox(width: AppSpacing.xs),
// 添加按钮
GestureDetector(
onTap: _showAddSubscriptionSheet,
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: ext.accent.withValues(alpha: 0.1),
borderRadius: AppRadius.mdBorder,
),
child: Icon(CupertinoIcons.plus, size: 18, color: ext.accent),
),
),
],
);
}
/// 网络标识
Widget _buildNetworkBadge(AppThemeExtension ext) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: AppSpacing.xs,
),
decoration: BoxDecoration(
color: ext.accent.withValues(alpha: 0.1),
borderRadius: AppRadius.mdBorder,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(CupertinoIcons.globe, size: 12, color: ext.accent),
const SizedBox(width: 3),
Text('联网', style: AppTypography.caption2.copyWith(color: ext.accent)),
],
),
);
}
/// 网络异常提示条
Widget _buildNetworkWarningBar() {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.xs,
),
color: CupertinoColors.systemRed.withValues(alpha: 0.1),
child: Row(
children: [
const Icon(
CupertinoIcons.wifi_exclamationmark,
size: 14,
color: CupertinoColors.systemRed,
),
const SizedBox(width: AppSpacing.xs),
Text(
'网络连接异常,部分内容可能无法加载',
style: AppTypography.caption1.copyWith(
color: CupertinoColors.systemRed,
),
),
],
),
);
}
// ── 订阅源首页(聚合视图) ──
Widget _buildSubscriptionHome(
AppThemeExtension ext,
RssState state,
RssNotifier notifier,
) {
final filteredSubs = _getFilteredSubscriptions(state);
return CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.md,
AppSpacing.md,
AppSpacing.md,
0,
),
sliver: SliverToBoxAdapter(
child: _buildCategoryFilter(ext, state, notifier),
),
),
SliverPadding(
padding: const EdgeInsets.all(AppSpacing.md),
sliver: SliverToBoxAdapter(
child: Row(
children: [
const Text('📡', style: TextStyle(fontSize: 20)),
const SizedBox(width: AppSpacing.sm),
Text(
'我的订阅',
style: AppTypography.headline.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: AppSpacing.sm),
Text(
'${filteredSubs.length}',
style: AppTypography.caption1.copyWith(color: ext.textHint),
),
],
),
),
),
if (filteredSubs.isEmpty)
SliverPadding(
padding: const EdgeInsets.all(AppSpacing.xl),
sliver: SliverToBoxAdapter(child: _buildEmptyState(ext)),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final sub = filteredSubs[index];
return RssSubscriptionCard(
subscription: sub,
onTap: () => notifier.selectSubscription(sub),
onDelete: () => _deleteSubscription(sub, notifier),
);
}, childCount: filteredSubs.length),
),
),
const SliverPadding(padding: EdgeInsets.only(bottom: 100)),
],
);
}
Widget _buildCategoryFilter(
AppThemeExtension ext,
RssState state,
RssNotifier notifier,
) {
return SizedBox(
height: 36,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
_buildCategoryChip(
ext,
label: '全部',
isSelected: state.selectedCategory == null,
onTap: () => notifier.setCategory(null),
),
const SizedBox(width: AppSpacing.xs),
...RssCategory.values.map(
(cat) => Padding(
padding: const EdgeInsets.only(right: AppSpacing.xs),
child: _buildCategoryChip(
ext,
label: cat.label,
isSelected: state.selectedCategory == cat,
onTap: () => notifier.setCategory(cat),
),
),
),
],
),
);
}
Widget _buildCategoryChip(
AppThemeExtension ext, {
required String label,
required bool isSelected,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm + 2,
vertical: AppSpacing.xs + 1,
),
decoration: BoxDecoration(
color: isSelected
? ext.accent.withValues(alpha: 0.12)
: ext.bgSecondary,
borderRadius: AppRadius.mdBorder,
border: isSelected ? Border.all(color: ext.accent) : null,
),
child: Text(
label,
style: AppTypography.caption1.copyWith(
color: isSelected ? ext.accent : ext.textSecondary,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
),
),
),
);
}
Widget _buildEmptyState(AppThemeExtension ext) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('📡', style: TextStyle(fontSize: 56)),
const SizedBox(height: AppSpacing.md),
Text(
'暂无订阅源',
style: AppTypography.headline.copyWith(color: ext.textSecondary),
),
const SizedBox(height: AppSpacing.sm),
Text(
'点击右上角 + 添加RSS订阅源',
style: AppTypography.subhead.copyWith(color: ext.textHint),
),
const SizedBox(height: AppSpacing.lg),
SizedBox(
width: 200,
child: CupertinoButton(
color: ext.accent,
borderRadius: AppRadius.lgBorder,
onPressed: _showAddSubscriptionSheet,
child: Text(
' 添加订阅',
style: AppTypography.subhead.copyWith(
color: ext.textOnAccent,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
);
}
List<RssSubscription> _getFilteredSubscriptions(RssState state) {
var subs = state.subscriptions;
if (state.selectedCategory != null) {
subs = subs.where((s) => s.category == state.selectedCategory).toList();
}
return subs;
}
// ── 文章列表 ──
Widget _buildArticleList(
AppThemeExtension ext,
RssState state,
RssNotifier notifier,
) {
return Column(
children: [
// 搜索栏
_buildSearchBar(ext, state, notifier),
// 文章内容区域
Expanded(
child: state.isLoading
? _buildShimmerList()
: state.errorMessage != null
? _buildErrorState(ext, notifier)
: state.searchQuery.isNotEmpty
? _buildSearchResults(ext, state, notifier)
: state.feedItems.isEmpty
? _buildEmptyArticleState(ext, notifier)
: state.isCardMode
? _buildCardModeView(ext, state, notifier)
: _buildArticleListView(ext, state, notifier),
),
],
);
}
/// Shimmer骨架屏列表
Widget _buildShimmerList() {
return ListView.builder(
itemCount: 5,
itemBuilder: (_, __) => Padding(
padding: const EdgeInsets.only(
bottom: AppSpacing.sm,
left: AppSpacing.md,
right: AppSpacing.md,
),
child: ShimmerPlaceholder.card(),
),
);
}
/// 空文章状态
Widget _buildEmptyArticleState(AppThemeExtension ext, RssNotifier notifier) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('📭', style: TextStyle(fontSize: 48)),
const SizedBox(height: AppSpacing.md),
Text(
'暂无文章',
style: AppTypography.subhead.copyWith(color: ext.textHint),
),
const SizedBox(height: AppSpacing.md),
CupertinoButton(
onPressed: () => notifier.loadFeed(refresh: true),
child: Text('刷新', style: TextStyle(color: ext.accent)),
),
],
),
);
}
/// 文章列表视图(非卡片模式)
Widget _buildArticleListView(
AppThemeExtension ext,
RssState state,
RssNotifier notifier,
) {
return RefreshIndicator(
color: ext.accent,
onRefresh: () => notifier.loadFeed(refresh: true),
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverPadding(
padding: const EdgeInsets.only(
left: AppSpacing.md,
right: AppSpacing.md,
top: AppSpacing.sm,
),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final item = state.feedItems[index];
return RssArticleCard(
item: item,
onTap: () => notifier.openDetail(item),
);
}, childCount: state.feedItems.length),
),
),
if (state.hasMoreArticles && state.feedItems.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.md),
child: Center(
child: CupertinoButton(
onPressed: notifier.loadMoreArticles,
child: Text('加载更多', style: TextStyle(color: ext.accent)),
),
),
),
),
const SliverPadding(padding: EdgeInsets.only(bottom: 100)),
],
),
);
}
Widget _buildErrorState(AppThemeExtension ext, RssNotifier notifier) {
final state = ref.read(rssProvider);
return Center(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.xl),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('😔', style: TextStyle(fontSize: 48)),
const SizedBox(height: AppSpacing.md),
Text(
state.errorMessage ?? '加载失败',
style: AppTypography.subhead.copyWith(color: ext.textHint),
textAlign: TextAlign.center,
),
const SizedBox(height: AppSpacing.md),
CupertinoButton(
color: ext.accent,
borderRadius: AppRadius.lgBorder,
onPressed: () => notifier.loadFeed(refresh: true),
child: Text(
'🔄 重试',
style: AppTypography.subhead.copyWith(
color: ext.textOnAccent,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
);
}
// ── 文章详情 ──
Widget _buildArticleDetail(
AppThemeExtension ext,
RssState state,
RssNotifier notifier,
) {
final item = state.selectedItem;
if (item == null) return const SizedBox.shrink();
final isReadingMode =
state.fullTextResult != null && state.fullTextResult!.success;
final isBookmarked = state.bookmarkedUids.contains(item.uid);
// 获取已保存的阅读进度
final savedProgress = notifier.getReadingProgress(item.uid);
final scrollController = ScrollController(
initialScrollOffset: savedProgress,
);
return NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification is ScrollEndNotification) {
notifier.saveReadingProgress(item.uid, notification.metrics.pixels);
}
return false;
},
child: CustomScrollView(
controller: scrollController,
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverPadding(
padding: const EdgeInsets.all(AppSpacing.md),
sliver: SliverList(
delegate: SliverChildListDelegate([
if (item.imageUrl != null && item.imageUrl!.isNotEmpty)
_buildHeroImage(ext, item.imageUrl!),
GlassContainer(
depth: GlassDepth.elevated,
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题
Text(
isReadingMode
? (state.fullTextResult!.title ?? item.title)
: item.title,
style: AppTypography.title2.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.bold,
),
),
// 元信息
if (item.sourceTitle != null ||
item.author != null ||
item.pubDate != null) ...[
const SizedBox(height: AppSpacing.sm),
_buildMetaRow(ext, item),
],
const SizedBox(height: AppSpacing.md),
// 正文内容
if (state.isLoadingFullText) ...[
const Center(child: CupertinoActivityIndicator()),
const SizedBox(height: AppSpacing.md),
] else if (isReadingMode) ...[
Text(
state.fullTextResult!.content ?? '',
style: AppTypography.body.copyWith(
color: ext.textPrimary,
height: 1.8,
),
),
if (state.fullTextResult!.images.isNotEmpty) ...[
const SizedBox(height: AppSpacing.lg),
_buildImageGallery(ext, state.fullTextResult!.images),
],
] else if (item.description != null &&
item.description!.isNotEmpty) ...[
Text(
HtmlUtils.stripTags(item.description!),
style: AppTypography.body.copyWith(
color: ext.textSecondary,
height: 1.7,
),
),
],
const SizedBox(height: AppSpacing.lg),
// 操作按钮
if (item.link != null)
_buildActionButtons(
ext,
notifier,
item,
isReadingMode,
isBookmarked,
),
],
),
),
]),
),
),
],
),
);
}
Widget _buildHeroImage(AppThemeExtension ext, String imageUrl) {
return Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.md),
child: ClipRRect(
borderRadius: AppRadius.lgBorder,
child: CachedNetworkImage(
imageUrl: imageUrl,
width: double.infinity,
height: 200,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
height: 200,
decoration: BoxDecoration(
color: ext.bgSecondary,
borderRadius: AppRadius.lgBorder,
),
child: const Center(child: CupertinoActivityIndicator()),
),
errorWidget: (_, __, ___) => const SizedBox.shrink(),
),
),
);
}
/// 操作按钮区域(查看原文 + 收藏 + 阅读模式)
Widget _buildActionButtons(
AppThemeExtension ext,
RssNotifier notifier,
RssFeedItem item,
bool isReadingMode,
bool isBookmarked,
) {
return Column(
children: [
Row(
children: [
// 查看原文按钮
Expanded(
child: CupertinoButton(
color: ext.accent,
borderRadius: AppRadius.lgBorder,
onPressed: () => _showOpenOriginalDialog(item.link!),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
CupertinoIcons.doc_text_search,
size: 16,
color: ext.textOnAccent,
),
const SizedBox(width: AppSpacing.xs),
Text(
'查看原文',
style: AppTypography.subhead.copyWith(
color: ext.textOnAccent,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
const SizedBox(width: AppSpacing.sm),
// 收藏按钮
CupertinoButton(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
color: ext.accent.withValues(alpha: 0.12),
borderRadius: AppRadius.lgBorder,
onPressed: () {
notifier.toggleBookmark(item);
_showToast(isBookmarked ? '🔖 已取消收藏' : '⭐ 已收藏');
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isBookmarked
? CupertinoIcons.bookmark_fill
: CupertinoIcons.bookmark,
size: 16,
color: ext.accent,
),
const SizedBox(width: AppSpacing.xs),
Text(
isBookmarked ? '已收藏' : '收藏',
style: AppTypography.subhead.copyWith(
color: ext.accent,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
// 阅读模式按钮(仅在非阅读模式下显示)
if (!isReadingMode) ...[
const SizedBox(height: AppSpacing.sm),
SizedBox(
width: double.infinity,
child: CupertinoButton(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
color: ext.accent.withValues(alpha: 0.12),
borderRadius: AppRadius.lgBorder,
onPressed: notifier.loadFullText,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(CupertinoIcons.doc_text, size: 16, color: ext.accent),
const SizedBox(width: AppSpacing.xs),
Text(
'📖 阅读模式',
style: AppTypography.subhead.copyWith(
color: ext.accent,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
],
],
);
}
/// 查看原文确认弹窗
void _showOpenOriginalDialog(String url) {
showCupertinoDialog<void>(
context: context,
builder: (ctx) => CupertinoAlertDialog(
title: const Text('查看原文'),
content: const Text('即将跳转到外部浏览器打开原文链接,是否继续?'),
actions: [
CupertinoDialogAction(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('取消'),
),
CupertinoDialogAction(
isDefaultAction: true,
onPressed: () {
Navigator.of(ctx).pop();
_launchUrl(url);
},
child: const Text('继续'),
),
],
),
);
}
/// 图片画廊使用FullScreenPhotoView全屏查看
Widget _buildImageGallery(AppThemeExtension ext, List<String> images) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'📎 文中图片',
style: AppTypography.subhead.copyWith(
color: ext.textSecondary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: AppSpacing.sm),
Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: images
.map(
(url) => GestureDetector(
onTap: () => Navigator.of(context).push(
CupertinoPageRoute<void>(
builder: (_) => FullScreenPhotoView(imageUrl: url),
),
),
child: ClipRRect(
borderRadius: AppRadius.mdBorder,
child: CachedNetworkImage(
imageUrl: url,
width: 100,
height: 100,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: ext.bgSecondary,
borderRadius: AppRadius.mdBorder,
),
child: const CupertinoActivityIndicator(radius: 8),
),
errorWidget: (_, __, ___) => const SizedBox.shrink(),
),
),
),
)
.toList(),
),
],
);
}
/// 文章元信息行
Widget _buildMetaRow(AppThemeExtension ext, RssFeedItem item) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: AppSpacing.xs,
),
decoration: BoxDecoration(
color: ext.bgSecondary,
borderRadius: AppRadius.smBorder,
),
child: Row(
children: [
if (item.sourceTitle != null) ...[
Icon(
CupertinoIcons.antenna_radiowaves_left_right,
size: 12,
color: ext.accent,
),
const SizedBox(width: 3),
Text(
item.sourceTitle!,
style: AppTypography.caption2.copyWith(
color: ext.accent,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: AppSpacing.sm),
],
if (item.author != null) ...[
Icon(CupertinoIcons.person, size: 12, color: ext.textHint),
const SizedBox(width: 3),
Flexible(
child: Text(
item.author!,
style: AppTypography.caption2.copyWith(color: ext.textHint),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: AppSpacing.sm),
],
if (item.pubDate != null) ...[
Icon(CupertinoIcons.time, size: 12, color: ext.textHint),
const SizedBox(width: 3),
Text(
item.pubDate!.timeAgo,
style: AppTypography.caption2.copyWith(color: ext.textHint),
),
],
],
),
);
}
// ── 添加订阅源弹窗 ──
void _showAddSubscriptionSheet() {
final state = ref.read(rssProvider);
RssAddSubscriptionSheet.show(
context,
existingSubscriptions: state.subscriptions,
onAdded: (title) {
ref.read(rssProvider.notifier).loadSubscriptions();
_showToast('✅ 已添加「$title');
},
);
}
// ── 朗读文章 ──
void _speakArticle(RssState state) {
final item = state.selectedItem;
if (item == null) return;
String text = '';
if (state.fullTextResult != null && state.fullTextResult!.success) {
text = state.fullTextResult!.content ?? '';
} else {
text = '${item.title}\n\n${HtmlUtils.stripTags(item.description ?? '')}';
}
if (text.trim().isEmpty) {
_showToast('暂无内容可朗读');
return;
}
TtsPlayerSheet.show(context, text: text);
}
// ── 删除订阅 ──
Future<void> _deleteSubscription(
RssSubscription sub,
RssNotifier notifier,
) async {
showCupertinoDialog<void>(
context: context,
builder: (ctx) => CupertinoAlertDialog(
title: const Text('删除订阅源'),
content: Text('确定要删除「${sub.title}」吗?'),
actions: [
CupertinoDialogAction(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('取消'),
),
CupertinoDialogAction(
isDestructiveAction: true,
onPressed: () async {
Navigator.of(ctx).pop();
await notifier.deleteSubscription(sub);
_showToast('🗑️ 已删除「${sub.title}');
},
child: const Text('删除'),
),
],
),
);
}
// ── 更多菜单OPML导入导出 ──
/// 更多菜单按钮
Widget _buildMoreMenu(AppThemeExtension ext, RssNotifier notifier) {
return GestureDetector(
onTap: () => _showMoreActions(ext, notifier),
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: ext.bgSecondary,
borderRadius: AppRadius.mdBorder,
),
child: Icon(CupertinoIcons.ellipsis, size: 16, color: ext.textHint),
),
);
}
/// 显示更多操作弹窗
void _showMoreActions(AppThemeExtension ext, RssNotifier notifier) {
showCupertinoModalPopup<void>(
context: context,
builder: (ctx) => CupertinoActionSheet(
actions: [
CupertinoActionSheetAction(
onPressed: () {
Navigator.of(ctx).pop();
_exportOpml(notifier);
},
child: Text('📤 导出OPML', style: TextStyle(color: ext.accent)),
),
CupertinoActionSheetAction(
onPressed: () {
Navigator.of(ctx).pop();
_importOpml(notifier);
},
child: Text('📥 导入OPML', style: TextStyle(color: ext.accent)),
),
],
cancelButton: CupertinoActionSheetAction(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('取消'),
),
),
);
}
/// 导出OPML
Future<void> _exportOpml(RssNotifier notifier) async {
try {
final opml = notifier.exportOpml();
final dir = await getTemporaryDirectory();
final file = File('${dir.path}/rss_subscriptions.opml');
await file.writeAsString(opml);
await SharePlus.instance.share(
ShareParams(text: 'RSS订阅源导出', files: [XFile(file.path)]),
);
} catch (e) {
Log.e('RssReader', 'OPML导出失败: $e');
_showToast('❌ 导出失败');
}
}
/// 导入OPML
Future<void> _importOpml(RssNotifier notifier) async {
try {
final result = await FilePicker.pickFiles(
type: FileType.custom,
allowedExtensions: ['opml', 'xml'],
);
if (result == null || result.files.isEmpty) return;
final file = result.files.first;
String opmlXml;
if (file.path != null) {
opmlXml = await File(file.path!).readAsString();
} else {
// 降级:使用 bytes 属性读取file_picker 11.x API
final bytes = file.bytes;
if (bytes == null || bytes.isEmpty) {
_showToast('❌ 无法读取文件数据');
return;
}
opmlXml = utf8.decode(bytes);
}
final count = await notifier.importOpml(opmlXml);
if (count > 0) {
_showToast('✅ 成功导入$count个订阅源');
} else {
_showToast('未发现新的订阅源');
}
} catch (e) {
Log.e('RssReader', 'OPML导入失败: $e');
_showToast('❌ 导入失败');
}
}
// ── 文章搜索 ──
/// 搜索栏使用RssSearchBar组件
Widget _buildSearchBar(
AppThemeExtension ext,
RssState state,
RssNotifier notifier,
) {
return RssSearchBar(
searchQuery: state.searchQuery,
onChanged: (query) => notifier.searchArticles(query),
onClear: () => notifier.clearSearch(),
);
}
/// 搜索结果列表使用RssSearchResultsList组件
Widget _buildSearchResults(
AppThemeExtension ext,
RssState state,
RssNotifier notifier,
) {
return RssSearchResultsList(
searchResults: state.searchResults,
isSearching: state.isSearching,
bookmarkedUids: state.bookmarkedUids,
onTapArticle: (item) => notifier.openDetail(item),
onToggleBookmark: (item) {
final isBookmarked = state.bookmarkedUids.contains(item.uid);
notifier.toggleBookmark(item);
_showToast(isBookmarked ? '🔖 已取消收藏' : '⭐ 已收藏');
},
);
}
// ── 卡片式阅读模式 ──
/// 卡片阅读模式视图使用RssCardModeView组件
Widget _buildCardModeView(
AppThemeExtension ext,
RssState state,
RssNotifier notifier,
) {
return RssCardModeView(
feedItems: state.feedItems,
bookmarkedUids: state.bookmarkedUids,
onTapArticle: (item) => notifier.openDetail(item),
onToggleBookmark: (item) {
final isBookmarked = state.bookmarkedUids.contains(item.uid);
notifier.toggleBookmark(item);
_showToast(isBookmarked ? '🔖 已取消收藏' : '⭐ 已收藏');
},
onMarkRead: (item) => RssService.markArticleRead(item.uid),
onAllSwiped: () => _showToast('已浏览全部文章'),
);
}
// ── 工具方法 ──
Future<void> _launchUrl(String url) async {
try {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri))
await launchUrl(uri, mode: LaunchMode.externalApplication);
} catch (e) {
Log.e('RssReader', '打开链接失败: $e');
}
}
void _showToast(String message) {
if (!mounted) return;
HapticFeedback.lightImpact();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
),
);
}
}