From b773c363fe30754750ef37f453a4f9e9aa1ff8d3 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Fri, 16 Oct 2026 16:07:37 +0200 Subject: [PATCH] Share the article count line and refresh scaffolding of Today and Learn (review items 5, 6) 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 --- lib/ui/common.dart | 47 ++++++++++++++++++++++++++++++++++++++++ lib/ui/feed_screen.dart | 32 +++++++-------------------- lib/ui/learn_screen.dart | 37 +++++++++---------------------- 3 files changed, 65 insertions(+), 51 deletions(-) diff --git a/lib/ui/common.dart b/lib/ui/common.dart index 1cc0669..e15a1a9 100644 --- a/lib/ui/common.dart +++ b/lib/ui/common.dart @@ -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 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 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({ diff --git a/lib/ui/feed_screen.dart b/lib/ui/feed_screen.dart index e0aeca5..058fa4d 100644 --- a/lib/ui/feed_screen.dart +++ b/lib/ui/feed_screen.dart @@ -31,7 +31,6 @@ class _FeedScreenState extends State { .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 { 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), ), ); }, diff --git a/lib/ui/learn_screen.dart b/lib/ui/learn_screen.dart index e834bf7..3f7026b 100644 --- a/lib/ui/learn_screen.dart +++ b/lib/ui/learn_screen.dart @@ -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 _section(BuildContext context, SourceKind kind) { + List _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'; }