This repository has been archived on 2026-06-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
FabledApp/lib/widgets/news_card.dart
T

229 lines
6.8 KiB
Dart

import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../data/models/news_item.dart';
class RssItemMeta {
final int id;
final String title;
final String url;
final String source;
final String snippet;
final DateTime? publishedAt;
const RssItemMeta({
required this.id,
required this.title,
required this.url,
required this.source,
required this.snippet,
this.publishedAt,
});
factory RssItemMeta.fromJson(Map<String, dynamic> json) => RssItemMeta(
id: json['id'] as int,
title: json['title'] as String? ?? '',
url: json['url'] as String? ?? '',
source: json['source'] as String? ?? '',
snippet: json['snippet'] as String? ?? '',
publishedAt: json['published_at'] != null
? DateTime.tryParse(json['published_at'] as String)
: null,
);
factory RssItemMeta.fromNewsItem(NewsItem item) => RssItemMeta(
id: item.id,
title: item.title,
url: item.url,
source: item.source,
snippet: item.snippet,
publishedAt: item.publishedAt,
);
String get relativeDate {
if (publishedAt == null) return '';
final diff = DateTime.now().difference(publishedAt!);
if (diff.inHours < 24) return '${diff.inHours}h ago';
if (diff.inHours < 48) return 'Yesterday';
return '${publishedAt!.month}/${publishedAt!.day}';
}
}
class NewsCard extends StatelessWidget {
final RssItemMeta item;
final String? reaction; // 'up' | 'down' | null
final void Function(int itemId, String reaction) onReaction;
final VoidCallback? onDiscuss;
const NewsCard({
super.key,
required this.item,
required this.reaction,
required this.onReaction,
this.onDiscuss,
});
Future<void> _openUrl() async {
if (item.url.isEmpty) return;
final uri = Uri.tryParse(item.url);
if (uri != null && await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: scheme.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Source + date row
Row(
children: [
if (item.source.isNotEmpty)
Text(
item.source.toUpperCase(),
style: textTheme.labelSmall?.copyWith(
color: scheme.primary,
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
),
),
if (item.source.isNotEmpty && item.relativeDate.isNotEmpty)
Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(
item.relativeDate,
style: textTheme.labelSmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
),
],
),
const SizedBox(height: 4),
// Title — tappable if URL present
GestureDetector(
onTap: item.url.isNotEmpty ? _openUrl : null,
child: Text(
item.title,
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: item.url.isNotEmpty ? scheme.primary : scheme.onSurface,
decoration: item.url.isNotEmpty ? TextDecoration.underline : null,
decorationColor: scheme.primary,
),
),
),
// Snippet
if (item.snippet.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
item.snippet,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
height: 1.45,
),
),
],
const SizedBox(height: 8),
// Actions row: reactions + discuss
Row(
children: [
_ReactionButton(
emoji: '👍',
active: reaction == 'up',
onTap: () => onReaction(item.id, 'up'),
),
const SizedBox(width: 6),
_ReactionButton(
emoji: '👎',
active: reaction == 'down',
onTap: () => onReaction(item.id, 'down'),
),
if (onDiscuss != null) ...[
const Spacer(),
_DiscussButton(onTap: onDiscuss!),
],
],
),
],
),
),
);
}
}
class _DiscussButton extends StatelessWidget {
final VoidCallback onTap;
const _DiscussButton({required this.onTap});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
decoration: BoxDecoration(
border: Border.all(color: scheme.primary.withValues(alpha: 0.5)),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'Discuss',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: scheme.primary,
),
),
),
);
}
}
class _ReactionButton extends StatelessWidget {
final String emoji;
final bool active;
final VoidCallback onTap;
const _ReactionButton({
required this.emoji,
required this.active,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: active ? scheme.primary.withValues(alpha: 0.12) : Colors.transparent,
border: Border.all(
color: active ? scheme.primary : scheme.outlineVariant,
),
borderRadius: BorderRadius.circular(6),
),
child: Text(emoji, style: const TextStyle(fontSize: 14)),
),
);
}
}