1197 lines
38 KiB
Dart
1197 lines
38 KiB
Dart
/// ============================================================
|
||
/// 闲言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,
|
||
),
|
||
);
|
||
}
|
||
}
|