23509adfa8
Replaces bare Story-N reaction rows with full NewsCard widgets: source label, relative timestamp, linked headline, 2-line snippet, and 👍/👎 reactions. Reads rss_items from message metadata (requires backend ≥ this sprint). Adds url_launcher ^6.3.1 for opening article links in the browser. Adds https/http <queries> entries to AndroidManifest for Android 11+. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
185 lines
5.6 KiB
Dart
185 lines
5.6 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:url_launcher/url_launcher.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,
|
|
);
|
|
|
|
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;
|
|
|
|
const NewsCard({
|
|
super.key,
|
|
required this.item,
|
|
required this.reaction,
|
|
required this.onReaction,
|
|
});
|
|
|
|
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),
|
|
// Reaction buttons
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
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'),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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)),
|
|
),
|
|
);
|
|
}
|
|
}
|