nandi/gleanpublic Fork 0
1dc6512
Commits
Clone
git clone https://git.rickub.com/nandi/glean.git
git clone ssh://git@rickub.com/nandi/glean.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Add feeds, library, profile and discover screens

Completes the Flutter client's screen surface: subscription management
(add/edit/remove, categories, dead-feed retry, OPML in and out), the
library's liked articles and annotations, a profile that doubles as the
settings screen when it is your own, and the three recommendation rails.

Tabs now build on first visit rather than all at once. IndexedStack keeps
every tab alive, which is what we want for scroll position, but it also
builds them up front -- with five tabs that meant five fetches at startup
for screens the reader might never open.

OPML export goes to the clipboard for now; a real file save needs a picker
dependency and a platform story, which is not worth pulling in yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandithebull committed 2026-09-20T20:32:36-07:00 Browse files
1dc6512 parent: b64f9ef
added app/lib/src/screens/discover_screen.dart +277 -0
new file mode 100644
@@ -0,0 +1,277 @@
1+import 'package:flutter/material.dart';
2+
3+import '../api/client.dart';
4+import '../api/responses.dart';
5+import '../app_state.dart';
6+import '../models/models.dart';
7+import '../theme.dart';
8+import '../widgets/article_tile.dart';
9+import '../widgets/common.dart';
10+import 'article_screen.dart';
11+import 'profile_screen.dart';
12+
13+/// The three recommendation rails the clustering engine produces: articles,
14+/// feeds and people. Each is independently fetched so one slow or empty rail
15+/// does not hold up the others.
16+class DiscoverScreen extends StatefulWidget {
17+ const DiscoverScreen({super.key});
18+
19+ @override
20+ State<DiscoverScreen> createState() => _DiscoverScreenState();
21+}
22+
23+class _DiscoverScreenState extends State<DiscoverScreen> {
24+ Future<List<Article>>? _articles;
25+ Future<FeedRecsResponse>? _feeds;
26+ Future<PeopleRecsResponse>? _people;
27+
28+ @override
29+ void initState() {
30+ super.initState();
31+ _load();
32+ }
33+
34+ void _load() {
35+ final client = AppScope.read(context).client;
36+ setState(() {
37+ _articles = client.articleRecs();
38+ _feeds = client.feedRecs();
39+ _people = client.peopleRecs();
40+ });
41+ }
42+
43+ Future<void> _run(Future<void> Function() action, String done) async {
44+ try {
45+ await action();
46+ if (!mounted) return;
47+ showToast(context, done);
48+ _load();
49+ } on ApiException catch (e) {
50+ if (mounted) showToast(context, e.message);
51+ }
52+ }
53+
54+ @override
55+ Widget build(BuildContext context) {
56+ return DefaultTabController(
57+ length: 3,
58+ child: Column(
59+ children: [
60+ const TabBar(tabs: [
61+ Tab(text: 'Articles'),
62+ Tab(text: 'Feeds'),
63+ Tab(text: 'People'),
64+ ]),
65+ Expanded(
66+ child: TabBarView(
67+ children: [
68+ AsyncView<List<Article>>(
69+ future: _articles,
70+ onRetry: _load,
71+ builder: (context, items) => items.isEmpty
72+ ? const EmptyView(message: 'No article suggestions yet.')
73+ : ListView(children: [
74+ for (final a in items)
75+ Dismissible(
76+ key: ValueKey('rec-article-${a.id}'),
77+ direction: DismissDirection.endToStart,
78+ background: const _DismissBackground(),
79+ onDismissed: (_) => _run(
80+ () => AppScope.read(context).client.dismissArticleRec(a.url),
81+ 'Dismissed.',
82+ ),
83+ child: ArticleTile(
84+ article: a,
85+ onTap: () => Navigator.of(context).push(
86+ MaterialPageRoute(
87+ builder: (_) => ArticleScreen(articleId: a.id),
88+ ),
89+ ),
90+ ),
91+ ),
92+ ]),
93+ ),
94+ AsyncView<FeedRecsResponse>(
95+ future: _feeds,
96+ onRetry: _load,
97+ builder: (context, data) => data.feeds.isEmpty
98+ ? const EmptyView(message: 'No feed suggestions yet.')
99+ : ListView(
100+ padding: const EdgeInsets.all(16),
101+ children: [
102+ for (final f in data.feeds)
103+ _FeedRecCard(
104+ rec: f,
105+ onSubscribe: () => _run(
106+ () => AppScope.read(context).client.addFeed(f.feedUrl),
107+ 'Subscribed.',
108+ ),
109+ onDismiss: () => _run(
110+ () => AppScope.read(context).client.dismissFeedRec(f.feedUrl),
111+ 'Dismissed.',
112+ ),
113+ ),
114+ ],
115+ ),
116+ ),
117+ AsyncView<PeopleRecsResponse>(
118+ future: _people,
119+ onRetry: _load,
120+ builder: (context, data) {
121+ final all = [...data.followed, ...data.discover];
122+ if (all.isEmpty) {
123+ return const EmptyView(message: 'No people suggestions yet.');
124+ }
125+ return ListView(
126+ padding: const EdgeInsets.all(16),
127+ children: [
128+ for (final p in all)
129+ _PersonCard(
130+ person: p,
131+ onOpen: () => Navigator.of(context).push(
132+ MaterialPageRoute(builder: (_) => ProfileScreen(did: p.did)),
133+ ),
134+ onDismiss: () => _run(
135+ () => AppScope.read(context).client.dismissPersonRec(p.did),
136+ 'Dismissed.',
137+ ),
138+ ),
139+ ],
140+ );
141+ },
142+ ),
143+ ],
144+ ),
145+ ),
146+ ],
147+ ),
148+ );
149+ }
150+}
151+
152+class _DismissBackground extends StatelessWidget {
153+ const _DismissBackground();
154+
155+ @override
156+ Widget build(BuildContext context) {
157+ final c = GleanColors.of(context);
158+ return Container(
159+ color: c.danger,
160+ alignment: Alignment.centerRight,
161+ padding: const EdgeInsets.only(right: 20),
162+ child: Text('Dismiss',
163+ style: Theme.of(context).textTheme.labelLarge?.copyWith(color: c.bg)),
164+ );
165+ }
166+}
167+
168+class _FeedRecCard extends StatelessWidget {
169+ const _FeedRecCard({
170+ required this.rec,
171+ required this.onSubscribe,
172+ required this.onDismiss,
173+ });
174+
175+ final FeedRecommendation rec;
176+ final VoidCallback onSubscribe;
177+ final VoidCallback onDismiss;
178+
179+ @override
180+ Widget build(BuildContext context) {
181+ final text = Theme.of(context).textTheme;
182+ return Padding(
183+ padding: const EdgeInsets.only(bottom: 12),
184+ child: GleanBox(
185+ filled: true,
186+ child: Column(
187+ crossAxisAlignment: CrossAxisAlignment.start,
188+ children: [
189+ Row(
190+ children: [
191+ FaviconBadge(url: rec.faviconUrl, seed: rec.title),
192+ const SizedBox(width: 8),
193+ Expanded(
194+ child: Text(rec.title.isEmpty ? rec.feedUrl : rec.title,
195+ maxLines: 1, overflow: TextOverflow.ellipsis, style: text.bodyLarge),
196+ ),
197+ ],
198+ ),
199+ if (rec.description.isNotEmpty) ...[
200+ const SizedBox(height: 6),
201+ Text(rec.description,
202+ maxLines: 3, overflow: TextOverflow.ellipsis, style: text.bodySmall),
203+ ],
204+ const SizedBox(height: 10),
205+ Row(
206+ children: [
207+ GleanTag('${rec.subscriberCount} readers'),
208+ const Spacer(),
209+ GleanButton(label: 'Dismiss', onPressed: onDismiss),
210+ const SizedBox(width: 8),
211+ GleanButton(label: 'Subscribe', accent: true, onPressed: onSubscribe),
212+ ],
213+ ),
214+ ],
215+ ),
216+ ),
217+ );
218+ }
219+}
220+
221+class _PersonCard extends StatelessWidget {
222+ const _PersonCard({
223+ required this.person,
224+ required this.onOpen,
225+ required this.onDismiss,
226+ });
227+
228+ final PersonRecommendation person;
229+ final VoidCallback onOpen;
230+ final VoidCallback onDismiss;
231+
232+ @override
233+ Widget build(BuildContext context) {
234+ final text = Theme.of(context).textTheme;
235+ // The engine explains its suggestion by overlap; showing that is more
236+ // useful than the raw Jaccard score.
237+ final reasons = <String>[
238+ if (person.commonFeeds > 0) '${person.commonFeeds} shared feeds',
239+ if (person.commonLikes > 0) '${person.commonLikes} shared likes',
240+ if (person.commonTags > 0) '${person.commonTags} shared tags',
241+ ];
242+ return Padding(
243+ padding: const EdgeInsets.only(bottom: 12),
244+ child: GleanBox(
245+ filled: true,
246+ onTap: onOpen,
247+ child: Row(
248+ children: [
249+ FaviconBadge(url: person.avatarUrl, seed: person.label, size: 36),
250+ const SizedBox(width: 12),
251+ Expanded(
252+ child: Column(
253+ crossAxisAlignment: CrossAxisAlignment.start,
254+ children: [
255+ Text(person.label,
256+ maxLines: 1, overflow: TextOverflow.ellipsis, style: text.bodyLarge),
257+ Text('@${person.handle}',
258+ maxLines: 1, overflow: TextOverflow.ellipsis, style: text.bodySmall),
259+ if (reasons.isNotEmpty) ...[
260+ const SizedBox(height: 6),
261+ Text(reasons.join(' · '), style: text.bodySmall),
262+ ],
263+ ],
264+ ),
265+ ),
266+ if (person.isFollowed) const GleanTag('following'),
267+ IconButton(
268+ icon: const Icon(Icons.close, size: 18),
269+ onPressed: onDismiss,
270+ tooltip: 'Dismiss',
271+ ),
272+ ],
273+ ),
274+ ),
275+ );
276+ }
277+}
new file mode 100644
@@ -0,0 +1,277 @@
1+import 'package:flutter/material.dart';
2+
3+import '../api/client.dart';
4+import '../api/responses.dart';
5+import '../app_state.dart';
6+import '../models/models.dart';
7+import '../theme.dart';
8+import '../widgets/article_tile.dart';
9+import '../widgets/common.dart';
10+import 'article_screen.dart';
11+import 'profile_screen.dart';
12+
13+/// The three recommendation rails the clustering engine produces: articles,
14+/// feeds and people. Each is independently fetched so one slow or empty rail
15+/// does not hold up the others.
16+class DiscoverScreen extends StatefulWidget {
17+ const DiscoverScreen({super.key});
18+
19+ @override
20+ State<DiscoverScreen> createState() => _DiscoverScreenState();
21+}
22+
23+class _DiscoverScreenState extends State<DiscoverScreen> {
24+ Future<List<Article>>? _articles;
25+ Future<FeedRecsResponse>? _feeds;
26+ Future<PeopleRecsResponse>? _people;
27+
28+ @override
29+ void initState() {
30+ super.initState();
31+ _load();
32+ }
33+
34+ void _load() {
35+ final client = AppScope.read(context).client;
36+ setState(() {
37+ _articles = client.articleRecs();
38+ _feeds = client.feedRecs();
39+ _people = client.peopleRecs();
40+ });
41+ }
42+
43+ Future<void> _run(Future<void> Function() action, String done) async {
44+ try {
45+ await action();
46+ if (!mounted) return;
47+ showToast(context, done);
48+ _load();
49+ } on ApiException catch (e) {
50+ if (mounted) showToast(context, e.message);
51+ }
52+ }
53+
54+ @override
55+ Widget build(BuildContext context) {
56+ return DefaultTabController(
57+ length: 3,
58+ child: Column(
59+ children: [
60+ const TabBar(tabs: [
61+ Tab(text: 'Articles'),
62+ Tab(text: 'Feeds'),
63+ Tab(text: 'People'),
64+ ]),
65+ Expanded(
66+ child: TabBarView(
67+ children: [
68+ AsyncView<List<Article>>(
69+ future: _articles,
70+ onRetry: _load,
71+ builder: (context, items) => items.isEmpty
72+ ? const EmptyView(message: 'No article suggestions yet.')
73+ : ListView(children: [
74+ for (final a in items)
75+ Dismissible(
76+ key: ValueKey('rec-article-${a.id}'),
77+ direction: DismissDirection.endToStart,
78+ background: const _DismissBackground(),
79+ onDismissed: (_) => _run(
80+ () => AppScope.read(context).client.dismissArticleRec(a.url),
81+ 'Dismissed.',
82+ ),
83+ child: ArticleTile(
84+ article: a,
85+ onTap: () => Navigator.of(context).push(
86+ MaterialPageRoute(
87+ builder: (_) => ArticleScreen(articleId: a.id),
88+ ),
89+ ),
90+ ),
91+ ),
92+ ]),
93+ ),
94+ AsyncView<FeedRecsResponse>(
95+ future: _feeds,
96+ onRetry: _load,
97+ builder: (context, data) => data.feeds.isEmpty
98+ ? const EmptyView(message: 'No feed suggestions yet.')
99+ : ListView(
100+ padding: const EdgeInsets.all(16),
101+ children: [
102+ for (final f in data.feeds)
103+ _FeedRecCard(
104+ rec: f,
105+ onSubscribe: () => _run(
106+ () => AppScope.read(context).client.addFeed(f.feedUrl),
107+ 'Subscribed.',
108+ ),
109+ onDismiss: () => _run(
110+ () => AppScope.read(context).client.dismissFeedRec(f.feedUrl),
111+ 'Dismissed.',
112+ ),
113+ ),
114+ ],
115+ ),
116+ ),
117+ AsyncView<PeopleRecsResponse>(
118+ future: _people,
119+ onRetry: _load,
120+ builder: (context, data) {
121+ final all = [...data.followed, ...data.discover];
122+ if (all.isEmpty) {
123+ return const EmptyView(message: 'No people suggestions yet.');
124+ }
125+ return ListView(
126+ padding: const EdgeInsets.all(16),
127+ children: [
128+ for (final p in all)
129+ _PersonCard(
130+ person: p,
131+ onOpen: () => Navigator.of(context).push(
132+ MaterialPageRoute(builder: (_) => ProfileScreen(did: p.did)),
133+ ),
134+ onDismiss: () => _run(
135+ () => AppScope.read(context).client.dismissPersonRec(p.did),
136+ 'Dismissed.',
137+ ),
138+ ),
139+ ],
140+ );
141+ },
142+ ),
143+ ],
144+ ),
145+ ),
146+ ],
147+ ),
148+ );
149+ }
150+}
151+
152+class _DismissBackground extends StatelessWidget {
153+ const _DismissBackground();
154+
155+ @override
156+ Widget build(BuildContext context) {
157+ final c = GleanColors.of(context);
158+ return Container(
159+ color: c.danger,
160+ alignment: Alignment.centerRight,
161+ padding: const EdgeInsets.only(right: 20),
162+ child: Text('Dismiss',
163+ style: Theme.of(context).textTheme.labelLarge?.copyWith(color: c.bg)),
164+ );
165+ }
166+}
167+
168+class _FeedRecCard extends StatelessWidget {
169+ const _FeedRecCard({
170+ required this.rec,
171+ required this.onSubscribe,
172+ required this.onDismiss,
173+ });
174+
175+ final FeedRecommendation rec;
176+ final VoidCallback onSubscribe;
177+ final VoidCallback onDismiss;
178+
179+ @override
180+ Widget build(BuildContext context) {
181+ final text = Theme.of(context).textTheme;
182+ return Padding(
183+ padding: const EdgeInsets.only(bottom: 12),
184+ child: GleanBox(
185+ filled: true,
186+ child: Column(
187+ crossAxisAlignment: CrossAxisAlignment.start,
188+ children: [
189+ Row(
190+ children: [
191+ FaviconBadge(url: rec.faviconUrl, seed: rec.title),
192+ const SizedBox(width: 8),
193+ Expanded(
194+ child: Text(rec.title.isEmpty ? rec.feedUrl : rec.title,
195+ maxLines: 1, overflow: TextOverflow.ellipsis, style: text.bodyLarge),
196+ ),
197+ ],
198+ ),
199+ if (rec.description.isNotEmpty) ...[
200+ const SizedBox(height: 6),
201+ Text(rec.description,
202+ maxLines: 3, overflow: TextOverflow.ellipsis, style: text.bodySmall),
203+ ],
204+ const SizedBox(height: 10),
205+ Row(
206+ children: [
207+ GleanTag('${rec.subscriberCount} readers'),
208+ const Spacer(),
209+ GleanButton(label: 'Dismiss', onPressed: onDismiss),
210+ const SizedBox(width: 8),
211+ GleanButton(label: 'Subscribe', accent: true, onPressed: onSubscribe),
212+ ],
213+ ),
214+ ],
215+ ),
216+ ),
217+ );
218+ }
219+}
220+
221+class _PersonCard extends StatelessWidget {
222+ const _PersonCard({
223+ required this.person,
224+ required this.onOpen,
225+ required this.onDismiss,
226+ });
227+
228+ final PersonRecommendation person;
229+ final VoidCallback onOpen;
230+ final VoidCallback onDismiss;
231+
232+ @override
233+ Widget build(BuildContext context) {
234+ final text = Theme.of(context).textTheme;
235+ // The engine explains its suggestion by overlap; showing that is more
236+ // useful than the raw Jaccard score.
237+ final reasons = <String>[
238+ if (person.commonFeeds > 0) '${person.commonFeeds} shared feeds',
239+ if (person.commonLikes > 0) '${person.commonLikes} shared likes',
240+ if (person.commonTags > 0) '${person.commonTags} shared tags',
241+ ];
242+ return Padding(
243+ padding: const EdgeInsets.only(bottom: 12),
244+ child: GleanBox(
245+ filled: true,
246+ onTap: onOpen,
247+ child: Row(
248+ children: [
249+ FaviconBadge(url: person.avatarUrl, seed: person.label, size: 36),
250+ const SizedBox(width: 12),
251+ Expanded(
252+ child: Column(
253+ crossAxisAlignment: CrossAxisAlignment.start,
254+ children: [
255+ Text(person.label,
256+ maxLines: 1, overflow: TextOverflow.ellipsis, style: text.bodyLarge),
257+ Text('@${person.handle}',
258+ maxLines: 1, overflow: TextOverflow.ellipsis, style: text.bodySmall),
259+ if (reasons.isNotEmpty) ...[
260+ const SizedBox(height: 6),
261+ Text(reasons.join(' · '), style: text.bodySmall),
262+ ],
263+ ],
264+ ),
265+ ),
266+ if (person.isFollowed) const GleanTag('following'),
267+ IconButton(
268+ icon: const Icon(Icons.close, size: 18),
269+ onPressed: onDismiss,
270+ tooltip: 'Dismiss',
271+ ),
272+ ],
273+ ),
274+ ),
275+ );
276+ }
277+}
added app/lib/src/screens/feeds_screen.dart +499 -0
new file mode 100644
@@ -0,0 +1,499 @@
1+import 'package:flutter/material.dart';
2+import 'package:flutter/services.dart';
3+
4+import '../api/client.dart';
5+import '../api/responses.dart';
6+import '../app_state.dart';
7+import '../models/models.dart';
8+import '../theme.dart';
9+import '../widgets/common.dart';
10+import 'articles_screen.dart';
11+
12+/// Subscription management: the list, categories, dead feeds, and the
13+/// add/edit/remove/refresh/OPML actions from /api/feeds.
14+class FeedsScreen extends StatefulWidget {
15+ const FeedsScreen({super.key});
16+
17+ @override
18+ State<FeedsScreen> createState() => _FeedsScreenState();
19+}
20+
21+class _FeedsScreenState extends State<FeedsScreen> {
22+ Future<FeedsResponse>? _future;
23+ int _page = 1;
24+ String? _category;
25+ bool _refreshing = false;
26+
27+ @override
28+ void initState() {
29+ super.initState();
30+ _load();
31+ }
32+
33+ void _load() {
34+ setState(() {
35+ _future = AppScope.read(context).client.feeds(page: _page, category: _category);
36+ });
37+ }
38+
39+ Future<void> _addFeed() async {
40+ final url = await showDialog<String>(
41+ context: context,
42+ builder: (_) => const _AddFeedDialog(),
43+ );
44+ if (url == null || url.isEmpty || !mounted) return;
45+ try {
46+ await AppScope.read(context).client.addFeed(url);
47+ if (!mounted) return;
48+ showToast(context, 'Subscribed.');
49+ _load();
50+ } on ApiException catch (e) {
51+ if (mounted) showToast(context, e.message);
52+ }
53+ }
54+
55+ Future<void> _remove(Subscription s) async {
56+ final ok = await showDialog<bool>(
57+ context: context,
58+ builder: (ctx) => AlertDialog(
59+ shape: const RoundedRectangleBorder(),
60+ title: const Text('Unsubscribe?'),
61+ content: Text(s.feedTitle),
62+ actions: [
63+ TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')),
64+ TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('Unsubscribe')),
65+ ],
66+ ),
67+ );
68+ if (ok != true || !mounted) return;
69+ try {
70+ await AppScope.read(context).client.removeFeed(s.feedUrl);
71+ if (!mounted) return;
72+ _load();
73+ } on ApiException catch (e) {
74+ if (mounted) showToast(context, e.message);
75+ }
76+ }
77+
78+ Future<void> _editCategory(Subscription s) async {
79+ final category = await showDialog<String>(
80+ context: context,
81+ builder: (_) => _EditCategoryDialog(initial: s.category),
82+ );
83+ if (category == null || !mounted) return;
84+ try {
85+ await AppScope.read(context).client.editFeed(s.feedUrl, category: category);
86+ if (!mounted) return;
87+ _load();
88+ } on ApiException catch (e) {
89+ if (mounted) showToast(context, e.message);
90+ }
91+ }
92+
93+ Future<void> _refreshAll() async {
94+ setState(() => _refreshing = true);
95+ try {
96+ await AppScope.read(context).client.refreshFeeds();
97+ if (!mounted) return;
98+ showToast(context, 'Refresh queued.');
99+ _load();
100+ } on ApiException catch (e) {
101+ if (mounted) showToast(context, e.message);
102+ } finally {
103+ if (mounted) setState(() => _refreshing = false);
104+ }
105+ }
106+
107+ Future<void> _exportOpml() async {
108+ try {
109+ final opml = await AppScope.read(context).client.downloadOpml();
110+ await Clipboard.setData(ClipboardData(text: opml));
111+ if (!mounted) return;
112+ // No file picker dependency yet, so the clipboard is the honest export.
113+ showToast(context, 'OPML copied to clipboard.');
114+ } on ApiException catch (e) {
115+ if (mounted) showToast(context, e.message);
116+ }
117+ }
118+
119+ Future<void> _importOpml() async {
120+ final text = await showDialog<String>(
121+ context: context,
122+ builder: (_) => const _ImportOpmlDialog(),
123+ );
124+ if (text == null || text.isEmpty || !mounted) return;
125+ try {
126+ final added = await AppScope.read(context).client.uploadOpml(text);
127+ if (!mounted) return;
128+ showToast(context, 'Added $added feeds.');
129+ _load();
130+ } on ApiException catch (e) {
131+ if (mounted) showToast(context, e.message);
132+ }
133+ }
134+
135+ Future<void> _retry(Feed feed) async {
136+ try {
137+ await AppScope.read(context).client.retryFeed(feed.feedUrl);
138+ if (!mounted) return;
139+ showToast(context, 'Retry queued.');
140+ } on ApiException catch (e) {
141+ if (mounted) showToast(context, e.message);
142+ }
143+ }
144+
145+ @override
146+ Widget build(BuildContext context) {
147+ return Scaffold(
148+ floatingActionButton: FloatingActionButton(
149+ onPressed: _addFeed,
150+ shape: const RoundedRectangleBorder(),
151+ child: const Icon(Icons.add),
152+ ),
153+ body: Column(
154+ children: [
155+ _Toolbar(
156+ refreshing: _refreshing,
157+ onRefresh: _refreshAll,
158+ onImport: _importOpml,
159+ onExport: _exportOpml,
160+ ),
161+ Expanded(
162+ child: AsyncView<FeedsResponse>(
163+ future: _future,
164+ onRetry: _load,
165+ builder: (context, data) => _body(context, data),
166+ ),
167+ ),
168+ ],
169+ ),
170+ );
171+ }
172+
173+ Widget _body(BuildContext context, FeedsResponse data) {
174+ if (data.subscriptions.isEmpty && data.deadFeeds.isEmpty) {
175+ return const EmptyView(message: 'No subscriptions yet.\nAdd a feed to get started.');
176+ }
177+ return RefreshIndicator(
178+ onRefresh: () async => _load(),
179+ child: ListView(
180+ children: [
181+ if (data.categories.isNotEmpty)
182+ _CategoryFilter(
183+ categories: data.categories,
184+ selected: _category,
185+ onChanged: (c) {
186+ _category = c;
187+ _page = 1;
188+ _load();
189+ },
190+ ),
191+ for (final s in data.subscriptions)
192+ _SubscriptionTile(
193+ subscription: s,
194+ onOpen: () => Navigator.of(context).push(MaterialPageRoute(
195+ builder: (_) => ArticlesScreen(feedUrl: s.feedUrl, title: s.feedTitle),
196+ )),
197+ onEdit: () => _editCategory(s),
198+ onRemove: () => _remove(s),
199+ ),
200+ if (data.deadFeeds.isNotEmpty) ...[
201+ Padding(
202+ padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
203+ child: Text('Not responding', style: Theme.of(context).textTheme.titleMedium),
204+ ),
205+ for (final f in data.deadFeeds)
206+ _DeadFeedTile(feed: f, onRetry: () => _retry(f)),
207+ ],
208+ const SizedBox(height: 80),
209+ ],
210+ ),
211+ );
212+ }
213+}
214+
215+class _Toolbar extends StatelessWidget {
216+ const _Toolbar({
217+ required this.refreshing,
218+ required this.onRefresh,
219+ required this.onImport,
220+ required this.onExport,
221+ });
222+
223+ final bool refreshing;
224+ final VoidCallback onRefresh;
225+ final VoidCallback onImport;
226+ final VoidCallback onExport;
227+
228+ @override
229+ Widget build(BuildContext context) {
230+ return Padding(
231+ padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
232+ child: Row(
233+ children: [
234+ GleanButton(label: 'Refresh', busy: refreshing, onPressed: onRefresh),
235+ const SizedBox(width: 8),
236+ GleanButton(label: 'Import', onPressed: onImport),
237+ const SizedBox(width: 8),
238+ GleanButton(label: 'Export', onPressed: onExport),
239+ ],
240+ ),
241+ );
242+ }
243+}
244+
245+class _CategoryFilter extends StatelessWidget {
246+ const _CategoryFilter({
247+ required this.categories,
248+ required this.selected,
249+ required this.onChanged,
250+ });
251+
252+ final List<String> categories;
253+ final String? selected;
254+ final ValueChanged<String?> onChanged;
255+
256+ @override
257+ Widget build(BuildContext context) {
258+ final c = GleanColors.of(context);
259+ return SizedBox(
260+ height: 48,
261+ child: ListView(
262+ scrollDirection: Axis.horizontal,
263+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
264+ children: [
265+ for (final label in <String?>[null, ...categories])
266+ Padding(
267+ padding: const EdgeInsets.only(right: 8),
268+ child: GestureDetector(
269+ onTap: () => onChanged(label),
270+ child: Container(
271+ alignment: Alignment.center,
272+ padding: const EdgeInsets.symmetric(horizontal: 12),
273+ decoration: BoxDecoration(
274+ color: selected == label ? c.accent : Colors.transparent,
275+ border: Border.all(color: c.border, width: 2),
276+ ),
277+ child: Text(
278+ label ?? 'All',
279+ style: Theme.of(context).textTheme.labelLarge?.copyWith(
280+ color: selected == label ? c.accentInk : c.fg,
281+ ),
282+ ),
283+ ),
284+ ),
285+ ),
286+ ],
287+ ),
288+ );
289+ }
290+}
291+
292+class _SubscriptionTile extends StatelessWidget {
293+ const _SubscriptionTile({
294+ required this.subscription,
295+ required this.onOpen,
296+ required this.onEdit,
297+ required this.onRemove,
298+ });
299+
300+ final Subscription subscription;
301+ final VoidCallback onOpen;
302+ final VoidCallback onEdit;
303+ final VoidCallback onRemove;
304+
305+ @override
306+ Widget build(BuildContext context) {
307+ final c = GleanColors.of(context);
308+ final text = Theme.of(context).textTheme;
309+ return InkWell(
310+ onTap: onOpen,
311+ child: Container(
312+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
313+ decoration: BoxDecoration(
314+ border: Border(bottom: BorderSide(color: c.faint, width: 1)),
315+ ),
316+ child: Row(
317+ children: [
318+ FaviconBadge(url: subscription.faviconUrl, seed: subscription.feedTitle),
319+ const SizedBox(width: 10),
320+ Expanded(
321+ child: Column(
322+ crossAxisAlignment: CrossAxisAlignment.start,
323+ children: [
324+ Text(subscription.feedTitle,
325+ maxLines: 1, overflow: TextOverflow.ellipsis, style: text.bodyLarge),
326+ if (subscription.category.isNotEmpty) ...[
327+ const SizedBox(height: 4),
328+ GleanTag(subscription.category),
329+ ],
330+ ],
331+ ),
332+ ),
333+ if (subscription.unreadCount > 0) ...[
334+ const SizedBox(width: 8),
335+ GleanTag('${subscription.unreadCount}', emphasis: true),
336+ ],
337+ PopupMenuButton<String>(
338+ icon: Icon(Icons.more_vert, color: c.muted, size: 18),
339+ onSelected: (v) => v == 'edit' ? onEdit() : onRemove(),
340+ itemBuilder: (_) => const [
341+ PopupMenuItem(value: 'edit', child: Text('Change category')),
342+ PopupMenuItem(value: 'remove', child: Text('Unsubscribe')),
343+ ],
344+ ),
345+ ],
346+ ),
347+ ),
348+ );
349+ }
350+}
351+
352+class _DeadFeedTile extends StatelessWidget {
353+ const _DeadFeedTile({required this.feed, required this.onRetry});
354+
355+ final Feed feed;
356+ final VoidCallback onRetry;
357+
358+ @override
359+ Widget build(BuildContext context) {
360+ final c = GleanColors.of(context);
361+ final text = Theme.of(context).textTheme;
362+ return Padding(
363+ padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
364+ child: GleanBox(
365+ filled: true,
366+ child: Column(
367+ crossAxisAlignment: CrossAxisAlignment.start,
368+ children: [
369+ Text(feed.title.isEmpty ? feed.feedUrl : feed.title, style: text.bodyMedium),
370+ const SizedBox(height: 4),
371+ Text(
372+ feed.lastError.isEmpty ? '${feed.errorCount} failures' : feed.lastError,
373+ style: text.bodySmall?.copyWith(color: c.danger),
374+ ),
375+ const SizedBox(height: 10),
376+ GleanButton(label: 'Retry', onPressed: onRetry),
377+ ],
378+ ),
379+ ),
380+ );
381+ }
382+}
383+
384+class _AddFeedDialog extends StatefulWidget {
385+ const _AddFeedDialog();
386+
387+ @override
388+ State<_AddFeedDialog> createState() => _AddFeedDialogState();
389+}
390+
391+class _AddFeedDialogState extends State<_AddFeedDialog> {
392+ final _controller = TextEditingController();
393+
394+ @override
395+ void dispose() {
396+ _controller.dispose();
397+ super.dispose();
398+ }
399+
400+ @override
401+ Widget build(BuildContext context) {
402+ return AlertDialog(
403+ shape: const RoundedRectangleBorder(),
404+ title: const Text('Add feed'),
405+ content: TextField(
406+ controller: _controller,
407+ autofocus: true,
408+ keyboardType: TextInputType.url,
409+ decoration: const InputDecoration(hintText: 'https://example.com/feed.xml'),
410+ onSubmitted: (v) => Navigator.pop(context, v.trim()),
411+ ),
412+ actions: [
413+ TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
414+ TextButton(
415+ onPressed: () => Navigator.pop(context, _controller.text.trim()),
416+ child: const Text('Add'),
417+ ),
418+ ],
419+ );
420+ }
421+}
422+
423+class _EditCategoryDialog extends StatefulWidget {
424+ const _EditCategoryDialog({required this.initial});
425+
426+ final String initial;
427+
428+ @override
429+ State<_EditCategoryDialog> createState() => _EditCategoryDialogState();
430+}
431+
432+class _EditCategoryDialogState extends State<_EditCategoryDialog> {
433+ late final _controller = TextEditingController(text: widget.initial);
434+
435+ @override
436+ void dispose() {
437+ _controller.dispose();
438+ super.dispose();
439+ }
440+
441+ @override
442+ Widget build(BuildContext context) {
443+ return AlertDialog(
444+ shape: const RoundedRectangleBorder(),
445+ title: const Text('Category'),
446+ content: TextField(
447+ controller: _controller,
448+ autofocus: true,
449+ decoration: const InputDecoration(hintText: 'news'),
450+ onSubmitted: (v) => Navigator.pop(context, v.trim()),
451+ ),
452+ actions: [
453+ TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
454+ TextButton(
455+ onPressed: () => Navigator.pop(context, _controller.text.trim()),
456+ child: const Text('Save'),
457+ ),
458+ ],
459+ );
460+ }
461+}
462+
463+class _ImportOpmlDialog extends StatefulWidget {
464+ const _ImportOpmlDialog();
465+
466+ @override
467+ State<_ImportOpmlDialog> createState() => _ImportOpmlDialogState();
468+}
469+
470+class _ImportOpmlDialogState extends State<_ImportOpmlDialog> {
471+ final _controller = TextEditingController();
472+
473+ @override
474+ void dispose() {
475+ _controller.dispose();
476+ super.dispose();
477+ }
478+
479+ @override
480+ Widget build(BuildContext context) {
481+ return AlertDialog(
482+ shape: const RoundedRectangleBorder(),
483+ title: const Text('Import OPML'),
484+ content: TextField(
485+ controller: _controller,
486+ autofocus: true,
487+ maxLines: 8,
488+ decoration: const InputDecoration(hintText: 'Paste OPML XML'),
489+ ),
490+ actions: [
491+ TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
492+ TextButton(
493+ onPressed: () => Navigator.pop(context, _controller.text),
494+ child: const Text('Import'),
495+ ),
496+ ],
497+ );
498+ }
499+}
new file mode 100644
@@ -0,0 +1,499 @@
1+import 'package:flutter/material.dart';
2+import 'package:flutter/services.dart';
3+
4+import '../api/client.dart';
5+import '../api/responses.dart';
6+import '../app_state.dart';
7+import '../models/models.dart';
8+import '../theme.dart';
9+import '../widgets/common.dart';
10+import 'articles_screen.dart';
11+
12+/// Subscription management: the list, categories, dead feeds, and the
13+/// add/edit/remove/refresh/OPML actions from /api/feeds.
14+class FeedsScreen extends StatefulWidget {
15+ const FeedsScreen({super.key});
16+
17+ @override
18+ State<FeedsScreen> createState() => _FeedsScreenState();
19+}
20+
21+class _FeedsScreenState extends State<FeedsScreen> {
22+ Future<FeedsResponse>? _future;
23+ int _page = 1;
24+ String? _category;
25+ bool _refreshing = false;
26+
27+ @override
28+ void initState() {
29+ super.initState();
30+ _load();
31+ }
32+
33+ void _load() {
34+ setState(() {
35+ _future = AppScope.read(context).client.feeds(page: _page, category: _category);
36+ });
37+ }
38+
39+ Future<void> _addFeed() async {
40+ final url = await showDialog<String>(
41+ context: context,
42+ builder: (_) => const _AddFeedDialog(),
43+ );
44+ if (url == null || url.isEmpty || !mounted) return;
45+ try {
46+ await AppScope.read(context).client.addFeed(url);
47+ if (!mounted) return;
48+ showToast(context, 'Subscribed.');
49+ _load();
50+ } on ApiException catch (e) {
51+ if (mounted) showToast(context, e.message);
52+ }
53+ }
54+
55+ Future<void> _remove(Subscription s) async {
56+ final ok = await showDialog<bool>(
57+ context: context,
58+ builder: (ctx) => AlertDialog(
59+ shape: const RoundedRectangleBorder(),
60+ title: const Text('Unsubscribe?'),
61+ content: Text(s.feedTitle),
62+ actions: [
63+ TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')),
64+ TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('Unsubscribe')),
65+ ],
66+ ),
67+ );
68+ if (ok != true || !mounted) return;
69+ try {
70+ await AppScope.read(context).client.removeFeed(s.feedUrl);
71+ if (!mounted) return;
72+ _load();
73+ } on ApiException catch (e) {
74+ if (mounted) showToast(context, e.message);
75+ }
76+ }
77+
78+ Future<void> _editCategory(Subscription s) async {
79+ final category = await showDialog<String>(
80+ context: context,
81+ builder: (_) => _EditCategoryDialog(initial: s.category),
82+ );
83+ if (category == null || !mounted) return;
84+ try {
85+ await AppScope.read(context).client.editFeed(s.feedUrl, category: category);
86+ if (!mounted) return;
87+ _load();
88+ } on ApiException catch (e) {
89+ if (mounted) showToast(context, e.message);
90+ }
91+ }
92+
93+ Future<void> _refreshAll() async {
94+ setState(() => _refreshing = true);
95+ try {
96+ await AppScope.read(context).client.refreshFeeds();
97+ if (!mounted) return;
98+ showToast(context, 'Refresh queued.');
99+ _load();
100+ } on ApiException catch (e) {
101+ if (mounted) showToast(context, e.message);
102+ } finally {
103+ if (mounted) setState(() => _refreshing = false);
104+ }
105+ }
106+
107+ Future<void> _exportOpml() async {
108+ try {
109+ final opml = await AppScope.read(context).client.downloadOpml();
110+ await Clipboard.setData(ClipboardData(text: opml));
111+ if (!mounted) return;
112+ // No file picker dependency yet, so the clipboard is the honest export.
113+ showToast(context, 'OPML copied to clipboard.');
114+ } on ApiException catch (e) {
115+ if (mounted) showToast(context, e.message);
116+ }
117+ }
118+
119+ Future<void> _importOpml() async {
120+ final text = await showDialog<String>(
121+ context: context,
122+ builder: (_) => const _ImportOpmlDialog(),
123+ );
124+ if (text == null || text.isEmpty || !mounted) return;
125+ try {
126+ final added = await AppScope.read(context).client.uploadOpml(text);
127+ if (!mounted) return;
128+ showToast(context, 'Added $added feeds.');
129+ _load();
130+ } on ApiException catch (e) {
131+ if (mounted) showToast(context, e.message);
132+ }
133+ }
134+
135+ Future<void> _retry(Feed feed) async {
136+ try {
137+ await AppScope.read(context).client.retryFeed(feed.feedUrl);
138+ if (!mounted) return;
139+ showToast(context, 'Retry queued.');
140+ } on ApiException catch (e) {
141+ if (mounted) showToast(context, e.message);
142+ }
143+ }
144+
145+ @override
146+ Widget build(BuildContext context) {
147+ return Scaffold(
148+ floatingActionButton: FloatingActionButton(
149+ onPressed: _addFeed,
150+ shape: const RoundedRectangleBorder(),
151+ child: const Icon(Icons.add),
152+ ),
153+ body: Column(
154+ children: [
155+ _Toolbar(
156+ refreshing: _refreshing,
157+ onRefresh: _refreshAll,
158+ onImport: _importOpml,
159+ onExport: _exportOpml,
160+ ),
161+ Expanded(
162+ child: AsyncView<FeedsResponse>(
163+ future: _future,
164+ onRetry: _load,
165+ builder: (context, data) => _body(context, data),
166+ ),
167+ ),
168+ ],
169+ ),
170+ );
171+ }
172+
173+ Widget _body(BuildContext context, FeedsResponse data) {
174+ if (data.subscriptions.isEmpty && data.deadFeeds.isEmpty) {
175+ return const EmptyView(message: 'No subscriptions yet.\nAdd a feed to get started.');
176+ }
177+ return RefreshIndicator(
178+ onRefresh: () async => _load(),
179+ child: ListView(
180+ children: [
181+ if (data.categories.isNotEmpty)
182+ _CategoryFilter(
183+ categories: data.categories,
184+ selected: _category,
185+ onChanged: (c) {
186+ _category = c;
187+ _page = 1;
188+ _load();
189+ },
190+ ),
191+ for (final s in data.subscriptions)
192+ _SubscriptionTile(
193+ subscription: s,
194+ onOpen: () => Navigator.of(context).push(MaterialPageRoute(
195+ builder: (_) => ArticlesScreen(feedUrl: s.feedUrl, title: s.feedTitle),
196+ )),
197+ onEdit: () => _editCategory(s),
198+ onRemove: () => _remove(s),
199+ ),
200+ if (data.deadFeeds.isNotEmpty) ...[
201+ Padding(
202+ padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
203+ child: Text('Not responding', style: Theme.of(context).textTheme.titleMedium),
204+ ),
205+ for (final f in data.deadFeeds)
206+ _DeadFeedTile(feed: f, onRetry: () => _retry(f)),
207+ ],
208+ const SizedBox(height: 80),
209+ ],
210+ ),
211+ );
212+ }
213+}
214+
215+class _Toolbar extends StatelessWidget {
216+ const _Toolbar({
217+ required this.refreshing,
218+ required this.onRefresh,
219+ required this.onImport,
220+ required this.onExport,
221+ });
222+
223+ final bool refreshing;
224+ final VoidCallback onRefresh;
225+ final VoidCallback onImport;
226+ final VoidCallback onExport;
227+
228+ @override
229+ Widget build(BuildContext context) {
230+ return Padding(
231+ padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
232+ child: Row(
233+ children: [
234+ GleanButton(label: 'Refresh', busy: refreshing, onPressed: onRefresh),
235+ const SizedBox(width: 8),
236+ GleanButton(label: 'Import', onPressed: onImport),
237+ const SizedBox(width: 8),
238+ GleanButton(label: 'Export', onPressed: onExport),
239+ ],
240+ ),
241+ );
242+ }
243+}
244+
245+class _CategoryFilter extends StatelessWidget {
246+ const _CategoryFilter({
247+ required this.categories,
248+ required this.selected,
249+ required this.onChanged,
250+ });
251+
252+ final List<String> categories;
253+ final String? selected;
254+ final ValueChanged<String?> onChanged;
255+
256+ @override
257+ Widget build(BuildContext context) {
258+ final c = GleanColors.of(context);
259+ return SizedBox(
260+ height: 48,
261+ child: ListView(
262+ scrollDirection: Axis.horizontal,
263+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
264+ children: [
265+ for (final label in <String?>[null, ...categories])
266+ Padding(
267+ padding: const EdgeInsets.only(right: 8),
268+ child: GestureDetector(
269+ onTap: () => onChanged(label),
270+ child: Container(
271+ alignment: Alignment.center,
272+ padding: const EdgeInsets.symmetric(horizontal: 12),
273+ decoration: BoxDecoration(
274+ color: selected == label ? c.accent : Colors.transparent,
275+ border: Border.all(color: c.border, width: 2),
276+ ),
277+ child: Text(
278+ label ?? 'All',
279+ style: Theme.of(context).textTheme.labelLarge?.copyWith(
280+ color: selected == label ? c.accentInk : c.fg,
281+ ),
282+ ),
283+ ),
284+ ),
285+ ),
286+ ],
287+ ),
288+ );
289+ }
290+}
291+
292+class _SubscriptionTile extends StatelessWidget {
293+ const _SubscriptionTile({
294+ required this.subscription,
295+ required this.onOpen,
296+ required this.onEdit,
297+ required this.onRemove,
298+ });
299+
300+ final Subscription subscription;
301+ final VoidCallback onOpen;
302+ final VoidCallback onEdit;
303+ final VoidCallback onRemove;
304+
305+ @override
306+ Widget build(BuildContext context) {
307+ final c = GleanColors.of(context);
308+ final text = Theme.of(context).textTheme;
309+ return InkWell(
310+ onTap: onOpen,
311+ child: Container(
312+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
313+ decoration: BoxDecoration(
314+ border: Border(bottom: BorderSide(color: c.faint, width: 1)),
315+ ),
316+ child: Row(
317+ children: [
318+ FaviconBadge(url: subscription.faviconUrl, seed: subscription.feedTitle),
319+ const SizedBox(width: 10),
320+ Expanded(
321+ child: Column(
322+ crossAxisAlignment: CrossAxisAlignment.start,
323+ children: [
324+ Text(subscription.feedTitle,
325+ maxLines: 1, overflow: TextOverflow.ellipsis, style: text.bodyLarge),
326+ if (subscription.category.isNotEmpty) ...[
327+ const SizedBox(height: 4),
328+ GleanTag(subscription.category),
329+ ],
330+ ],
331+ ),
332+ ),
333+ if (subscription.unreadCount > 0) ...[
334+ const SizedBox(width: 8),
335+ GleanTag('${subscription.unreadCount}', emphasis: true),
336+ ],
337+ PopupMenuButton<String>(
338+ icon: Icon(Icons.more_vert, color: c.muted, size: 18),
339+ onSelected: (v) => v == 'edit' ? onEdit() : onRemove(),
340+ itemBuilder: (_) => const [
341+ PopupMenuItem(value: 'edit', child: Text('Change category')),
342+ PopupMenuItem(value: 'remove', child: Text('Unsubscribe')),
343+ ],
344+ ),
345+ ],
346+ ),
347+ ),
348+ );
349+ }
350+}
351+
352+class _DeadFeedTile extends StatelessWidget {
353+ const _DeadFeedTile({required this.feed, required this.onRetry});
354+
355+ final Feed feed;
356+ final VoidCallback onRetry;
357+
358+ @override
359+ Widget build(BuildContext context) {
360+ final c = GleanColors.of(context);
361+ final text = Theme.of(context).textTheme;
362+ return Padding(
363+ padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
364+ child: GleanBox(
365+ filled: true,
366+ child: Column(
367+ crossAxisAlignment: CrossAxisAlignment.start,
368+ children: [
369+ Text(feed.title.isEmpty ? feed.feedUrl : feed.title, style: text.bodyMedium),
370+ const SizedBox(height: 4),
371+ Text(
372+ feed.lastError.isEmpty ? '${feed.errorCount} failures' : feed.lastError,
373+ style: text.bodySmall?.copyWith(color: c.danger),
374+ ),
375+ const SizedBox(height: 10),
376+ GleanButton(label: 'Retry', onPressed: onRetry),
377+ ],
378+ ),
379+ ),
380+ );
381+ }
382+}
383+
384+class _AddFeedDialog extends StatefulWidget {
385+ const _AddFeedDialog();
386+
387+ @override
388+ State<_AddFeedDialog> createState() => _AddFeedDialogState();
389+}
390+
391+class _AddFeedDialogState extends State<_AddFeedDialog> {
392+ final _controller = TextEditingController();
393+
394+ @override
395+ void dispose() {
396+ _controller.dispose();
397+ super.dispose();
398+ }
399+
400+ @override
401+ Widget build(BuildContext context) {
402+ return AlertDialog(
403+ shape: const RoundedRectangleBorder(),
404+ title: const Text('Add feed'),
405+ content: TextField(
406+ controller: _controller,
407+ autofocus: true,
408+ keyboardType: TextInputType.url,
409+ decoration: const InputDecoration(hintText: 'https://example.com/feed.xml'),
410+ onSubmitted: (v) => Navigator.pop(context, v.trim()),
411+ ),
412+ actions: [
413+ TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
414+ TextButton(
415+ onPressed: () => Navigator.pop(context, _controller.text.trim()),
416+ child: const Text('Add'),
417+ ),
418+ ],
419+ );
420+ }
421+}
422+
423+class _EditCategoryDialog extends StatefulWidget {
424+ const _EditCategoryDialog({required this.initial});
425+
426+ final String initial;
427+
428+ @override
429+ State<_EditCategoryDialog> createState() => _EditCategoryDialogState();
430+}
431+
432+class _EditCategoryDialogState extends State<_EditCategoryDialog> {
433+ late final _controller = TextEditingController(text: widget.initial);
434+
435+ @override
436+ void dispose() {
437+ _controller.dispose();
438+ super.dispose();
439+ }
440+
441+ @override
442+ Widget build(BuildContext context) {
443+ return AlertDialog(
444+ shape: const RoundedRectangleBorder(),
445+ title: const Text('Category'),
446+ content: TextField(
447+ controller: _controller,
448+ autofocus: true,
449+ decoration: const InputDecoration(hintText: 'news'),
450+ onSubmitted: (v) => Navigator.pop(context, v.trim()),
451+ ),
452+ actions: [
453+ TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
454+ TextButton(
455+ onPressed: () => Navigator.pop(context, _controller.text.trim()),
456+ child: const Text('Save'),
457+ ),
458+ ],
459+ );
460+ }
461+}
462+
463+class _ImportOpmlDialog extends StatefulWidget {
464+ const _ImportOpmlDialog();
465+
466+ @override
467+ State<_ImportOpmlDialog> createState() => _ImportOpmlDialogState();
468+}
469+
470+class _ImportOpmlDialogState extends State<_ImportOpmlDialog> {
471+ final _controller = TextEditingController();
472+
473+ @override
474+ void dispose() {
475+ _controller.dispose();
476+ super.dispose();
477+ }
478+
479+ @override
480+ Widget build(BuildContext context) {
481+ return AlertDialog(
482+ shape: const RoundedRectangleBorder(),
483+ title: const Text('Import OPML'),
484+ content: TextField(
485+ controller: _controller,
486+ autofocus: true,
487+ maxLines: 8,
488+ decoration: const InputDecoration(hintText: 'Paste OPML XML'),
489+ ),
490+ actions: [
491+ TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
492+ TextButton(
493+ onPressed: () => Navigator.pop(context, _controller.text),
494+ child: const Text('Import'),
495+ ),
496+ ],
497+ );
498+ }
499+}
modified app/lib/src/screens/home_shell.dart +77 -11
@@ -4,7 +4,11 @@ import '../app_state.dart';
44 import '../theme.dart';
55 import 'articles_screen.dart';
66 import 'dashboard_screen.dart';
7+import 'discover_screen.dart';
8+import 'feeds_screen.dart';
9+import 'library_screen.dart';
710 import 'login_screen.dart';
11+import 'profile_screen.dart';
812 import 'trending_screen.dart';
913
1014 /// Bottom-tab shell. Trending is public; the rest require a session, so a
@@ -28,11 +32,14 @@ class _HomeShellState extends State<HomeShell> {
2832 // Tab set depends on the session: no point offering Home to a visitor who
2933 // would only get a 401.
3034 final tabs = <_Tab>[
31- if (app.signedIn)
35+ if (app.signedIn) ...[
3236 const _Tab(icon: Icons.home_outlined, label: 'Home', child: DashboardScreen()),
33- if (app.signedIn)
3437 const _Tab(icon: Icons.article_outlined, label: 'Articles', child: ArticlesScreen()),
35- const _Tab(icon: Icons.trending_up, label: 'Trending', child: TrendingScreen()),
38+ const _Tab(icon: Icons.rss_feed, label: 'Feeds', child: FeedsScreen()),
39+ const _Tab(icon: Icons.bookmark_border, label: 'Library', child: LibraryScreen()),
40+ const _Tab(icon: Icons.explore_outlined, label: 'Discover', child: DiscoverScreen()),
41+ ] else
42+ const _Tab(icon: Icons.trending_up, label: 'Trending', child: TrendingScreen()),
3643 ];
3744 final index = _index.clamp(0, tabs.length - 1);
3845
@@ -40,12 +47,33 @@ class _HomeShellState extends State<HomeShell> {
4047 appBar: AppBar(
4148 title: Text(tabs[index].label == 'Home' ? 'glean' : tabs[index].label),
4249 actions: [
43- if (app.signedIn)
50+ if (app.signedIn) ...[
51+ IconButton(
52+ tooltip: 'Trending',
53+ icon: const Icon(Icons.trending_up),
54+ onPressed: () => Navigator.of(context).push(MaterialPageRoute(
55+ builder: (_) => Scaffold(
56+ appBar: AppBar(title: const Text('Trending')),
57+ body: const TrendingScreen(),
58+ ),
59+ )),
60+ ),
61+ IconButton(
62+ tooltip: 'Profile',
63+ icon: const Icon(Icons.person_outline),
64+ onPressed: () => Navigator.of(context).push(MaterialPageRoute(
65+ builder: (_) => Scaffold(
66+ appBar: AppBar(title: const Text('Profile')),
67+ body: const ProfileScreen(),
68+ ),
69+ )),
70+ ),
4471 IconButton(
4572 tooltip: 'Sign out',
4673 icon: const Icon(Icons.logout),
4774 onPressed: () => AppScope.read(context).signOut(),
48- )
75+ ),
76+ ]
4977 else
5078 TextButton(
5179 onPressed: () => Navigator.of(context).push(
@@ -55,12 +83,7 @@ class _HomeShellState extends State<HomeShell> {
5583 ),
5684 ],
5785 ),
58- // ArticlesScreen builds its own Scaffold/AppBar for its filter bar, so it
59- // is shown without this shell's chrome when selected.
60- body: IndexedStack(
61- index: index,
62- children: [for (final t in tabs) t.child],
63- ),
86+ body: _LazyIndexedStack(index: index, children: [for (final t in tabs) t.child]),
6487 bottomNavigationBar: tabs.length < 2
6588 ? null
6689 : Container(
@@ -89,3 +112,46 @@ class _Tab {
89112 final String label;
90113 final Widget child;
91114 }
115+
116+
117+/// IndexedStack keeps every tab alive, which is what we want -- scroll position
118+/// and loaded pages survive switching -- but it also *builds* them all up
119+/// front, so every screen would fire its initial fetch at startup whether or
120+/// not the reader ever opens it. This builds each tab on first visit and keeps
121+/// it alive from then on.
122+class _LazyIndexedStack extends StatefulWidget {
123+ const _LazyIndexedStack({required this.index, required this.children});
124+
125+ final int index;
126+ final List<Widget> children;
127+
128+ @override
129+ State<_LazyIndexedStack> createState() => _LazyIndexedStackState();
130+}
131+
132+class _LazyIndexedStackState extends State<_LazyIndexedStack> {
133+ final _visited = <int>{};
134+
135+ @override
136+ void initState() {
137+ super.initState();
138+ _visited.add(widget.index);
139+ }
140+
141+ @override
142+ void didUpdateWidget(_LazyIndexedStack old) {
143+ super.didUpdateWidget(old);
144+ _visited.add(widget.index);
145+ }
146+
147+ @override
148+ Widget build(BuildContext context) {
149+ return IndexedStack(
150+ index: widget.index,
151+ children: [
152+ for (var i = 0; i < widget.children.length; i++)
153+ if (_visited.contains(i)) widget.children[i] else const SizedBox.shrink(),
154+ ],
155+ );
156+ }
157+}
@@ -4,7 +4,11 @@ import '../app_state.dart';
4 import '../theme.dart';4 import '../theme.dart';
5 import 'articles_screen.dart';5 import 'articles_screen.dart';
6 import 'dashboard_screen.dart';6 import 'dashboard_screen.dart';
7+import 'discover_screen.dart';
8+import 'feeds_screen.dart';
9+import 'library_screen.dart';
7 import 'login_screen.dart';10 import 'login_screen.dart';
11+import 'profile_screen.dart';
8 import 'trending_screen.dart';12 import 'trending_screen.dart';
9 13
10 /// Bottom-tab shell. Trending is public; the rest require a session, so a14 /// Bottom-tab shell. Trending is public; the rest require a session, so a
@@ -28,11 +32,14 @@ class _HomeShellState extends State<HomeShell> {
28 // Tab set depends on the session: no point offering Home to a visitor who32 // Tab set depends on the session: no point offering Home to a visitor who
29 // would only get a 401.33 // would only get a 401.
30 final tabs = <_Tab>[34 final tabs = <_Tab>[
31- if (app.signedIn)35+ if (app.signedIn) ...[
32 const _Tab(icon: Icons.home_outlined, label: 'Home', child: DashboardScreen()),36 const _Tab(icon: Icons.home_outlined, label: 'Home', child: DashboardScreen()),
33- if (app.signedIn)
34 const _Tab(icon: Icons.article_outlined, label: 'Articles', child: ArticlesScreen()),37 const _Tab(icon: Icons.article_outlined, label: 'Articles', child: ArticlesScreen()),
35- const _Tab(icon: Icons.trending_up, label: 'Trending', child: TrendingScreen()),38+ const _Tab(icon: Icons.rss_feed, label: 'Feeds', child: FeedsScreen()),
39+ const _Tab(icon: Icons.bookmark_border, label: 'Library', child: LibraryScreen()),
40+ const _Tab(icon: Icons.explore_outlined, label: 'Discover', child: DiscoverScreen()),
41+ ] else
42+ const _Tab(icon: Icons.trending_up, label: 'Trending', child: TrendingScreen()),
36 ];43 ];
37 final index = _index.clamp(0, tabs.length - 1);44 final index = _index.clamp(0, tabs.length - 1);
38 45
@@ -40,12 +47,33 @@ class _HomeShellState extends State<HomeShell> {
40 appBar: AppBar(47 appBar: AppBar(
41 title: Text(tabs[index].label == 'Home' ? 'glean' : tabs[index].label),48 title: Text(tabs[index].label == 'Home' ? 'glean' : tabs[index].label),
42 actions: [49 actions: [
43- if (app.signedIn)50+ if (app.signedIn) ...[
51+ IconButton(
52+ tooltip: 'Trending',
53+ icon: const Icon(Icons.trending_up),
54+ onPressed: () => Navigator.of(context).push(MaterialPageRoute(
55+ builder: (_) => Scaffold(
56+ appBar: AppBar(title: const Text('Trending')),
57+ body: const TrendingScreen(),
58+ ),
59+ )),
60+ ),
61+ IconButton(
62+ tooltip: 'Profile',
63+ icon: const Icon(Icons.person_outline),
64+ onPressed: () => Navigator.of(context).push(MaterialPageRoute(
65+ builder: (_) => Scaffold(
66+ appBar: AppBar(title: const Text('Profile')),
67+ body: const ProfileScreen(),
68+ ),
69+ )),
70+ ),
44 IconButton(71 IconButton(
45 tooltip: 'Sign out',72 tooltip: 'Sign out',
46 icon: const Icon(Icons.logout),73 icon: const Icon(Icons.logout),
47 onPressed: () => AppScope.read(context).signOut(),74 onPressed: () => AppScope.read(context).signOut(),
48- )75+ ),
76+ ]
49 else77 else
50 TextButton(78 TextButton(
51 onPressed: () => Navigator.of(context).push(79 onPressed: () => Navigator.of(context).push(
@@ -55,12 +83,7 @@ class _HomeShellState extends State<HomeShell> {
55 ),83 ),
56 ],84 ],
57 ),85 ),
58- // ArticlesScreen builds its own Scaffold/AppBar for its filter bar, so it86+ body: _LazyIndexedStack(index: index, children: [for (final t in tabs) t.child]),
59- // is shown without this shell's chrome when selected.
60- body: IndexedStack(
61- index: index,
62- children: [for (final t in tabs) t.child],
63- ),
64 bottomNavigationBar: tabs.length < 287 bottomNavigationBar: tabs.length < 2
65 ? null88 ? null
66 : Container(89 : Container(
@@ -89,3 +112,46 @@ class _Tab {
89 final String label;112 final String label;
90 final Widget child;113 final Widget child;
91 }114 }
115+
116+
117+/// IndexedStack keeps every tab alive, which is what we want -- scroll position
118+/// and loaded pages survive switching -- but it also *builds* them all up
119+/// front, so every screen would fire its initial fetch at startup whether or
120+/// not the reader ever opens it. This builds each tab on first visit and keeps
121+/// it alive from then on.
122+class _LazyIndexedStack extends StatefulWidget {
123+ const _LazyIndexedStack({required this.index, required this.children});
124+
125+ final int index;
126+ final List<Widget> children;
127+
128+ @override
129+ State<_LazyIndexedStack> createState() => _LazyIndexedStackState();
130+}
131+
132+class _LazyIndexedStackState extends State<_LazyIndexedStack> {
133+ final _visited = <int>{};
134+
135+ @override
136+ void initState() {
137+ super.initState();
138+ _visited.add(widget.index);
139+ }
140+
141+ @override
142+ void didUpdateWidget(_LazyIndexedStack old) {
143+ super.didUpdateWidget(old);
144+ _visited.add(widget.index);
145+ }
146+
147+ @override
148+ Widget build(BuildContext context) {
149+ return IndexedStack(
150+ index: widget.index,
151+ children: [
152+ for (var i = 0; i < widget.children.length; i++)
153+ if (_visited.contains(i)) widget.children[i] else const SizedBox.shrink(),
154+ ],
155+ );
156+ }
157+}
added app/lib/src/screens/library_screen.dart +200 -0
new file mode 100644
@@ -0,0 +1,200 @@
1+import 'package:flutter/material.dart';
2+
3+import '../api/client.dart';
4+import '../api/responses.dart';
5+import '../app_state.dart';
6+import '../models/models.dart';
7+import '../theme.dart';
8+import '../widgets/article_tile.dart';
9+import '../widgets/common.dart';
10+import 'article_screen.dart';
11+
12+/// Saved things: liked articles and the reader's own annotations, each
13+/// paginated independently by the server (liked_page / annot_page).
14+class LibraryScreen extends StatefulWidget {
15+ const LibraryScreen({super.key});
16+
17+ @override
18+ State<LibraryScreen> createState() => _LibraryScreenState();
19+}
20+
21+class _LibraryScreenState extends State<LibraryScreen> {
22+ Future<LibraryResponse>? _future;
23+ int _likedPage = 1;
24+ int _annotPage = 1;
25+
26+ @override
27+ void initState() {
28+ super.initState();
29+ _load();
30+ }
31+
32+ void _load() {
33+ setState(() {
34+ _future = AppScope.read(context)
35+ .client
36+ .library(likedPage: _likedPage, annotPage: _annotPage);
37+ });
38+ }
39+
40+ Future<void> _deleteAnnotation(int id) async {
41+ try {
42+ await AppScope.read(context).client.deleteAnnotation(id);
43+ if (!mounted) return;
44+ _load();
45+ } on ApiException catch (e) {
46+ if (mounted) showToast(context, e.message);
47+ }
48+ }
49+
50+ @override
51+ Widget build(BuildContext context) {
52+ return DefaultTabController(
53+ length: 2,
54+ child: Column(
55+ children: [
56+ const TabBar(tabs: [Tab(text: 'Liked'), Tab(text: 'Notes')]),
57+ Expanded(
58+ child: AsyncView<LibraryResponse>(
59+ future: _future,
60+ onRetry: _load,
61+ builder: (context, data) => TabBarView(
62+ children: [
63+ _liked(context, data),
64+ _notes(context, data),
65+ ],
66+ ),
67+ ),
68+ ),
69+ ],
70+ ),
71+ );
72+ }
73+
74+ Widget _liked(BuildContext context, LibraryResponse data) {
75+ if (data.articles.isEmpty) {
76+ return const EmptyView(message: 'Nothing liked yet.');
77+ }
78+ return ListView.builder(
79+ itemCount: data.articles.length + 1,
80+ itemBuilder: (context, i) {
81+ if (i == data.articles.length) {
82+ return _Pager(
83+ pagination: data.likedPage,
84+ onPage: (p) {
85+ _likedPage = p;
86+ _load();
87+ },
88+ );
89+ }
90+ final a = data.articles[i];
91+ return ArticleTile(
92+ article: a,
93+ onTap: () => Navigator.of(context).push(
94+ MaterialPageRoute(builder: (_) => ArticleScreen(articleId: a.id)),
95+ ),
96+ );
97+ },
98+ );
99+ }
100+
101+ Widget _notes(BuildContext context, LibraryResponse data) {
102+ if (data.annotations.isEmpty) {
103+ return const EmptyView(message: 'No annotations yet.');
104+ }
105+ final text = Theme.of(context).textTheme;
106+ return ListView.builder(
107+ padding: const EdgeInsets.all(16),
108+ itemCount: data.annotations.length + 1,
109+ itemBuilder: (context, i) {
110+ if (i == data.annotations.length) {
111+ return _Pager(
112+ pagination: data.annotPage,
113+ onPage: (p) {
114+ _annotPage = p;
115+ _load();
116+ },
117+ );
118+ }
119+ final an = data.annotations[i];
120+ return Padding(
121+ padding: const EdgeInsets.only(bottom: 12),
122+ child: GleanBox(
123+ filled: true,
124+ child: Column(
125+ crossAxisAlignment: CrossAxisAlignment.start,
126+ children: [
127+ Row(
128+ children: [
129+ Expanded(child: Text(relativeTime(an.createdAt), style: text.bodySmall)),
130+ InkWell(
131+ onTap: () => _deleteAnnotation(an.id),
132+ child: Icon(Icons.delete_outline,
133+ size: 18, color: GleanColors.of(context).muted),
134+ ),
135+ ],
136+ ),
137+ if (an.quote.isNotEmpty) ...[
138+ const SizedBox(height: 8),
139+ Text('"${an.quote}"',
140+ style: text.bodyMedium?.copyWith(fontStyle: FontStyle.italic)),
141+ ],
142+ if (an.note.isNotEmpty) ...[
143+ const SizedBox(height: 8),
144+ Text(an.note, style: text.bodyMedium),
145+ ],
146+ if (an.tags.isNotEmpty) ...[
147+ const SizedBox(height: 8),
148+ Wrap(
149+ spacing: 6,
150+ runSpacing: 6,
151+ children: [for (final t in an.tags) GleanTag(t)],
152+ ),
153+ ],
154+ if (an.articleId != null) ...[
155+ const SizedBox(height: 10),
156+ GleanButton(
157+ label: 'Open article',
158+ onPressed: () => Navigator.of(context).push(
159+ MaterialPageRoute(
160+ builder: (_) => ArticleScreen(articleId: an.articleId!),
161+ ),
162+ ),
163+ ),
164+ ],
165+ ],
166+ ),
167+ ),
168+ );
169+ },
170+ );
171+ }
172+}
173+
174+class _Pager extends StatelessWidget {
175+ const _Pager({required this.pagination, required this.onPage});
176+
177+ final Pagination pagination;
178+ final ValueChanged<int> onPage;
179+
180+ @override
181+ Widget build(BuildContext context) {
182+ if (!pagination.hasPrev && !pagination.hasNext) return const SizedBox(height: 24);
183+ return Padding(
184+ padding: const EdgeInsets.all(16),
185+ child: Row(
186+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
187+ children: [
188+ GleanButton(
189+ label: 'Prev',
190+ onPressed: pagination.hasPrev ? () => onPage(pagination.prevPage) : null,
191+ ),
192+ GleanButton(
193+ label: 'Next',
194+ onPressed: pagination.hasNext ? () => onPage(pagination.nextPage) : null,
195+ ),
196+ ],
197+ ),
198+ );
199+ }
200+}
new file mode 100644
@@ -0,0 +1,200 @@
1+import 'package:flutter/material.dart';
2+
3+import '../api/client.dart';
4+import '../api/responses.dart';
5+import '../app_state.dart';
6+import '../models/models.dart';
7+import '../theme.dart';
8+import '../widgets/article_tile.dart';
9+import '../widgets/common.dart';
10+import 'article_screen.dart';
11+
12+/// Saved things: liked articles and the reader's own annotations, each
13+/// paginated independently by the server (liked_page / annot_page).
14+class LibraryScreen extends StatefulWidget {
15+ const LibraryScreen({super.key});
16+
17+ @override
18+ State<LibraryScreen> createState() => _LibraryScreenState();
19+}
20+
21+class _LibraryScreenState extends State<LibraryScreen> {
22+ Future<LibraryResponse>? _future;
23+ int _likedPage = 1;
24+ int _annotPage = 1;
25+
26+ @override
27+ void initState() {
28+ super.initState();
29+ _load();
30+ }
31+
32+ void _load() {
33+ setState(() {
34+ _future = AppScope.read(context)
35+ .client
36+ .library(likedPage: _likedPage, annotPage: _annotPage);
37+ });
38+ }
39+
40+ Future<void> _deleteAnnotation(int id) async {
41+ try {
42+ await AppScope.read(context).client.deleteAnnotation(id);
43+ if (!mounted) return;
44+ _load();
45+ } on ApiException catch (e) {
46+ if (mounted) showToast(context, e.message);
47+ }
48+ }
49+
50+ @override
51+ Widget build(BuildContext context) {
52+ return DefaultTabController(
53+ length: 2,
54+ child: Column(
55+ children: [
56+ const TabBar(tabs: [Tab(text: 'Liked'), Tab(text: 'Notes')]),
57+ Expanded(
58+ child: AsyncView<LibraryResponse>(
59+ future: _future,
60+ onRetry: _load,
61+ builder: (context, data) => TabBarView(
62+ children: [
63+ _liked(context, data),
64+ _notes(context, data),
65+ ],
66+ ),
67+ ),
68+ ),
69+ ],
70+ ),
71+ );
72+ }
73+
74+ Widget _liked(BuildContext context, LibraryResponse data) {
75+ if (data.articles.isEmpty) {
76+ return const EmptyView(message: 'Nothing liked yet.');
77+ }
78+ return ListView.builder(
79+ itemCount: data.articles.length + 1,
80+ itemBuilder: (context, i) {
81+ if (i == data.articles.length) {
82+ return _Pager(
83+ pagination: data.likedPage,
84+ onPage: (p) {
85+ _likedPage = p;
86+ _load();
87+ },
88+ );
89+ }
90+ final a = data.articles[i];
91+ return ArticleTile(
92+ article: a,
93+ onTap: () => Navigator.of(context).push(
94+ MaterialPageRoute(builder: (_) => ArticleScreen(articleId: a.id)),
95+ ),
96+ );
97+ },
98+ );
99+ }
100+
101+ Widget _notes(BuildContext context, LibraryResponse data) {
102+ if (data.annotations.isEmpty) {
103+ return const EmptyView(message: 'No annotations yet.');
104+ }
105+ final text = Theme.of(context).textTheme;
106+ return ListView.builder(
107+ padding: const EdgeInsets.all(16),
108+ itemCount: data.annotations.length + 1,
109+ itemBuilder: (context, i) {
110+ if (i == data.annotations.length) {
111+ return _Pager(
112+ pagination: data.annotPage,
113+ onPage: (p) {
114+ _annotPage = p;
115+ _load();
116+ },
117+ );
118+ }
119+ final an = data.annotations[i];
120+ return Padding(
121+ padding: const EdgeInsets.only(bottom: 12),
122+ child: GleanBox(
123+ filled: true,
124+ child: Column(
125+ crossAxisAlignment: CrossAxisAlignment.start,
126+ children: [
127+ Row(
128+ children: [
129+ Expanded(child: Text(relativeTime(an.createdAt), style: text.bodySmall)),
130+ InkWell(
131+ onTap: () => _deleteAnnotation(an.id),
132+ child: Icon(Icons.delete_outline,
133+ size: 18, color: GleanColors.of(context).muted),
134+ ),
135+ ],
136+ ),
137+ if (an.quote.isNotEmpty) ...[
138+ const SizedBox(height: 8),
139+ Text('"${an.quote}"',
140+ style: text.bodyMedium?.copyWith(fontStyle: FontStyle.italic)),
141+ ],
142+ if (an.note.isNotEmpty) ...[
143+ const SizedBox(height: 8),
144+ Text(an.note, style: text.bodyMedium),
145+ ],
146+ if (an.tags.isNotEmpty) ...[
147+ const SizedBox(height: 8),
148+ Wrap(
149+ spacing: 6,
150+ runSpacing: 6,
151+ children: [for (final t in an.tags) GleanTag(t)],
152+ ),
153+ ],
154+ if (an.articleId != null) ...[
155+ const SizedBox(height: 10),
156+ GleanButton(
157+ label: 'Open article',
158+ onPressed: () => Navigator.of(context).push(
159+ MaterialPageRoute(
160+ builder: (_) => ArticleScreen(articleId: an.articleId!),
161+ ),
162+ ),
163+ ),
164+ ],
165+ ],
166+ ),
167+ ),
168+ );
169+ },
170+ );
171+ }
172+}
173+
174+class _Pager extends StatelessWidget {
175+ const _Pager({required this.pagination, required this.onPage});
176+
177+ final Pagination pagination;
178+ final ValueChanged<int> onPage;
179+
180+ @override
181+ Widget build(BuildContext context) {
182+ if (!pagination.hasPrev && !pagination.hasNext) return const SizedBox(height: 24);
183+ return Padding(
184+ padding: const EdgeInsets.all(16),
185+ child: Row(
186+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
187+ children: [
188+ GleanButton(
189+ label: 'Prev',
190+ onPressed: pagination.hasPrev ? () => onPage(pagination.prevPage) : null,
191+ ),
192+ GleanButton(
193+ label: 'Next',
194+ onPressed: pagination.hasNext ? () => onPage(pagination.nextPage) : null,
195+ ),
196+ ],
197+ ),
198+ );
199+ }
200+}
added app/lib/src/screens/profile_screen.dart +237 -0
new file mode 100644
@@ -0,0 +1,237 @@
1+import 'package:flutter/material.dart';
2+
3+import '../api/client.dart';
4+import '../api/responses.dart';
5+import '../app_state.dart';
6+import '../theme.dart';
7+import '../widgets/common.dart';
8+import 'articles_screen.dart';
9+
10+/// A reader's profile and, when it is your own, the settings the server keeps
11+/// per-user: language filters, expanded list view, and the digest toggle.
12+class ProfileScreen extends StatefulWidget {
13+ const ProfileScreen({super.key, this.did});
14+
15+ /// Defaults to the signed-in user.
16+ final String? did;
17+
18+ @override
19+ State<ProfileScreen> createState() => _ProfileScreenState();
20+}
21+
22+class _ProfileScreenState extends State<ProfileScreen> {
23+ Future<ProfileResponse>? _future;
24+
25+ /// Locally applied settings, so a toggle reflects immediately instead of
26+ /// waiting on a full profile refetch.
27+ bool? _expandedView;
28+ bool? _digestEnabled;
29+ Set<String>? _languages;
30+
31+ @override
32+ void initState() {
33+ super.initState();
34+ _load();
35+ }
36+
37+ void _load() {
38+ final app = AppScope.read(context);
39+ final did = widget.did ?? app.user?.did ?? '';
40+ setState(() {
41+ _expandedView = null;
42+ _digestEnabled = null;
43+ _languages = null;
44+ _future = app.client.profile(did);
45+ });
46+ }
47+
48+ bool get _isSelf {
49+ final me = AppScope.read(context).user?.did;
50+ return me != null && (widget.did == null || widget.did == me);
51+ }
52+
53+ Future<void> _setExpanded(bool v) async {
54+ setState(() => _expandedView = v);
55+ try {
56+ final applied = await AppScope.read(context).client.setExpandedView(v);
57+ if (mounted) setState(() => _expandedView = applied);
58+ } on ApiException catch (e) {
59+ if (!mounted) return;
60+ setState(() => _expandedView = !v);
61+ showToast(context, e.message);
62+ }
63+ }
64+
65+ Future<void> _setDigest(bool v) async {
66+ setState(() => _digestEnabled = v);
67+ try {
68+ final applied = await AppScope.read(context).client.setDigestEnabled(v);
69+ if (mounted) setState(() => _digestEnabled = applied);
70+ } on ApiException catch (e) {
71+ if (!mounted) return;
72+ setState(() => _digestEnabled = !v);
73+ showToast(context, e.message);
74+ }
75+ }
76+
77+ Future<void> _toggleLanguage(String code) async {
78+ try {
79+ final langs = await AppScope.read(context).client.toggleLanguage(code);
80+ if (mounted) setState(() => _languages = langs.toSet());
81+ } on ApiException catch (e) {
82+ if (mounted) showToast(context, e.message);
83+ }
84+ }
85+
86+ @override
87+ Widget build(BuildContext context) {
88+ return AsyncView<ProfileResponse>(
89+ future: _future,
90+ onRetry: _load,
91+ builder: (context, data) => _body(context, data),
92+ );
93+ }
94+
95+ Widget _body(BuildContext context, ProfileResponse data) {
96+ final text = Theme.of(context).textTheme;
97+ final u = data.profileUser;
98+ final expanded = _expandedView ?? data.expandedView;
99+ final digest = _digestEnabled ?? data.digestEnabled;
100+ final langs = _languages ?? data.userLanguages.toSet();
101+
102+ return RefreshIndicator(
103+ onRefresh: () async => _load(),
104+ child: ListView(
105+ padding: const EdgeInsets.all(16),
106+ children: [
107+ Row(
108+ children: [
109+ _Avatar(url: u.avatarUrl, seed: u.label),
110+ const SizedBox(width: 12),
111+ Expanded(
112+ child: Column(
113+ crossAxisAlignment: CrossAxisAlignment.start,
114+ children: [
115+ Text(u.label, style: text.headlineSmall),
116+ Text('@${u.handle}', style: text.bodySmall),
117+ ],
118+ ),
119+ ),
120+ ],
121+ ),
122+ const SizedBox(height: 16),
123+ Row(
124+ children: [
125+ Expanded(child: _Stat(value: '${data.subscriptionCount}', label: 'feeds')),
126+ const SizedBox(width: 12),
127+ Expanded(child: _Stat(value: '${data.annotationCount}', label: 'notes')),
128+ ],
129+ ),
130+ if (_isSelf) ...[
131+ const SizedBox(height: 28),
132+ Text('Settings', style: text.titleMedium),
133+ const SizedBox(height: 8),
134+ SwitchListTile(
135+ contentPadding: EdgeInsets.zero,
136+ value: expanded,
137+ onChanged: _setExpanded,
138+ title: Text('Expanded article list', style: text.bodyMedium),
139+ subtitle: Text('Show summaries in lists', style: text.bodySmall),
140+ ),
141+ SwitchListTile(
142+ contentPadding: EdgeInsets.zero,
143+ value: digest,
144+ onChanged: AppScope.of(context).hasLlm ? _setDigest : null,
145+ title: Text('Daily digest', style: text.bodyMedium),
146+ subtitle: Text(
147+ AppScope.of(context).hasLlm
148+ ? 'Summarise unread articles'
149+ : 'Unavailable: this server has no LLM configured',
150+ style: text.bodySmall,
151+ ),
152+ ),
153+ if (data.availableLanguages.isNotEmpty) ...[
154+ const SizedBox(height: 20),
155+ Text('Languages', style: text.titleMedium),
156+ const SizedBox(height: 4),
157+ Text(
158+ langs.isEmpty
159+ ? 'No filter: articles in any language are shown.'
160+ : 'Only these languages are shown.',
161+ style: text.bodySmall,
162+ ),
163+ const SizedBox(height: 10),
164+ Wrap(
165+ spacing: 8,
166+ runSpacing: 8,
167+ children: [
168+ for (final l in data.availableLanguages)
169+ GestureDetector(
170+ onTap: () => _toggleLanguage(l.code),
171+ child: GleanTag(l.name, emphasis: langs.contains(l.code)),
172+ ),
173+ ],
174+ ),
175+ ],
176+ ],
177+ if (data.subscriptions.isNotEmpty) ...[
178+ const SizedBox(height: 28),
179+ Text('Feeds', style: text.titleMedium),
180+ const SizedBox(height: 8),
181+ for (final s in data.subscriptions)
182+ ListTile(
183+ contentPadding: EdgeInsets.zero,
184+ leading: FaviconBadge(url: s.faviconUrl, seed: s.feedTitle),
185+ title: Text(s.feedTitle,
186+ maxLines: 1, overflow: TextOverflow.ellipsis, style: text.bodyMedium),
187+ onTap: () => Navigator.of(context).push(MaterialPageRoute(
188+ builder: (_) => ArticlesScreen(feedUrl: s.feedUrl, title: s.feedTitle),
189+ )),
190+ ),
191+ ],
192+ const SizedBox(height: 40),
193+ ],
194+ ),
195+ );
196+ }
197+}
198+
199+class _Avatar extends StatelessWidget {
200+ const _Avatar({required this.url, required this.seed});
201+
202+ final String url;
203+ final String seed;
204+
205+ @override
206+ Widget build(BuildContext context) {
207+ final c = GleanColors.of(context);
208+ return Container(
209+ width: 56,
210+ height: 56,
211+ decoration: BoxDecoration(border: Border.all(color: c.border, width: 2)),
212+ child: FaviconBadge(url: url, seed: seed, size: 52),
213+ );
214+ }
215+}
216+
217+class _Stat extends StatelessWidget {
218+ const _Stat({required this.value, required this.label});
219+
220+ final String value;
221+ final String label;
222+
223+ @override
224+ Widget build(BuildContext context) {
225+ final text = Theme.of(context).textTheme;
226+ return GleanBox(
227+ filled: true,
228+ child: Column(
229+ crossAxisAlignment: CrossAxisAlignment.start,
230+ children: [
231+ Text(value, style: text.displaySmall),
232+ Text(label, style: text.bodySmall),
233+ ],
234+ ),
235+ );
236+ }
237+}
new file mode 100644
@@ -0,0 +1,237 @@
1+import 'package:flutter/material.dart';
2+
3+import '../api/client.dart';
4+import '../api/responses.dart';
5+import '../app_state.dart';
6+import '../theme.dart';
7+import '../widgets/common.dart';
8+import 'articles_screen.dart';
9+
10+/// A reader's profile and, when it is your own, the settings the server keeps
11+/// per-user: language filters, expanded list view, and the digest toggle.
12+class ProfileScreen extends StatefulWidget {
13+ const ProfileScreen({super.key, this.did});
14+
15+ /// Defaults to the signed-in user.
16+ final String? did;
17+
18+ @override
19+ State<ProfileScreen> createState() => _ProfileScreenState();
20+}
21+
22+class _ProfileScreenState extends State<ProfileScreen> {
23+ Future<ProfileResponse>? _future;
24+
25+ /// Locally applied settings, so a toggle reflects immediately instead of
26+ /// waiting on a full profile refetch.
27+ bool? _expandedView;
28+ bool? _digestEnabled;
29+ Set<String>? _languages;
30+
31+ @override
32+ void initState() {
33+ super.initState();
34+ _load();
35+ }
36+
37+ void _load() {
38+ final app = AppScope.read(context);
39+ final did = widget.did ?? app.user?.did ?? '';
40+ setState(() {
41+ _expandedView = null;
42+ _digestEnabled = null;
43+ _languages = null;
44+ _future = app.client.profile(did);
45+ });
46+ }
47+
48+ bool get _isSelf {
49+ final me = AppScope.read(context).user?.did;
50+ return me != null && (widget.did == null || widget.did == me);
51+ }
52+
53+ Future<void> _setExpanded(bool v) async {
54+ setState(() => _expandedView = v);
55+ try {
56+ final applied = await AppScope.read(context).client.setExpandedView(v);
57+ if (mounted) setState(() => _expandedView = applied);
58+ } on ApiException catch (e) {
59+ if (!mounted) return;
60+ setState(() => _expandedView = !v);
61+ showToast(context, e.message);
62+ }
63+ }
64+
65+ Future<void> _setDigest(bool v) async {
66+ setState(() => _digestEnabled = v);
67+ try {
68+ final applied = await AppScope.read(context).client.setDigestEnabled(v);
69+ if (mounted) setState(() => _digestEnabled = applied);
70+ } on ApiException catch (e) {
71+ if (!mounted) return;
72+ setState(() => _digestEnabled = !v);
73+ showToast(context, e.message);
74+ }
75+ }
76+
77+ Future<void> _toggleLanguage(String code) async {
78+ try {
79+ final langs = await AppScope.read(context).client.toggleLanguage(code);
80+ if (mounted) setState(() => _languages = langs.toSet());
81+ } on ApiException catch (e) {
82+ if (mounted) showToast(context, e.message);
83+ }
84+ }
85+
86+ @override
87+ Widget build(BuildContext context) {
88+ return AsyncView<ProfileResponse>(
89+ future: _future,
90+ onRetry: _load,
91+ builder: (context, data) => _body(context, data),
92+ );
93+ }
94+
95+ Widget _body(BuildContext context, ProfileResponse data) {
96+ final text = Theme.of(context).textTheme;
97+ final u = data.profileUser;
98+ final expanded = _expandedView ?? data.expandedView;
99+ final digest = _digestEnabled ?? data.digestEnabled;
100+ final langs = _languages ?? data.userLanguages.toSet();
101+
102+ return RefreshIndicator(
103+ onRefresh: () async => _load(),
104+ child: ListView(
105+ padding: const EdgeInsets.all(16),
106+ children: [
107+ Row(
108+ children: [
109+ _Avatar(url: u.avatarUrl, seed: u.label),
110+ const SizedBox(width: 12),
111+ Expanded(
112+ child: Column(
113+ crossAxisAlignment: CrossAxisAlignment.start,
114+ children: [
115+ Text(u.label, style: text.headlineSmall),
116+ Text('@${u.handle}', style: text.bodySmall),
117+ ],
118+ ),
119+ ),
120+ ],
121+ ),
122+ const SizedBox(height: 16),
123+ Row(
124+ children: [
125+ Expanded(child: _Stat(value: '${data.subscriptionCount}', label: 'feeds')),
126+ const SizedBox(width: 12),
127+ Expanded(child: _Stat(value: '${data.annotationCount}', label: 'notes')),
128+ ],
129+ ),
130+ if (_isSelf) ...[
131+ const SizedBox(height: 28),
132+ Text('Settings', style: text.titleMedium),
133+ const SizedBox(height: 8),
134+ SwitchListTile(
135+ contentPadding: EdgeInsets.zero,
136+ value: expanded,
137+ onChanged: _setExpanded,
138+ title: Text('Expanded article list', style: text.bodyMedium),
139+ subtitle: Text('Show summaries in lists', style: text.bodySmall),
140+ ),
141+ SwitchListTile(
142+ contentPadding: EdgeInsets.zero,
143+ value: digest,
144+ onChanged: AppScope.of(context).hasLlm ? _setDigest : null,
145+ title: Text('Daily digest', style: text.bodyMedium),
146+ subtitle: Text(
147+ AppScope.of(context).hasLlm
148+ ? 'Summarise unread articles'
149+ : 'Unavailable: this server has no LLM configured',
150+ style: text.bodySmall,
151+ ),
152+ ),
153+ if (data.availableLanguages.isNotEmpty) ...[
154+ const SizedBox(height: 20),
155+ Text('Languages', style: text.titleMedium),
156+ const SizedBox(height: 4),
157+ Text(
158+ langs.isEmpty
159+ ? 'No filter: articles in any language are shown.'
160+ : 'Only these languages are shown.',
161+ style: text.bodySmall,
162+ ),
163+ const SizedBox(height: 10),
164+ Wrap(
165+ spacing: 8,
166+ runSpacing: 8,
167+ children: [
168+ for (final l in data.availableLanguages)
169+ GestureDetector(
170+ onTap: () => _toggleLanguage(l.code),
171+ child: GleanTag(l.name, emphasis: langs.contains(l.code)),
172+ ),
173+ ],
174+ ),
175+ ],
176+ ],
177+ if (data.subscriptions.isNotEmpty) ...[
178+ const SizedBox(height: 28),
179+ Text('Feeds', style: text.titleMedium),
180+ const SizedBox(height: 8),
181+ for (final s in data.subscriptions)
182+ ListTile(
183+ contentPadding: EdgeInsets.zero,
184+ leading: FaviconBadge(url: s.faviconUrl, seed: s.feedTitle),
185+ title: Text(s.feedTitle,
186+ maxLines: 1, overflow: TextOverflow.ellipsis, style: text.bodyMedium),
187+ onTap: () => Navigator.of(context).push(MaterialPageRoute(
188+ builder: (_) => ArticlesScreen(feedUrl: s.feedUrl, title: s.feedTitle),
189+ )),
190+ ),
191+ ],
192+ const SizedBox(height: 40),
193+ ],
194+ ),
195+ );
196+ }
197+}
198+
199+class _Avatar extends StatelessWidget {
200+ const _Avatar({required this.url, required this.seed});
201+
202+ final String url;
203+ final String seed;
204+
205+ @override
206+ Widget build(BuildContext context) {
207+ final c = GleanColors.of(context);
208+ return Container(
209+ width: 56,
210+ height: 56,
211+ decoration: BoxDecoration(border: Border.all(color: c.border, width: 2)),
212+ child: FaviconBadge(url: url, seed: seed, size: 52),
213+ );
214+ }
215+}
216+
217+class _Stat extends StatelessWidget {
218+ const _Stat({required this.value, required this.label});
219+
220+ final String value;
221+ final String label;
222+
223+ @override
224+ Widget build(BuildContext context) {
225+ final text = Theme.of(context).textTheme;
226+ return GleanBox(
227+ filled: true,
228+ child: Column(
229+ crossAxisAlignment: CrossAxisAlignment.start,
230+ children: [
231+ Text(value, style: text.displaySmall),
232+ Text(label, style: text.bodySmall),
233+ ],
234+ ),
235+ );
236+ }
237+}
modified app/test/widget_test.dart +20 -3
@@ -12,9 +12,10 @@ import 'package:glean_app/src/screens/home_shell.dart';
1212 import 'package:glean_app/src/theme.dart';
1313
1414 /// Fake server covering just the routes the shell touches on startup.
15-http.Client _fakeServer({required bool signedIn}) {
15+http.Client _fakeServer({required bool signedIn, List<String>? seen}) {
1616 return MockClient((req) async {
1717 final path = req.url.path;
18+ seen?.add(path);
1819 if (path == '/api/me') {
1920 return http.Response(
2021 jsonEncode({
@@ -89,12 +90,13 @@ http.Client _fakeServer({required bool signedIn}) {
8990 });
9091 }
9192
92-Future<void> _pump(WidgetTester tester, {required bool signedIn}) async {
93+Future<void> _pump(WidgetTester tester,
94+ {required bool signedIn, List<String>? seen}) async {
9395 SharedPreferences.setMockInitialValues({});
9496 final state = AppState(
9597 session: GleanSession(
9698 baseUrl: 'https://example.test',
97- client: _fakeServer(signedIn: signedIn),
99+ client: _fakeServer(signedIn: signedIn, seen: seen),
98100 ),
99101 );
100102 await state.bootstrap();
@@ -128,4 +130,19 @@ void main() {
128130 expect(find.text('7'), findsOneWidget);
129131 expect(find.text('unread'), findsOneWidget);
130132 });
133+
134+ testWidgets('tabs do not fetch until they are opened', (tester) async {
135+ final seen = <String>[];
136+ await _pump(tester, signedIn: true, seen: seen);
137+
138+ // Home is the initial tab, so only it should have fetched. Building every
139+ // tab up front would fire five requests at startup.
140+ expect(seen, contains('/api/dashboard/'));
141+ expect(seen.where((p) => p.startsWith('/api/recs')), isEmpty);
142+ expect(seen, isNot(contains('/api/feeds/')));
143+
144+ await tester.tap(find.text('Feeds'));
145+ await tester.pumpAndSettle();
146+ expect(seen, contains('/api/feeds/'));
147+ });
131148 }
@@ -12,9 +12,10 @@ import 'package:glean_app/src/screens/home_shell.dart';
12 import 'package:glean_app/src/theme.dart';12 import 'package:glean_app/src/theme.dart';
13 13
14 /// Fake server covering just the routes the shell touches on startup.14 /// Fake server covering just the routes the shell touches on startup.
15-http.Client _fakeServer({required bool signedIn}) {15+http.Client _fakeServer({required bool signedIn, List<String>? seen}) {
16 return MockClient((req) async {16 return MockClient((req) async {
17 final path = req.url.path;17 final path = req.url.path;
18+ seen?.add(path);
18 if (path == '/api/me') {19 if (path == '/api/me') {
19 return http.Response(20 return http.Response(
20 jsonEncode({21 jsonEncode({
@@ -89,12 +90,13 @@ http.Client _fakeServer({required bool signedIn}) {
89 });90 });
90 }91 }
91 92
92-Future<void> _pump(WidgetTester tester, {required bool signedIn}) async {93+Future<void> _pump(WidgetTester tester,
94+ {required bool signedIn, List<String>? seen}) async {
93 SharedPreferences.setMockInitialValues({});95 SharedPreferences.setMockInitialValues({});
94 final state = AppState(96 final state = AppState(
95 session: GleanSession(97 session: GleanSession(
96 baseUrl: 'https://example.test',98 baseUrl: 'https://example.test',
97- client: _fakeServer(signedIn: signedIn),99+ client: _fakeServer(signedIn: signedIn, seen: seen),
98 ),100 ),
99 );101 );
100 await state.bootstrap();102 await state.bootstrap();
@@ -128,4 +130,19 @@ void main() {
128 expect(find.text('7'), findsOneWidget);130 expect(find.text('7'), findsOneWidget);
129 expect(find.text('unread'), findsOneWidget);131 expect(find.text('unread'), findsOneWidget);
130 });132 });
133+
134+ testWidgets('tabs do not fetch until they are opened', (tester) async {
135+ final seen = <String>[];
136+ await _pump(tester, signedIn: true, seen: seen);
137+
138+ // Home is the initial tab, so only it should have fetched. Building every
139+ // tab up front would fire five requests at startup.
140+ expect(seen, contains('/api/dashboard/'));
141+ expect(seen.where((p) => p.startsWith('/api/recs')), isEmpty);
142+ expect(seen, isNot(contains('/api/feeds/')));
143+
144+ await tester.tap(find.text('Feeds'));
145+ await tester.pumpAndSettle();
146+ expect(seen, contains('/api/feeds/'));
147+ });
131 }148 }