1412 lines
49 KiB
Dart
1412 lines
49 KiB
Dart
/// ============================================================
|
||
/// 闲言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}';
|
||
}
|
||
}
|