articleCountLine, RefreshButton and RefreshableList live in common.dart. LearnScreen._describe becomes _windowLabel there, and LearnScreen._section is _kindSection like in SourcesScreen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
134 lines
4.2 KiB
Dart
134 lines
4.2 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
|
|
import '../models/source.dart';
|
|
import '../state/app_state.dart';
|
|
|
|
/// User-facing app name. Platform launcher labels (AndroidManifest, Info.plist,
|
|
/// web/manifest.json, windows/runner) are set separately.
|
|
const appName = 'Tagesbyte';
|
|
|
|
String timeAgo(DateTime? date) {
|
|
if (date == null) return '';
|
|
final diff = DateTime.now().difference(date.toLocal());
|
|
if (diff.inMinutes < 1) return 'just now';
|
|
if (diff.inMinutes < 60) return '${diff.inMinutes} min ago';
|
|
if (diff.inHours < 24) return '${diff.inHours} h ago';
|
|
if (diff.inDays == 1) return 'yesterday';
|
|
return '${diff.inDays} days ago';
|
|
}
|
|
|
|
String formatDateTime(DateTime date) {
|
|
final d = date.toLocal();
|
|
String two(int n) => n.toString().padLeft(2, '0');
|
|
return '${two(d.day)}.${two(d.month)}.${d.year} ${two(d.hour)}:${two(d.minute)}';
|
|
}
|
|
|
|
const weekdayNames = [
|
|
'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday',
|
|
];
|
|
|
|
const monthNames = [
|
|
'January', 'February', 'March', 'April', 'May', 'June',
|
|
'July', 'August', 'September', 'October', 'November', 'December',
|
|
];
|
|
|
|
bool isSameDay(DateTime a, DateTime b) =>
|
|
a.year == b.year && a.month == b.month && a.day == b.day;
|
|
|
|
/// The line above the articles of [kind], e.g.
|
|
/// "12 articles · last 24 h · 3 paid/ads hidden".
|
|
String articleCountLine(AppState state, SourceKind kind) {
|
|
final hidden = state.hiddenCountOf(kind);
|
|
return '${state.articlesOf(kind).length} articles · last ${_windowLabel(state.windowOf(kind))}'
|
|
'${hidden > 0 ? ' · $hidden paid/ads hidden' : ''}';
|
|
}
|
|
|
|
String _windowLabel(Duration window) =>
|
|
window.inHours < 72 ? '${window.inHours} h' : '${window.inDays} days';
|
|
|
|
Future<void> openUrl(BuildContext context, String url) async {
|
|
final ok = await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
|
|
if (!ok && context.mounted) {
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(SnackBar(content: Text('Could not open $url')));
|
|
}
|
|
}
|
|
|
|
/// App bar button that refreshes all Sources; off while a refresh runs.
|
|
class RefreshButton extends StatelessWidget {
|
|
const RefreshButton({super.key, required this.state});
|
|
|
|
final AppState state;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => IconButton(
|
|
tooltip: 'Refresh',
|
|
onPressed: state.loading ? null : state.refresh,
|
|
icon: const Icon(Icons.refresh),
|
|
);
|
|
}
|
|
|
|
/// A scrollable [list] that refreshes all Sources when pulled down, under a
|
|
/// progress bar while a refresh runs and an optional [notice].
|
|
class RefreshableList extends StatelessWidget {
|
|
const RefreshableList({super.key, required this.state, required this.list, this.notice});
|
|
|
|
final AppState state;
|
|
final Widget list;
|
|
final Widget? notice;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Column(
|
|
children: [
|
|
if (state.loading) const LinearProgressIndicator(),
|
|
if (notice != null) notice!,
|
|
Expanded(child: RefreshIndicator(onRefresh: state.refresh, child: list)),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Centered icon + text + optional action, used for empty and error states.
|
|
class EmptyState extends StatelessWidget {
|
|
const EmptyState({
|
|
super.key,
|
|
required this.icon,
|
|
required this.title,
|
|
this.message,
|
|
this.action,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String title;
|
|
final String? message;
|
|
final Widget? action;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, size: 56, color: theme.colorScheme.primary),
|
|
const SizedBox(height: 16),
|
|
Text(title, style: theme.textTheme.titleMedium, textAlign: TextAlign.center),
|
|
if (message != null) ...[
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
message!,
|
|
style: theme.textTheme.bodyMedium
|
|
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
if (action != null) ...[const SizedBox(height: 20), action!],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|