Source kinds, Learn tab and catalog update #9
No files matched your search
@@ -1,6 +1,9 @@
|
||||
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';
|
||||
@@ -33,6 +36,17 @@ const monthNames = [
|
||||
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) {
|
||||
@@ -41,6 +55,39 @@ Future<void> openUrl(BuildContext context, String url) async {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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({
|
||||
|
||||
+8
-24
@@ -31,7 +31,6 @@ class _FeedScreenState extends State<FeedScreen> {
|
||||
.where((a) => _sourceId == null || a.source.id == _sourceId)
|
||||
.toList();
|
||||
final sourcesInFeed = {for (final a in all) a.source.id: a.source};
|
||||
final hidden = state.hiddenCountOf(SourceKind.news);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -40,38 +39,23 @@ class _FeedScreenState extends State<FeedScreen> {
|
||||
children: [
|
||||
const Text(appName),
|
||||
Text(
|
||||
state.loading
|
||||
? 'Loading feeds…'
|
||||
: '${all.length} articles · last ${state.settings.maxAgeHours} h'
|
||||
'${hidden > 0 ? ' · $hidden paid/ads hidden' : ''}',
|
||||
state.loading ? 'Loading feeds…' : articleCountLine(state, SourceKind.news),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Refresh',
|
||||
onPressed: state.loading ? null : state.refresh,
|
||||
icon: const Icon(Icons.refresh),
|
||||
),
|
||||
],
|
||||
actions: [RefreshButton(state: state)],
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(52),
|
||||
child: _filterBar(sourcesInFeed.values.toList()),
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
if (state.loading) const LinearProgressIndicator(),
|
||||
if (state.feedErrors.isNotEmpty && !state.loading)
|
||||
_ErrorBanner(errors: state.feedErrors),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: state.refresh,
|
||||
child: _list(state, visible),
|
||||
),
|
||||
),
|
||||
],
|
||||
body: RefreshableList(
|
||||
state: state,
|
||||
notice: state.feedErrors.isNotEmpty && !state.loading
|
||||
? _ErrorBanner(errors: state.feedErrors)
|
||||
: null,
|
||||
list: _list(state, visible),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
+10
-27
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import '../models/source.dart';
|
||||
import '../state/app_state.dart';
|
||||
import 'article_screen.dart';
|
||||
import 'common.dart';
|
||||
import 'feed_screen.dart';
|
||||
|
||||
/// Reading that keeps for days, from the Sources that aren't News: one
|
||||
@@ -22,37 +23,23 @@ class LearnScreen extends StatelessWidget {
|
||||
builder: (context, _) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Learn'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Refresh',
|
||||
onPressed: state.loading ? null : state.refresh,
|
||||
icon: const Icon(Icons.refresh),
|
||||
),
|
||||
],
|
||||
actions: [RefreshButton(state: state)],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
if (state.loading) const LinearProgressIndicator(),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: state.refresh,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 24),
|
||||
children: [for (final kind in kinds) ..._section(context, kind)],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
body: RefreshableList(
|
||||
state: state,
|
||||
list: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 24),
|
||||
children: [for (final kind in kinds) ..._kindSection(context, kind)],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _section(BuildContext context, SourceKind kind) {
|
||||
List<Widget> _kindSection(BuildContext context, SourceKind kind) {
|
||||
final theme = Theme.of(context);
|
||||
final muted = theme.colorScheme.onSurfaceVariant;
|
||||
final articles = state.articlesOf(kind);
|
||||
final hidden = state.hiddenCountOf(kind);
|
||||
final hasSources = state.enabledSources.any((s) => s.kind == kind);
|
||||
return [
|
||||
Padding(
|
||||
@@ -63,8 +50,7 @@ class LearnScreen extends StatelessWidget {
|
||||
Text(kind.label,
|
||||
style: theme.textTheme.titleMedium?.copyWith(color: theme.colorScheme.primary)),
|
||||
Text(
|
||||
'${articles.length} articles · last ${_describe(state.windowOf(kind))}'
|
||||
'${hidden > 0 ? ' · $hidden paid/ads hidden' : ''}',
|
||||
articleCountLine(state, kind),
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: muted),
|
||||
),
|
||||
],
|
||||
@@ -92,7 +78,4 @@ class LearnScreen extends StatelessWidget {
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static String _describe(Duration window) =>
|
||||
window.inHours < 72 ? '${window.inHours} h' : '${window.inDays} days';
|
||||
}
|
||||
Reference in new issue
Block a user