Files
xianyan/lib/features/discover/presentation/pages/tool/rss_reader_page.dart
Developer 10df6b705c 同步
2026-06-02 03:52:54 +08:00

1412 lines
49 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-05-30
/// 作用: iOS风格RSS订阅阅读器 — 订阅源管理/文章列表/文章详情/分类筛选
/// 上次更新: 完整版—添加订阅/删除/分类/下拉刷新/已读标记/缩略图
/// ============================================================
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_slidable/flutter_slidable.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/html_utils.dart';
import 'package:xianyan/features/discover/services/rss_service.dart';
import 'package:xianyan/shared/widgets/adaptive/adaptive_back_button.dart';
import 'package:xianyan/shared/widgets/containers/glass_container.dart';
/// ── RSS阅读器页面 ──
class RssReaderPage extends StatefulWidget {
const RssReaderPage({super.key});
@override
State<RssReaderPage> createState() => _RssReaderPageState();
}
class _RssReaderPageState extends State<RssReaderPage> {
/// 订阅源列表
List<RssSubscription> _subscriptions = [];
/// 当前选中的订阅源null=显示源列表)
RssSubscription? _selectedSubscription;
/// 当前订阅源的文章列表
List<RssFeedItem> _feedItems = [];
/// 正在加载
bool _isLoading = false;
/// 错误信息
String? _errorMessage;
/// 选中的文章(查看详情)
RssFeedItem? _selectedItem;
/// 阅读模式全文
RssFullTextResult? _fullTextResult;
/// 是否正在加载全文
bool _isLoadingFullText = false;
/// 当前分类筛选
RssCategory? _selectedCategory;
@override
void initState() {
super.initState();
_loadSubscriptions();
}
/// 加载订阅源列表
void _loadSubscriptions() {
setState(() {
_subscriptions = RssService.getSavedSubscriptions();
});
}
@override
Widget build(BuildContext context) {
final ext = AppTheme.ext(context);
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
leading: _buildLeading(ext),
middle: Text(
_selectedItem != null
? _selectedItem!.title
: _selectedSubscription != null
? _selectedSubscription!.title
: '📡 RSS订阅',
style: AppTypography.title3.copyWith(color: ext.textPrimary),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: _buildTrailing(ext),
backgroundColor: ext.bgElevated.withValues(alpha: 0.85),
border: null,
),
child: SafeArea(
child: _selectedItem != null
? _buildArticleDetail(ext)
: _selectedSubscription != null
? _buildArticleList(ext)
: _buildSubscriptionHome(ext),
),
);
}
/// ── 导航栏左侧按钮 ──
Widget _buildLeading(AppThemeExtension ext) {
if (_selectedItem != null) {
return CupertinoButton(
padding: EdgeInsets.zero,
onPressed: _closeDetail,
child: Icon(CupertinoIcons.chevron_left, color: ext.accent),
);
}
if (_selectedSubscription != null) {
return CupertinoButton(
padding: EdgeInsets.zero,
onPressed: _backToSubscriptions,
child: Icon(CupertinoIcons.chevron_left, color: ext.accent),
);
}
return const AdaptiveBackButton();
}
/// ── 导航栏右侧按钮 ──
Widget _buildTrailing(AppThemeExtension ext) {
if (_selectedItem != null || _selectedSubscription != null) {
return const SizedBox(width: 44);
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildNetworkBadge(ext),
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 _buildSubscriptionHome(AppThemeExtension ext) {
final filteredSubs = _getFilteredSubscriptions();
return CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.md,
AppSpacing.md,
AppSpacing.md,
0,
),
sliver: SliverToBoxAdapter(child: _buildCategoryFilter(ext)),
),
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 _SubscriptionCard(
subscription: sub,
onTap: () => _loadFeed(sub),
onDelete: () => _deleteSubscription(sub),
);
}, childCount: filteredSubs.length),
),
),
const SliverPadding(padding: EdgeInsets.only(bottom: 100)),
],
);
}
/// ── 分类筛选条 ──
Widget _buildCategoryFilter(AppThemeExtension ext) {
return SizedBox(
height: 36,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
_buildCategoryChip(
ext,
label: '全部',
isSelected: _selectedCategory == null,
onTap: () => setState(() => _selectedCategory = 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: _selectedCategory == cat,
onTap: () => setState(() => _selectedCategory = 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() {
var subs = _subscriptions;
if (_selectedCategory != null) {
subs = subs.where((s) => s.category == _selectedCategory).toList();
}
return subs;
}
// ============================================================
// 文章列表
// ============================================================
Widget _buildArticleList(AppThemeExtension ext) {
if (_isLoading) {
return const Center(child: CupertinoActivityIndicator());
}
if (_errorMessage != null) {
return _buildErrorState(ext);
}
if (_feedItems.isEmpty) {
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: _refreshCurrentFeed,
child: Text('刷新', style: TextStyle(color: ext.accent)),
),
],
),
);
}
return RefreshIndicator(
color: ext.accent,
onRefresh: _refreshCurrentFeed,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(
left: AppSpacing.md,
right: AppSpacing.md,
top: AppSpacing.sm,
bottom: 100,
),
itemCount: _feedItems.length,
itemBuilder: (context, index) {
final item = _feedItems[index];
return _ArticleCard(item: item, onTap: () => _openDetail(item));
},
),
);
}
/// ── 错误状态 ──
Widget _buildErrorState(AppThemeExtension ext) {
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(
_errorMessage!,
style: AppTypography.subhead.copyWith(color: ext.textHint),
textAlign: TextAlign.center,
),
const SizedBox(height: AppSpacing.md),
CupertinoButton(
color: ext.accent,
borderRadius: AppRadius.lgBorder,
onPressed: _refreshCurrentFeed,
child: Text(
'🔄 重试',
style: AppTypography.subhead.copyWith(
color: ext.textOnAccent,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
);
}
// ============================================================
// 文章详情
// ============================================================
Widget _buildArticleDetail(AppThemeExtension ext) {
final item = _selectedItem!;
final isReadingMode = _fullTextResult != null && _fullTextResult!.success;
return CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverPadding(
padding: const EdgeInsets.all(AppSpacing.md),
sliver: SliverList(
delegate: SliverChildListDelegate([
if (item.imageUrl != null && item.imageUrl!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.md),
child: ClipRRect(
borderRadius: AppRadius.lgBorder,
child: CachedNetworkImage(
imageUrl: item.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(),
),
),
),
GlassContainer(
depth: GlassDepth.elevated,
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
isReadingMode ? (_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 (_isLoadingFullText) ...[
const Center(child: CupertinoActivityIndicator()),
const SizedBox(height: AppSpacing.md),
] else if (isReadingMode) ...[
Text(
_fullTextResult!.content ?? '',
style: AppTypography.body.copyWith(
color: ext.textPrimary,
height: 1.8,
),
),
if (_fullTextResult!.images.isNotEmpty) ...[
const SizedBox(height: AppSpacing.lg),
_buildImageGallery(ext, _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) ...[
Row(
children: [
Expanded(
child: CupertinoButton(
color: ext.accent,
borderRadius: AppRadius.lgBorder,
onPressed: () => _launchUrl(item.link!),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
CupertinoIcons.globe,
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),
if (!isReadingMode)
CupertinoButton(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
color: ext.accent.withValues(alpha: 0.12),
borderRadius: AppRadius.lgBorder,
onPressed: _loadFullText,
child: Row(
mainAxisSize: MainAxisSize.min,
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,
),
),
],
),
),
],
),
],
],
),
),
]),
),
),
],
);
}
/// 加载全文内容(阅读模式)
Future<void> _loadFullText() async {
if (_selectedItem?.link == null) return;
setState(() => _isLoadingFullText = true);
try {
final result = await RssService.fetchFullText(_selectedItem!.link!);
if (mounted) {
setState(() {
_fullTextResult = result;
_isLoadingFullText = false;
});
}
} catch (e) {
if (mounted) {
setState(() => _isLoadingFullText = false);
_showToast('📖 全文加载失败');
}
}
}
/// 构建图片画廊
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) {
return GestureDetector(
onTap: () => _launchUrl(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(
_formatDate(item.pubDate!),
style: AppTypography.caption2.copyWith(color: ext.textHint),
),
],
],
),
);
}
// ============================================================
// 添加订阅源弹窗
// ============================================================
void _showAddSubscriptionSheet() {
final ext = AppTheme.ext(context);
final urlController = TextEditingController();
bool isDiscovering = false;
showCupertinoModalPopup<void>(
context: context,
builder: (ctx) {
return StatefulBuilder(
builder: (ctx, setModalState) {
return Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(ctx).size.height * 0.6,
),
decoration: BoxDecoration(
color: ext.bgElevated,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(AppRadius.xl),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(AppSpacing.md),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'添加订阅源',
style: AppTypography.headline.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w600,
),
),
CupertinoButton(
padding: EdgeInsets.zero,
onPressed: () {
urlController.dispose();
Navigator.of(ctx).pop();
},
child: Text(
'完成',
style: AppTypography.subhead.copyWith(
color: ext.accent,
),
),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
),
child: Container(
decoration: BoxDecoration(
color: ext.bgSecondary,
borderRadius: AppRadius.lgBorder,
),
child: CupertinoTextField(
controller: urlController,
placeholder: '输入RSS/Atom源地址',
placeholderStyle: AppTypography.body.copyWith(
color: ext.textHint,
),
style: AppTypography.body.copyWith(
color: ext.textPrimary,
),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: const BoxDecoration(),
keyboardType: TextInputType.url,
prefix: Padding(
padding: const EdgeInsets.only(left: AppSpacing.md),
child: Icon(
CupertinoIcons.link,
size: 18,
color: ext.textHint,
),
),
onSubmitted: (_) => _discoverAndAdd(
urlController.text,
() => setModalState(() => isDiscovering = true),
() => setModalState(() => isDiscovering = false),
),
),
),
),
const SizedBox(height: AppSpacing.md),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
),
child: SizedBox(
width: double.infinity,
child: CupertinoButton(
color: ext.accent,
borderRadius: AppRadius.lgBorder,
onPressed: isDiscovering
? null
: () => _discoverAndAdd(
urlController.text,
() => setModalState(() => isDiscovering = true),
() =>
setModalState(() => isDiscovering = false),
),
child: isDiscovering
? const CupertinoActivityIndicator(
color: CupertinoColors.white,
)
: Text(
'🔍 自动发现并添加',
style: AppTypography.subhead.copyWith(
color: ext.textOnAccent,
fontWeight: FontWeight.w600,
),
),
),
),
),
const SizedBox(height: AppSpacing.lg),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
),
child: Row(
children: [
Text(
'💡 推荐订阅源',
style: AppTypography.subhead.copyWith(
color: ext.textSecondary,
fontWeight: FontWeight.w600,
),
),
],
),
),
const SizedBox(height: AppSpacing.sm),
Flexible(
child: CupertinoScrollbar(
child: ListView.builder(
shrinkWrap: true,
physics: const ClampingScrollPhysics(),
itemCount: RssService.defaultSubscriptions.length,
itemBuilder: (_, index) {
final sub = RssService.defaultSubscriptions[index];
final isAdded = _subscriptions.any(
(s) => s.url == sub.url,
);
return CupertinoButton(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
onPressed: isAdded
? null
: () async {
await RssService.addSubscription(sub);
_loadSubscriptions();
if (ctx.mounted) {
Navigator.of(ctx).pop();
}
_showToast('✅ 已添加「${sub.title}');
},
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: ext.accent.withValues(alpha: 0.1),
borderRadius: AppRadius.mdBorder,
),
child: Center(
child: Text(
sub.title.substring(0, 1),
style: AppTypography.subhead.copyWith(
color: ext.accent,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
sub.title,
style: AppTypography.body.copyWith(
color: isAdded
? ext.textHint
: ext.textPrimary,
),
),
Text(
sub.description ?? sub.url,
style: AppTypography.caption2.copyWith(
color: ext.textHint,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
if (isAdded)
Icon(
CupertinoIcons.checkmark_circle_fill,
color: ext.accent,
size: 20,
)
else
Icon(
CupertinoIcons.plus_circle,
color: ext.textHint,
size: 20,
),
],
),
);
},
),
),
),
const SizedBox(height: AppSpacing.lg),
],
),
);
},
);
},
);
}
/// ── 自动发现并添加RSS源 ──
Future<void> _discoverAndAdd(
String url,
VoidCallback onStart,
VoidCallback onEnd,
) async {
if (url.trim().isEmpty) {
_showToast('⚠️ 请输入RSS源地址');
return;
}
onStart();
final sub = await RssService.discoverFeed(url);
onEnd();
if (sub != null) {
await RssService.addSubscription(sub);
_loadSubscriptions();
if (mounted) Navigator.of(context).pop();
_showToast('✅ 已添加「${sub.title}');
} else {
_showToast('❌ 无法识别RSS源请检查地址');
}
}
// ============================================================
// 辅助方法
// ============================================================
Future<void> _loadFeed(RssSubscription sub) async {
setState(() {
_selectedSubscription = sub;
_isLoading = true;
_errorMessage = null;
_feedItems = [];
});
try {
final items = await RssService.fetchFeed(sub);
if (mounted) {
setState(() {
_feedItems = items;
_isLoading = false;
});
}
} catch (e) {
Log.e('RssReader', '加载失败: $e');
if (mounted) {
setState(() {
_errorMessage = '加载失败,请检查网络连接';
_isLoading = false;
});
}
}
}
Future<void> _refreshCurrentFeed() async {
if (_selectedSubscription != null) {
await _loadFeed(_selectedSubscription!);
}
}
void _openDetail(RssFeedItem item) {
setState(() => _selectedItem = item);
RssService.markArticleRead(item.uid);
}
void _closeDetail() {
setState(() {
_selectedItem = null;
_fullTextResult = null;
_isLoadingFullText = false;
});
}
void _backToSubscriptions() {
setState(() {
_selectedSubscription = null;
_feedItems = [];
_errorMessage = null;
});
}
Future<void> _deleteSubscription(RssSubscription sub) 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 RssService.removeSubscription(sub.id);
_loadSubscriptions();
_showToast('🗑️ 已删除「${sub.title}');
},
child: const Text('删除'),
),
],
),
);
}
String _formatDate(DateTime date) {
final now = DateTime.now();
final diff = now.difference(date);
if (diff.inMinutes < 1) return '刚刚';
if (diff.inHours < 1) return '${diff.inMinutes}分钟前';
if (diff.inDays < 1) return '${diff.inHours}小时前';
if (diff.inDays < 7) return '${diff.inDays}天前';
return '${date.month}/${date.day}';
}
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,
),
);
}
}
// ============================================================
// 订阅源卡片组件(支持左滑删除)
// ============================================================
class _SubscriptionCard extends StatelessWidget {
const _SubscriptionCard({
required this.subscription,
required this.onTap,
required this.onDelete,
});
final RssSubscription subscription;
final VoidCallback onTap;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) {
final ext = AppTheme.ext(context);
return Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
child: Slidable(
endActionPane: ActionPane(
motion: const BehindMotion(),
extentRatio: 0.2,
children: [
SlidableAction(
onPressed: (_) => onDelete(),
backgroundColor: CupertinoColors.systemRed,
foregroundColor: CupertinoColors.white,
icon: CupertinoIcons.delete,
label: '删除',
borderRadius: BorderRadius.circular(AppRadius.lg),
),
],
),
child: GestureDetector(
onTap: onTap,
child: GlassContainer(
depth: GlassDepth.elevated,
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.md),
child: Row(
children: [
_buildIcon(ext),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
subscription.title,
style: AppTypography.subhead.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
_buildCategoryTag(ext),
],
),
if (subscription.description != null) ...[
const SizedBox(height: 2),
Text(
subscription.description!,
style: AppTypography.caption1.copyWith(
color: ext.textHint,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
Icon(
CupertinoIcons.chevron_right,
color: ext.textHint,
size: 18,
),
],
),
),
),
),
);
}
Widget _buildIcon(AppThemeExtension ext) {
return Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: ext.accent.withValues(alpha: 0.1),
borderRadius: AppRadius.lgBorder,
),
child: Center(
child: subscription.iconUrl != null
? ClipRRect(
borderRadius: AppRadius.lgBorder,
child: CachedNetworkImage(
imageUrl: subscription.iconUrl!,
width: 44,
height: 44,
fit: BoxFit.cover,
errorWidget: (_, __, ___) => _buildIconText(ext),
),
)
: _buildIconText(ext),
),
);
}
Widget _buildIconText(AppThemeExtension ext) {
return Text(
subscription.title.substring(0, 1),
style: AppTypography.title3.copyWith(
color: ext.accent,
fontWeight: FontWeight.bold,
),
);
}
Widget _buildCategoryTag(AppThemeExtension ext) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xs + 2,
vertical: 1,
),
decoration: BoxDecoration(
color: ext.accent.withValues(alpha: 0.08),
borderRadius: AppRadius.smBorder,
),
child: Text(
subscription.category.label,
style: AppTypography.caption2.copyWith(color: ext.accent, fontSize: 10),
),
);
}
}
// ============================================================
// 文章卡片组件
// ============================================================
class _ArticleCard extends StatelessWidget {
const _ArticleCard({required this.item, required this.onTap});
final RssFeedItem item;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final ext = AppTheme.ext(context);
final hasImage = item.imageUrl != null && item.imageUrl!.isNotEmpty;
return Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
child: GestureDetector(
onTap: onTap,
child: GlassContainer(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.md),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (item.sourceTitle != null) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xs + 1,
vertical: 1,
),
decoration: BoxDecoration(
color: ext.accent.withValues(alpha: 0.08),
borderRadius: AppRadius.smBorder,
),
child: Text(
item.sourceTitle!,
style: AppTypography.caption2.copyWith(
color: ext.accent,
fontSize: 10,
),
),
),
const SizedBox(width: AppSpacing.xs),
],
if (item.isRead)
Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xs + 1,
vertical: 1,
),
decoration: BoxDecoration(
color: ext.textHint.withValues(alpha: 0.08),
borderRadius: AppRadius.smBorder,
),
child: Text(
'已读',
style: AppTypography.caption2.copyWith(
color: ext.textHint,
fontSize: 10,
),
),
),
],
),
const SizedBox(height: AppSpacing.xs),
Text(
item.title,
style: AppTypography.subhead.copyWith(
color: ext.textPrimary,
fontWeight: item.isRead
? FontWeight.w400
: FontWeight.w600,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (item.description != null &&
item.description!.isNotEmpty) ...[
const SizedBox(height: AppSpacing.xs),
Text(
HtmlUtils.stripTags(item.description!),
style: AppTypography.caption1.copyWith(
color: ext.textHint,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: AppSpacing.sm),
Row(
children: [
if (item.author != null)
Flexible(
child: Text(
item.author!,
style: AppTypography.caption2.copyWith(
color: ext.textHint,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (item.author != null && item.pubDate != null)
Text(
' · ',
style: AppTypography.caption2.copyWith(
color: ext.textHint,
),
),
if (item.pubDate != null)
Text(
_formatDate(item.pubDate!),
style: AppTypography.caption2.copyWith(
color: ext.textHint,
),
),
],
),
],
),
),
if (hasImage) ...[
const SizedBox(width: AppSpacing.sm),
ClipRRect(
borderRadius: AppRadius.mdBorder,
child: CachedNetworkImage(
imageUrl: item.imageUrl!,
width: 72,
height: 72,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: ext.bgSecondary,
borderRadius: AppRadius.mdBorder,
),
),
errorWidget: (_, __, ___) => const SizedBox.shrink(),
),
),
],
],
),
),
),
);
}
String _formatDate(DateTime date) {
final now = DateTime.now();
final diff = now.difference(date);
if (diff.inMinutes < 1) return '刚刚';
if (diff.inHours < 1) return '${diff.inMinutes}分钟前';
if (diff.inDays < 1) return '${diff.inHours}小时前';
if (diff.inDays < 7) return '${diff.inDays}天前';
return '${date.month}/${date.day}';
}
}