nandi/gleanpublic Fork
⑂ 9c32350
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.

Download ZIP

Point the Flutter client at the Nim reader

The client was written against the Go API -- cookie sessions, CSRF, ATProto
OAuth, pagination envelopes, ~50 endpoints. The reader has ten routes and no
auth, so most of that had nothing left to talk to.

Gone: the session and CSRF layer, the response envelopes, and the login,
dashboard, discover, library, profile and trending screens. What remains is
an article list, a reader view and a feed drawer, which is the whole of what
the reader serves.

The models shrank with it. Article loses guid, fullContent, updated,
likeCount and hasLiked; Subscription becomes a Feed with a title and an
unread count. The "all feeds" row is synthesised client-side, since the
server does not send one.

Renamed the app bar's "Mark read" to "Mark all read". A test could not tell
it from the per-article button of the same name, which is a fair sign a
reader could not either -- and that one clears a whole feed.

Verified against a running reader, not only a mock: live_reader_test drives
feeds, articles, read state and FTS5 search over HTTP. A mock returns
whatever the test author believed the server sends, which is exactly the
mistake that made this rewrite necessary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandithebull committed 2026-09-21T20:23:05-07:00 Browse files
9c32350 parent: 4ecfc2c
modified app/lib/main.dart +3 -15
@@ -21,7 +21,8 @@ class _GleanAppState extends State<GleanApp> {
2121 @override
2222 void initState() {
2323 super.initState();
24- _state.bootstrap();
24+ // The feed list loads from the shell, which is the only screen that
25+ // needs it; there is no session to restore first.
2526 }
2627
2728 @override
@@ -39,23 +40,10 @@ class _GleanAppState extends State<GleanApp> {
3940 debugShowCheckedModeBanner: false,
4041 theme: gleanTheme(Brightness.light),
4142 darkTheme: gleanTheme(Brightness.dark),
42- home: const _Root(),
43+ home: const HomeShell(),
4344 ),
4445 );
4546 }
4647 }
4748
48-/// Holds the splash until the persisted session has been checked, so the app
49-/// does not flash the signed-out shell at a signed-in user.
50-class _Root extends StatelessWidget {
51- const _Root();
5249
53- @override
54- Widget build(BuildContext context) {
55- final app = AppScope.of(context);
56- if (app.loading && app.user == null) {
57- return const Scaffold(body: Center(child: CircularProgressIndicator()));
58- }
59- return const HomeShell();
60- }
61-}
@@ -21,7 +21,8 @@ class _GleanAppState extends State<GleanApp> {
21 @override21 @override
22 void initState() {22 void initState() {
23 super.initState();23 super.initState();
24- _state.bootstrap();24+ // The feed list loads from the shell, which is the only screen that
25+ // needs it; there is no session to restore first.
25 }26 }
26 27
27 @override28 @override
@@ -39,23 +40,10 @@ class _GleanAppState extends State<GleanApp> {
39 debugShowCheckedModeBanner: false,40 debugShowCheckedModeBanner: false,
40 theme: gleanTheme(Brightness.light),41 theme: gleanTheme(Brightness.light),
41 darkTheme: gleanTheme(Brightness.dark),42 darkTheme: gleanTheme(Brightness.dark),
42- home: const _Root(),43+ home: const HomeShell(),
43 ),44 ),
44 );45 );
45 }46 }
46 }47 }
47 48
48-/// Holds the splash until the persisted session has been checked, so the app
49-/// does not flash the signed-out shell at a signed-in user.
50-class _Root extends StatelessWidget {
51- const _Root();
52 49
53- @override
54- Widget build(BuildContext context) {
55- final app = AppScope.of(context);
56- if (app.loading && app.user == null) {
57- return const Scaffold(body: Center(child: CircularProgressIndicator()));
58- }
59- return const HomeShell();
60- }
61-}
modified app/lib/src/api/client.dart +75 -215
@@ -3,255 +3,115 @@ import 'dart:convert';
33 import 'package:http/http.dart' as http;
44
55 import '../models/models.dart';
6-import 'responses.dart';
7-import 'session.dart';
86
9-/// Thrown for any non-2xx API response. The server puts a human-readable
10-/// message in `{"error": "..."}` (writeAPIError), so surface that when present.
7+/// Thrown for any non-2xx response, or one carrying `{"error": …}`.
118 class ApiException implements Exception {
12- ApiException(this.statusCode, this.message);
13-
14- final int statusCode;
9+ ApiException(this.message);
1510 final String message;
16-
17- bool get isUnauthorized => statusCode == 401 || statusCode == 403;
18-
1911 @override
2012 String toString() => message;
2113 }
2214
23-/// Typed wrapper over the Glean JSON API (internal/server/server.go).
15+/// The reader's API.
2416 ///
25-/// Every method here corresponds to exactly one route; the grouping and order
26-/// follow setupRoutes() so the two can be diffed by eye.
17+/// No cookies, no CSRF, no auth: the server is single-user and binds
18+/// loopback. That is why this class is a thin wrapper rather than a session.
2719 class GleanClient {
28- GleanClient(this.session);
20+ GleanClient({required this.baseUrl, http.Client? client})
21+ : _client = client ?? http.Client();
2922
30- final GleanSession session;
23+ final String baseUrl;
24+ final http.Client _client;
3125
32- // --- plumbing ---
26+ Uri _uri(String path, [Map<String, String>? query]) {
27+ final q = query?.entries.where((e) => e.value.isNotEmpty);
28+ return Uri.parse('$baseUrl$path').replace(
29+ queryParameters: q == null || q.isEmpty ? null : Map.fromEntries(q),
30+ );
31+ }
3332
34- Map<String, dynamic> _decode(http.Response res) {
35- final body = res.body.isEmpty ? '{}' : res.body;
36- late final Map<String, dynamic> json;
33+ dynamic _decode(http.Response res) {
34+ late final dynamic body;
3735 try {
38- json = jsonDecode(body) as Map<String, dynamic>;
36+ body = jsonDecode(res.body.isEmpty ? 'null' : res.body);
3937 } on FormatException {
40- // A non-JSON body means we hit something other than the API -- a proxy
41- // error page, or an HTML redirect from an expired session.
42- throw ApiException(res.statusCode, 'Unexpected non-JSON response from server.');
38+ throw ApiException('Unexpected response from server.');
39+ }
40+ if (body is Map && body['error'] != null) {
41+ throw ApiException('${body['error']}');
42+ }
43+ if (res.statusCode < 200 || res.statusCode >= 300) {
44+ throw ApiException('Request failed (${res.statusCode}).');
4345 }
44- if (res.statusCode >= 200 && res.statusCode < 300) return json;
45- final msg = (json['error'] as String?) ?? 'Request failed (${res.statusCode}).';
46- throw ApiException(res.statusCode, msg);
46+ return body;
4747 }
4848
49- Future<Map<String, dynamic>> _get(String path, [Map<String, dynamic>? q]) async =>
50- _decode(await session.get(path, q));
49+ Future<dynamic> _get(String path, [Map<String, String>? q]) async =>
50+ _decode(await _client.get(_uri(path, q)));
5151
52- Future<Map<String, dynamic>> _post(String path,
53- {Map<String, dynamic>? query, Map<String, String>? form}) async =>
54- _decode(await session.send('POST', path, query: query, form: form));
55-
56- Future<Map<String, dynamic>> _delete(String path,
57- {Map<String, dynamic>? query, Map<String, String>? form}) async =>
58- _decode(await session.send('DELETE', path, query: query, form: form));
59-
60- // --- identity ---
61-
62- Future<MeResponse> me() async => MeResponse.fromJson(await _get('/api/me'));
63-
64- // --- dashboard ---
65-
66- Future<DashboardResponse> dashboard() async =>
67- DashboardResponse.fromJson(await _get('/api/dashboard/'));
68-
69- // --- articles ---
70-
71- Future<ArticlesResponse> articles({
72- int page = 1,
73- String? feedUrl,
74- String? status,
75- String? search,
76- String? category,
77- bool sortOldest = false,
78- }) async =>
79- ArticlesResponse.fromJson(await _get('/api/articles/', {
80- 'page': page,
81- 'feed': feedUrl,
82- 'status': status,
83- 'q': search,
84- 'category': category,
85- if (sortOldest) 'sort': 'oldest',
86- }));
87-
88- Future<ArticleDetailResponse> article(int id) async =>
89- ArticleDetailResponse.fromJson(await _get('/api/articles/$id'));
90-
91- Future<int> newArticleCount() async =>
92- jsonInt((await _get('/api/articles/new-count'))['count']);
93-
94- Future<bool> markRead(int id) async =>
95- jsonBool((await _post('/api/articles/$id/read'))['is_read']);
96-
97- Future<bool> markUnread(int id) async =>
98- jsonBool((await _post('/api/articles/$id/unread'))['is_read']);
99-
100- Future<LikeResult> likeArticle(int id) async =>
101- LikeResult.fromJson(await _post('/api/articles/$id/like'));
102-
103- Future<String> fetchContent(int id) async =>
104- jsonStr((await _post('/api/articles/$id/fetch-content'))['full_content']);
105-
106- Future<void> markAllRead({String? feedUrl, String? category}) =>
107- _post('/api/articles/mark-all-read', form: {
108- 'feed': ?feedUrl,
109- 'category': ?category,
110- });
52+ Future<dynamic> _post(String path, [Map<String, String>? form]) async =>
53+ _decode(await _client.post(_uri(path), body: form ?? const {}));
11154
11255 // --- feeds ---
11356
114- Future<FeedsResponse> feeds({int page = 1, String? category}) async =>
115- FeedsResponse.fromJson(await _get('/api/feeds/', {'page': page, 'category': category}));
57+ Future<List<Feed>> feeds() async =>
58+ ((await _get('/feeds')) as List)
59+ .map((e) => Feed.fromJson(e as Map<String, dynamic>))
60+ .toList();
11661
117- Future<Subscription> addFeed(String url, {String? category}) async {
118- final j = await _post('/api/feeds/add', form: {
119- 'feed_url': url,
120- 'category': ?category,
121- });
122- return Subscription.fromJson(j['subscription'] as Map<String, dynamic>);
123- }
62+ Future<int> unreadCount() async =>
63+ ((await _get('/unread')) as Map<String, dynamic>)['count'] as int? ?? 0;
12464
125- Future<Subscription> editFeed(String feedUrl, {String? category}) async {
126- final j = await _post('/api/feeds/edit', form: {
127- 'feed_url': feedUrl,
128- 'category': ?category,
129- });
130- return Subscription.fromJson(j['subscription'] as Map<String, dynamic>);
65+ /// Subscribe. The server fetches immediately and reports how many articles
66+ /// arrived, so a feed that resolves but is empty is distinguishable from
67+ /// one that failed.
68+ Future<int> addFeed(String url) async {
69+ final j = await _post('/feeds', {'url': url}) as Map<String, dynamic>;
70+ return (j['added'] as num?)?.toInt() ?? 0;
13171 }
13272
133- Future<void> removeFeed(String feedUrl) =>
134- _delete('/api/feeds/remove', form: {'url': feedUrl});
73+ Future<void> removeFeed(String feedUrl) async =>
74+ _decode(await _client.delete(_uri('/feeds', {'url': feedUrl})));
13575
136- Future<int> uploadOpml(String opml) async {
137- final res = await session.sendFile(
138- '/api/feeds/opml/upload',
139- field: 'opml',
140- filename: 'subscriptions.opml',
141- bytes: utf8.encode(opml),
142- );
143- return jsonInt(_decode(res)['added']);
144- }
76+ Future<RefreshResult> refresh() async =>
77+ RefreshResult.fromJson(await _post('/refresh') as Map<String, dynamic>);
14578
146- Future<String> downloadOpml() async {
147- final res = await session.get('/api/feeds/opml/download');
148- if (res.statusCode >= 300) {
149- throw ApiException(res.statusCode, 'Could not export subscriptions.');
150- }
151- return res.body;
152- }
153-
154- Future<void> refreshFeeds() => _post('/api/feeds/refresh');
155-
156- Future<void> retryFeed(String feedUrl) =>
157- _post('/api/feeds/retry', form: {'url': feedUrl});
158-
159- Future<List<Subscription>> feedList() async =>
160- jsonList((await _get('/api/feeds/list'))['subscriptions'], Subscription.fromJson);
161-
162- Future<void> clearAllSubscriptions() => _post('/api/feeds/clear');
163-
164- // --- trending ---
165-
166- Future<TrendingResponse> trending({int page = 1, String? scope}) async =>
167- TrendingResponse.fromJson(await _get('/api/trending/', {'page': page, 'scope': scope}));
168-
169- // --- profile ---
170-
171- Future<ProfileResponse> profile(String did) async =>
172- ProfileResponse.fromJson(await _get('/api/profile/$did'));
173-
174- // --- library ---
175-
176- Future<LibraryResponse> library({int likedPage = 1, int annotPage = 1}) async =>
177- LibraryResponse.fromJson(
178- await _get('/api/library/', {'liked_page': likedPage, 'annot_page': annotPage}));
79+ // --- articles ---
17980
180- Future<Annotation> createAnnotation({
181- required String articleUrl,
182- required String feedUrl,
183- String quote = '',
184- String note = '',
185- List<String> tags = const [],
186- int? rating,
81+ Future<List<Article>> articles({
82+ String status = 'unread',
83+ String feedUrl = '',
84+ String search = '',
85+ int limit = 100,
18786 }) async {
188- final j = await _post('/api/library/create', form: {
189- 'article_url': articleUrl,
190- 'feed_url': feedUrl,
191- 'quote': quote,
192- 'note': note,
193- 'tags': tags.join(','),
194- if (rating != null) 'rating': '$rating',
195- });
196- return Annotation.fromJson(j['annotation'] as Map<String, dynamic>);
87+ // A search spans everything; scoping it to a feed and a read state as
88+ // well would quietly return nothing for the common case.
89+ final q = search.isNotEmpty
90+ ? {'q': search, 'limit': '$limit'}
91+ : {'status': status, 'feed': feedUrl, 'limit': '$limit'};
92+ return ((await _get('/articles', q)) as List)
93+ .map((e) => Article.fromJson(e as Map<String, dynamic>))
94+ .toList();
19795 }
19896
199- Future<void> deleteAnnotation(int id) => _post('/api/library/$id/delete');
200-
201- // --- recommendations ---
202-
203- Future<List<Article>> articleRecs() async =>
204- jsonList((await _get('/api/recs/articles'))['articles'], Article.fromJson);
97+ Future<Article> article(int id) async =>
98+ Article.fromJson(await _get('/articles/$id') as Map<String, dynamic>);
20599
206- Future<FeedRecsResponse> feedRecs() async =>
207- FeedRecsResponse.fromJson(await _get('/api/recs/feeds'));
100+ Future<void> setRead(int id, {bool read = true}) =>
101+ _post('/read', {'id': '$id', if (!read) 'undo': '1'});
208102
209- Future<PeopleRecsResponse> peopleRecs() async =>
210- PeopleRecsResponse.fromJson(await _get('/api/recs/people'));
211-
212- Future<void> dismissFeedRec(String feedUrl) =>
213- _post('/api/recs/dismiss-feed', form: {'feed_url': feedUrl});
214-
215- Future<void> dismissArticleRec(String articleUrl) =>
216- _post('/api/recs/dismiss-article', form: {'article_url': articleUrl});
217-
218- Future<void> dismissPersonRec(String did) =>
219- _post('/api/recs/dismiss-person', form: {'target_did': did});
220-
221- // --- settings ---
222-
223- Future<List<String>> toggleLanguage(String code) async =>
224- jsonStrList((await _post('/api/settings/languages/$code'))['languages']);
225-
226- Future<bool> setExpandedView(bool enabled) async => jsonBool(
227- (await _post('/api/settings/expanded-view',
228- form: {'expanded_view': enabled ? '1' : '0'}))['expanded_view'],
229- );
230-
231- Future<bool> setDigestEnabled(bool enabled) async => jsonBool(
232- (await _post('/api/settings/digest-enabled',
233- form: {'digest_enabled': enabled ? '1' : '0'}))['digest_enabled'],
234- );
235-
236- // --- digest ---
237-
238- Future<Map<String, dynamic>> digest() => _get('/api/digest');
239-
240- Future<void> markDigestRead() => _post('/api/digest/mark-read');
241-
242- // --- auth ---
243-
244- /// Resolve a partial handle to candidate accounts for the login field.
245- Future<List<Map<String, dynamic>>> resolveActors(String query) async {
246- final j = await _get('/api/auth/actors', {'q': query});
247- return ((j['actors'] as List?) ?? const []).cast<Map<String, dynamic>>();
103+ Future<int> markAllRead({String feedUrl = ''}) async {
104+ final j = await _post('/read-all', {if (feedUrl.isNotEmpty) 'feed': feedUrl})
105+ as Map<String, dynamic>;
106+ return (j['unread'] as num?)?.toInt() ?? 0;
248107 }
249108
250- /// Returns the URL the user must visit to authorize. The server answers with
251- /// `{"redirect": ...}` -- either the PDS authorization endpoint, or
252- /// `/dashboard` when it fell back to creating a read-only account.
253- Future<String> startAuth(String handle) async =>
254- jsonStr((await _post('/api/auth/start', form: {'handle': handle}))['redirect']);
109+ /// Scrape the original page for feeds that only ship an excerpt.
110+ Future<String> fetchFullText(int id) async {
111+ final j = await _post('/fetch-content', {'id': '$id'})
112+ as Map<String, dynamic>;
113+ return (j['content'] as String?) ?? '';
114+ }
255115
256- Future<void> logout() => _post('/api/auth/logout');
116+ void close() => _client.close();
257117 }
@@ -3,255 +3,115 @@ import 'dart:convert';
3 import 'package:http/http.dart' as http;3 import 'package:http/http.dart' as http;
4 4
5 import '../models/models.dart';5 import '../models/models.dart';
6-import 'responses.dart';
7-import 'session.dart';
8 6
9-/// Thrown for any non-2xx API response. The server puts a human-readable7+/// Thrown for any non-2xx response, or one carrying `{"error": …}`.
10-/// message in `{"error": "..."}` (writeAPIError), so surface that when present.
11 class ApiException implements Exception {8 class ApiException implements Exception {
12- ApiException(this.statusCode, this.message);9+ ApiException(this.message);
13-
14- final int statusCode;
15 final String message;10 final String message;
16-
17- bool get isUnauthorized => statusCode == 401 || statusCode == 403;
18-
19 @override11 @override
20 String toString() => message;12 String toString() => message;
21 }13 }
22 14
23-/// Typed wrapper over the Glean JSON API (internal/server/server.go).15+/// The reader's API.
24 ///16 ///
25-/// Every method here corresponds to exactly one route; the grouping and order17+/// No cookies, no CSRF, no auth: the server is single-user and binds
26-/// follow setupRoutes() so the two can be diffed by eye.18+/// loopback. That is why this class is a thin wrapper rather than a session.
27 class GleanClient {19 class GleanClient {
28- GleanClient(this.session);20+ GleanClient({required this.baseUrl, http.Client? client})
21+ : _client = client ?? http.Client();
29 22
30- final GleanSession session;23+ final String baseUrl;
24+ final http.Client _client;
31 25
32- // --- plumbing ---26+ Uri _uri(String path, [Map<String, String>? query]) {
27+ final q = query?.entries.where((e) => e.value.isNotEmpty);
28+ return Uri.parse('$baseUrl$path').replace(
29+ queryParameters: q == null || q.isEmpty ? null : Map.fromEntries(q),
30+ );
31+ }
33 32
34- Map<String, dynamic> _decode(http.Response res) {33+ dynamic _decode(http.Response res) {
35- final body = res.body.isEmpty ? '{}' : res.body;34+ late final dynamic body;
36- late final Map<String, dynamic> json;
37 try {35 try {
38- json = jsonDecode(body) as Map<String, dynamic>;36+ body = jsonDecode(res.body.isEmpty ? 'null' : res.body);
39 } on FormatException {37 } on FormatException {
40- // A non-JSON body means we hit something other than the API -- a proxy38+ throw ApiException('Unexpected response from server.');
41- // error page, or an HTML redirect from an expired session.39+ }
42- throw ApiException(res.statusCode, 'Unexpected non-JSON response from server.');40+ if (body is Map && body['error'] != null) {
41+ throw ApiException('${body['error']}');
42+ }
43+ if (res.statusCode < 200 || res.statusCode >= 300) {
44+ throw ApiException('Request failed (${res.statusCode}).');
43 }45 }
44- if (res.statusCode >= 200 && res.statusCode < 300) return json;46+ return body;
45- final msg = (json['error'] as String?) ?? 'Request failed (${res.statusCode}).';
46- throw ApiException(res.statusCode, msg);
47 }47 }
48 48
49- Future<Map<String, dynamic>> _get(String path, [Map<String, dynamic>? q]) async =>49+ Future<dynamic> _get(String path, [Map<String, String>? q]) async =>
50- _decode(await session.get(path, q));50+ _decode(await _client.get(_uri(path, q)));
51 51
52- Future<Map<String, dynamic>> _post(String path,52+ Future<dynamic> _post(String path, [Map<String, String>? form]) async =>
53- {Map<String, dynamic>? query, Map<String, String>? form}) async =>53+ _decode(await _client.post(_uri(path), body: form ?? const {}));
54- _decode(await session.send('POST', path, query: query, form: form));
55-
56- Future<Map<String, dynamic>> _delete(String path,
57- {Map<String, dynamic>? query, Map<String, String>? form}) async =>
58- _decode(await session.send('DELETE', path, query: query, form: form));
59-
60- // --- identity ---
61-
62- Future<MeResponse> me() async => MeResponse.fromJson(await _get('/api/me'));
63-
64- // --- dashboard ---
65-
66- Future<DashboardResponse> dashboard() async =>
67- DashboardResponse.fromJson(await _get('/api/dashboard/'));
68-
69- // --- articles ---
70-
71- Future<ArticlesResponse> articles({
72- int page = 1,
73- String? feedUrl,
74- String? status,
75- String? search,
76- String? category,
77- bool sortOldest = false,
78- }) async =>
79- ArticlesResponse.fromJson(await _get('/api/articles/', {
80- 'page': page,
81- 'feed': feedUrl,
82- 'status': status,
83- 'q': search,
84- 'category': category,
85- if (sortOldest) 'sort': 'oldest',
86- }));
87-
88- Future<ArticleDetailResponse> article(int id) async =>
89- ArticleDetailResponse.fromJson(await _get('/api/articles/$id'));
90-
91- Future<int> newArticleCount() async =>
92- jsonInt((await _get('/api/articles/new-count'))['count']);
93-
94- Future<bool> markRead(int id) async =>
95- jsonBool((await _post('/api/articles/$id/read'))['is_read']);
96-
97- Future<bool> markUnread(int id) async =>
98- jsonBool((await _post('/api/articles/$id/unread'))['is_read']);
99-
100- Future<LikeResult> likeArticle(int id) async =>
101- LikeResult.fromJson(await _post('/api/articles/$id/like'));
102-
103- Future<String> fetchContent(int id) async =>
104- jsonStr((await _post('/api/articles/$id/fetch-content'))['full_content']);
105-
106- Future<void> markAllRead({String? feedUrl, String? category}) =>
107- _post('/api/articles/mark-all-read', form: {
108- 'feed': ?feedUrl,
109- 'category': ?category,
110- });
111 54
112 // --- feeds ---55 // --- feeds ---
113 56
114- Future<FeedsResponse> feeds({int page = 1, String? category}) async =>57+ Future<List<Feed>> feeds() async =>
115- FeedsResponse.fromJson(await _get('/api/feeds/', {'page': page, 'category': category}));58+ ((await _get('/feeds')) as List)
59+ .map((e) => Feed.fromJson(e as Map<String, dynamic>))
60+ .toList();
116 61
117- Future<Subscription> addFeed(String url, {String? category}) async {62+ Future<int> unreadCount() async =>
118- final j = await _post('/api/feeds/add', form: {63+ ((await _get('/unread')) as Map<String, dynamic>)['count'] as int? ?? 0;
119- 'feed_url': url,
120- 'category': ?category,
121- });
122- return Subscription.fromJson(j['subscription'] as Map<String, dynamic>);
123- }
124 64
125- Future<Subscription> editFeed(String feedUrl, {String? category}) async {65+ /// Subscribe. The server fetches immediately and reports how many articles
126- final j = await _post('/api/feeds/edit', form: {66+ /// arrived, so a feed that resolves but is empty is distinguishable from
127- 'feed_url': feedUrl,67+ /// one that failed.
128- 'category': ?category,68+ Future<int> addFeed(String url) async {
129- });69+ final j = await _post('/feeds', {'url': url}) as Map<String, dynamic>;
130- return Subscription.fromJson(j['subscription'] as Map<String, dynamic>);70+ return (j['added'] as num?)?.toInt() ?? 0;
131 }71 }
132 72
133- Future<void> removeFeed(String feedUrl) =>73+ Future<void> removeFeed(String feedUrl) async =>
134- _delete('/api/feeds/remove', form: {'url': feedUrl});74+ _decode(await _client.delete(_uri('/feeds', {'url': feedUrl})));
135 75
136- Future<int> uploadOpml(String opml) async {76+ Future<RefreshResult> refresh() async =>
137- final res = await session.sendFile(77+ RefreshResult.fromJson(await _post('/refresh') as Map<String, dynamic>);
138- '/api/feeds/opml/upload',
139- field: 'opml',
140- filename: 'subscriptions.opml',
141- bytes: utf8.encode(opml),
142- );
143- return jsonInt(_decode(res)['added']);
144- }
145 78
146- Future<String> downloadOpml() async {79+ // --- articles ---
147- final res = await session.get('/api/feeds/opml/download');
148- if (res.statusCode >= 300) {
149- throw ApiException(res.statusCode, 'Could not export subscriptions.');
150- }
151- return res.body;
152- }
153-
154- Future<void> refreshFeeds() => _post('/api/feeds/refresh');
155-
156- Future<void> retryFeed(String feedUrl) =>
157- _post('/api/feeds/retry', form: {'url': feedUrl});
158-
159- Future<List<Subscription>> feedList() async =>
160- jsonList((await _get('/api/feeds/list'))['subscriptions'], Subscription.fromJson);
161-
162- Future<void> clearAllSubscriptions() => _post('/api/feeds/clear');
163-
164- // --- trending ---
165-
166- Future<TrendingResponse> trending({int page = 1, String? scope}) async =>
167- TrendingResponse.fromJson(await _get('/api/trending/', {'page': page, 'scope': scope}));
168-
169- // --- profile ---
170-
171- Future<ProfileResponse> profile(String did) async =>
172- ProfileResponse.fromJson(await _get('/api/profile/$did'));
173-
174- // --- library ---
175-
176- Future<LibraryResponse> library({int likedPage = 1, int annotPage = 1}) async =>
177- LibraryResponse.fromJson(
178- await _get('/api/library/', {'liked_page': likedPage, 'annot_page': annotPage}));
179 80
180- Future<Annotation> createAnnotation({81+ Future<List<Article>> articles({
181- required String articleUrl,82+ String status = 'unread',
182- required String feedUrl,83+ String feedUrl = '',
183- String quote = '',84+ String search = '',
184- String note = '',85+ int limit = 100,
185- List<String> tags = const [],
186- int? rating,
187 }) async {86 }) async {
188- final j = await _post('/api/library/create', form: {87+ // A search spans everything; scoping it to a feed and a read state as
189- 'article_url': articleUrl,88+ // well would quietly return nothing for the common case.
190- 'feed_url': feedUrl,89+ final q = search.isNotEmpty
191- 'quote': quote,90+ ? {'q': search, 'limit': '$limit'}
192- 'note': note,91+ : {'status': status, 'feed': feedUrl, 'limit': '$limit'};
193- 'tags': tags.join(','),92+ return ((await _get('/articles', q)) as List)
194- if (rating != null) 'rating': '$rating',93+ .map((e) => Article.fromJson(e as Map<String, dynamic>))
195- });94+ .toList();
196- return Annotation.fromJson(j['annotation'] as Map<String, dynamic>);
197 }95 }
198 96
199- Future<void> deleteAnnotation(int id) => _post('/api/library/$id/delete');97+ Future<Article> article(int id) async =>
200-98+ Article.fromJson(await _get('/articles/$id') as Map<String, dynamic>);
201- // --- recommendations ---
202-
203- Future<List<Article>> articleRecs() async =>
204- jsonList((await _get('/api/recs/articles'))['articles'], Article.fromJson);
205 99
206- Future<FeedRecsResponse> feedRecs() async =>100+ Future<void> setRead(int id, {bool read = true}) =>
207- FeedRecsResponse.fromJson(await _get('/api/recs/feeds'));101+ _post('/read', {'id': '$id', if (!read) 'undo': '1'});
208 102
209- Future<PeopleRecsResponse> peopleRecs() async =>103+ Future<int> markAllRead({String feedUrl = ''}) async {
210- PeopleRecsResponse.fromJson(await _get('/api/recs/people'));104+ final j = await _post('/read-all', {if (feedUrl.isNotEmpty) 'feed': feedUrl})
211-105+ as Map<String, dynamic>;
212- Future<void> dismissFeedRec(String feedUrl) =>106+ return (j['unread'] as num?)?.toInt() ?? 0;
213- _post('/api/recs/dismiss-feed', form: {'feed_url': feedUrl});
214-
215- Future<void> dismissArticleRec(String articleUrl) =>
216- _post('/api/recs/dismiss-article', form: {'article_url': articleUrl});
217-
218- Future<void> dismissPersonRec(String did) =>
219- _post('/api/recs/dismiss-person', form: {'target_did': did});
220-
221- // --- settings ---
222-
223- Future<List<String>> toggleLanguage(String code) async =>
224- jsonStrList((await _post('/api/settings/languages/$code'))['languages']);
225-
226- Future<bool> setExpandedView(bool enabled) async => jsonBool(
227- (await _post('/api/settings/expanded-view',
228- form: {'expanded_view': enabled ? '1' : '0'}))['expanded_view'],
229- );
230-
231- Future<bool> setDigestEnabled(bool enabled) async => jsonBool(
232- (await _post('/api/settings/digest-enabled',
233- form: {'digest_enabled': enabled ? '1' : '0'}))['digest_enabled'],
234- );
235-
236- // --- digest ---
237-
238- Future<Map<String, dynamic>> digest() => _get('/api/digest');
239-
240- Future<void> markDigestRead() => _post('/api/digest/mark-read');
241-
242- // --- auth ---
243-
244- /// Resolve a partial handle to candidate accounts for the login field.
245- Future<List<Map<String, dynamic>>> resolveActors(String query) async {
246- final j = await _get('/api/auth/actors', {'q': query});
247- return ((j['actors'] as List?) ?? const []).cast<Map<String, dynamic>>();
248 }107 }
249 108
250- /// Returns the URL the user must visit to authorize. The server answers with109+ /// Scrape the original page for feeds that only ship an excerpt.
251- /// `{"redirect": ...}` -- either the PDS authorization endpoint, or110+ Future<String> fetchFullText(int id) async {
252- /// `/dashboard` when it fell back to creating a read-only account.111+ final j = await _post('/fetch-content', {'id': '$id'})
253- Future<String> startAuth(String handle) async =>112+ as Map<String, dynamic>;
254- jsonStr((await _post('/api/auth/start', form: {'handle': handle}))['redirect']);113+ return (j['content'] as String?) ?? '';
114+ }
255 115
256- Future<void> logout() => _post('/api/auth/logout');116+ void close() => _client.close();
257 }117 }
deleted app/lib/src/api/responses.dart +0 -261
deleted file mode 100644
@@ -1,261 +0,0 @@
1-import '../models/models.dart';
2-
3-/// Response envelopes for each endpoint, mirroring the unexported structs at
4-/// the bottom of internal/server/api.go.
5-
6-class MeResponse {
7- const MeResponse({this.user, required this.csrfToken, required this.hasLlm, required this.clientId});
8-
9- final User? user;
10- final String csrfToken;
11- final bool hasLlm;
12- final String clientId;
13-
14- bool get signedIn => user != null;
15-
16- /// True when the server is running without a real OAuth client id, in which
17- /// case sign-in cannot complete (see GLEAN_OAUTH_CLIENT_ID).
18- bool get oauthConfigured => clientId.isNotEmpty;
19-
20- factory MeResponse.fromJson(Map<String, dynamic> j) => MeResponse(
21- user: j['user'] == null ? null : User.fromJson(j['user'] as Map<String, dynamic>),
22- csrfToken: jsonStr(j['csrf_token']),
23- hasLlm: jsonBool(j['has_llm']),
24- clientId: jsonStr(j['client_id']),
25- );
26-}
27-
28-class DashboardResponse {
29- const DashboardResponse({
30- required this.user,
31- required this.subscriptionCount,
32- required this.unreadCount,
33- required this.articles,
34- required this.personalTrending,
35- required this.globalTrending,
36- required this.digestEnabled,
37- required this.hasLlm,
38- });
39-
40- final User user;
41- final int subscriptionCount;
42- final int unreadCount;
43- final List<Article> articles;
44- final List<TrendingItem> personalTrending;
45- final List<TrendingItem> globalTrending;
46- final bool digestEnabled;
47- final bool hasLlm;
48-
49- factory DashboardResponse.fromJson(Map<String, dynamic> j) => DashboardResponse(
50- user: User.fromJson((j['user'] as Map<String, dynamic>?) ?? const {}),
51- subscriptionCount: jsonInt(j['subscription_count']),
52- unreadCount: jsonInt(j['unread_count']),
53- articles: jsonList(j['articles'], Article.fromJson),
54- personalTrending: jsonList(j['personal_trending'], TrendingItem.fromJson),
55- globalTrending: jsonList(j['global_trending'], TrendingItem.fromJson),
56- digestEnabled: jsonBool(j['digest_enabled']),
57- hasLlm: jsonBool(j['has_llm']),
58- );
59-}
60-
61-class ArticlesResponse {
62- const ArticlesResponse({
63- required this.articles,
64- required this.feedUrl,
65- required this.status,
66- required this.searchQuery,
67- required this.sortOldest,
68- required this.category,
69- required this.categories,
70- required this.expandedView,
71- required this.pagination,
72- this.feed,
73- required this.isSubscribed,
74- });
75-
76- final List<Article> articles;
77- final String feedUrl;
78- final String status;
79- final String searchQuery;
80- final bool sortOldest;
81- final String category;
82- final List<String> categories;
83- final bool expandedView;
84- final Pagination pagination;
85- final Feed? feed;
86- final bool isSubscribed;
87-
88- factory ArticlesResponse.fromJson(Map<String, dynamic> j) => ArticlesResponse(
89- articles: jsonList(j['articles'], Article.fromJson),
90- feedUrl: jsonStr(j['feed_url']),
91- status: jsonStr(j['status']),
92- searchQuery: jsonStr(j['search_query']),
93- sortOldest: jsonBool(j['sort_oldest']),
94- category: jsonStr(j['category']),
95- categories: jsonStrList(j['categories']),
96- expandedView: jsonBool(j['expanded_view']),
97- pagination: Pagination.fromJson(j['pagination'] as Map<String, dynamic>?),
98- feed: j['feed'] == null ? null : Feed.fromJson(j['feed'] as Map<String, dynamic>),
99- isSubscribed: jsonBool(j['is_subscribed']),
100- );
101-}
102-
103-class ArticleDetailResponse {
104- const ArticleDetailResponse({
105- required this.currentUserDid,
106- required this.article,
107- required this.feed,
108- required this.annotations,
109- this.nextId,
110- });
111-
112- final String currentUserDid;
113- final Article article;
114- final Feed feed;
115- final List<Annotation> annotations;
116- final int? nextId;
117-
118- factory ArticleDetailResponse.fromJson(Map<String, dynamic> j) => ArticleDetailResponse(
119- currentUserDid: jsonStr(j['current_user_did']),
120- article: Article.fromJson((j['article'] as Map<String, dynamic>?) ?? const {}),
121- feed: Feed.fromJson((j['feed'] as Map<String, dynamic>?) ?? const {}),
122- annotations: jsonList(j['annotations'], Annotation.fromJson),
123- nextId: (j['next_id'] as num?)?.toInt(),
124- );
125-}
126-
127-class FeedsResponse {
128- const FeedsResponse({
129- required this.subscriptions,
130- required this.subscriptionCount,
131- required this.categories,
132- required this.category,
133- required this.deadFeeds,
134- required this.pagination,
135- });
136-
137- final List<Subscription> subscriptions;
138- final int subscriptionCount;
139- final List<String> categories;
140- final String category;
141- final List<Feed> deadFeeds;
142- final Pagination pagination;
143-
144- factory FeedsResponse.fromJson(Map<String, dynamic> j) => FeedsResponse(
145- subscriptions: jsonList(j['subscriptions'], Subscription.fromJson),
146- subscriptionCount: jsonInt(j['subscription_count']),
147- categories: jsonStrList(j['categories']),
148- category: jsonStr(j['category']),
149- deadFeeds: jsonList(j['dead_feeds'], Feed.fromJson),
150- pagination: Pagination.fromJson(j['pagination'] as Map<String, dynamic>?),
151- );
152-}
153-
154-class TrendingResponse {
155- const TrendingResponse({required this.trending, required this.scope, required this.pagination});
156-
157- final List<TrendingItem> trending;
158- final String scope;
159- final Pagination pagination;
160-
161- factory TrendingResponse.fromJson(Map<String, dynamic> j) => TrendingResponse(
162- trending: jsonList(j['trending'], TrendingItem.fromJson),
163- scope: jsonStr(j['scope']),
164- pagination: Pagination.fromJson(j['pagination'] as Map<String, dynamic>?),
165- );
166-}
167-
168-class LibraryResponse {
169- const LibraryResponse({
170- required this.articles,
171- required this.annotations,
172- required this.likedPage,
173- required this.annotPage,
174- });
175-
176- final List<Article> articles;
177- final List<Annotation> annotations;
178- final Pagination likedPage;
179- final Pagination annotPage;
180-
181- factory LibraryResponse.fromJson(Map<String, dynamic> j) => LibraryResponse(
182- articles: jsonList(j['articles'], Article.fromJson),
183- annotations: jsonList(j['annotations'], Annotation.fromJson),
184- likedPage: Pagination.fromJson(j['liked_page'] as Map<String, dynamic>?),
185- annotPage: Pagination.fromJson(j['annot_page'] as Map<String, dynamic>?),
186- );
187-}
188-
189-class ProfileResponse {
190- const ProfileResponse({
191- required this.profileUser,
192- required this.subscriptions,
193- required this.annotations,
194- required this.subscriptionCount,
195- required this.annotationCount,
196- required this.userLanguages,
197- required this.availableLanguages,
198- required this.expandedView,
199- required this.digestEnabled,
200- });
201-
202- final User profileUser;
203- final List<Subscription> subscriptions;
204- final List<Annotation> annotations;
205- final int subscriptionCount;
206- final int annotationCount;
207- final List<String> userLanguages;
208- final List<Language> availableLanguages;
209- final bool expandedView;
210- final bool digestEnabled;
211-
212- factory ProfileResponse.fromJson(Map<String, dynamic> j) => ProfileResponse(
213- profileUser: User.fromJson((j['profile_user'] as Map<String, dynamic>?) ?? const {}),
214- subscriptions: jsonList(j['subscriptions'], Subscription.fromJson),
215- annotations: jsonList(j['annotations'], Annotation.fromJson),
216- subscriptionCount: jsonInt(j['subscription_count']),
217- annotationCount: jsonInt(j['annotation_count']),
218- userLanguages: jsonStrList(j['user_languages']),
219- availableLanguages: jsonList(j['available_languages'], Language.fromJson),
220- expandedView: jsonBool(j['expanded_view']),
221- digestEnabled: jsonBool(j['digest_enabled']),
222- );
223-}
224-
225-class PeopleRecsResponse {
226- const PeopleRecsResponse({required this.followed, required this.discover});
227-
228- final List<PersonRecommendation> followed;
229- final List<PersonRecommendation> discover;
230-
231- factory PeopleRecsResponse.fromJson(Map<String, dynamic> j) => PeopleRecsResponse(
232- followed: jsonList(j['followed'], PersonRecommendation.fromJson),
233- discover: jsonList(j['discover'], PersonRecommendation.fromJson),
234- );
235-}
236-
237-class FeedRecsResponse {
238- const FeedRecsResponse({required this.feeds, required this.subscriptionCount});
239-
240- final List<FeedRecommendation> feeds;
241- final int subscriptionCount;
242-
243- factory FeedRecsResponse.fromJson(Map<String, dynamic> j) => FeedRecsResponse(
244- feeds: jsonList(j['feeds'], FeedRecommendation.fromJson),
245- subscriptionCount: jsonInt(j['subscription_count']),
246- );
247-}
248-
249-class LikeResult {
250- const LikeResult({required this.id, required this.liked, required this.likeCount});
251-
252- final int id;
253- final bool liked;
254- final int likeCount;
255-
256- factory LikeResult.fromJson(Map<String, dynamic> j) => LikeResult(
257- id: jsonInt(j['id']),
258- liked: jsonBool(j['liked']),
259- likeCount: jsonInt(j['like_count']),
260- );
261-}
deleted file mode 100644
@@ -1,261 +0,0 @@
1-import '../models/models.dart';
2-
3-/// Response envelopes for each endpoint, mirroring the unexported structs at
4-/// the bottom of internal/server/api.go.
5-
6-class MeResponse {
7- const MeResponse({this.user, required this.csrfToken, required this.hasLlm, required this.clientId});
8-
9- final User? user;
10- final String csrfToken;
11- final bool hasLlm;
12- final String clientId;
13-
14- bool get signedIn => user != null;
15-
16- /// True when the server is running without a real OAuth client id, in which
17- /// case sign-in cannot complete (see GLEAN_OAUTH_CLIENT_ID).
18- bool get oauthConfigured => clientId.isNotEmpty;
19-
20- factory MeResponse.fromJson(Map<String, dynamic> j) => MeResponse(
21- user: j['user'] == null ? null : User.fromJson(j['user'] as Map<String, dynamic>),
22- csrfToken: jsonStr(j['csrf_token']),
23- hasLlm: jsonBool(j['has_llm']),
24- clientId: jsonStr(j['client_id']),
25- );
26-}
27-
28-class DashboardResponse {
29- const DashboardResponse({
30- required this.user,
31- required this.subscriptionCount,
32- required this.unreadCount,
33- required this.articles,
34- required this.personalTrending,
35- required this.globalTrending,
36- required this.digestEnabled,
37- required this.hasLlm,
38- });
39-
40- final User user;
41- final int subscriptionCount;
42- final int unreadCount;
43- final List<Article> articles;
44- final List<TrendingItem> personalTrending;
45- final List<TrendingItem> globalTrending;
46- final bool digestEnabled;
47- final bool hasLlm;
48-
49- factory DashboardResponse.fromJson(Map<String, dynamic> j) => DashboardResponse(
50- user: User.fromJson((j['user'] as Map<String, dynamic>?) ?? const {}),
51- subscriptionCount: jsonInt(j['subscription_count']),
52- unreadCount: jsonInt(j['unread_count']),
53- articles: jsonList(j['articles'], Article.fromJson),
54- personalTrending: jsonList(j['personal_trending'], TrendingItem.fromJson),
55- globalTrending: jsonList(j['global_trending'], TrendingItem.fromJson),
56- digestEnabled: jsonBool(j['digest_enabled']),
57- hasLlm: jsonBool(j['has_llm']),
58- );
59-}
60-
61-class ArticlesResponse {
62- const ArticlesResponse({
63- required this.articles,
64- required this.feedUrl,
65- required this.status,
66- required this.searchQuery,
67- required this.sortOldest,
68- required this.category,
69- required this.categories,
70- required this.expandedView,
71- required this.pagination,
72- this.feed,
73- required this.isSubscribed,
74- });
75-
76- final List<Article> articles;
77- final String feedUrl;
78- final String status;
79- final String searchQuery;
80- final bool sortOldest;
81- final String category;
82- final List<String> categories;
83- final bool expandedView;
84- final Pagination pagination;
85- final Feed? feed;
86- final bool isSubscribed;
87-
88- factory ArticlesResponse.fromJson(Map<String, dynamic> j) => ArticlesResponse(
89- articles: jsonList(j['articles'], Article.fromJson),
90- feedUrl: jsonStr(j['feed_url']),
91- status: jsonStr(j['status']),
92- searchQuery: jsonStr(j['search_query']),
93- sortOldest: jsonBool(j['sort_oldest']),
94- category: jsonStr(j['category']),
95- categories: jsonStrList(j['categories']),
96- expandedView: jsonBool(j['expanded_view']),
97- pagination: Pagination.fromJson(j['pagination'] as Map<String, dynamic>?),
98- feed: j['feed'] == null ? null : Feed.fromJson(j['feed'] as Map<String, dynamic>),
99- isSubscribed: jsonBool(j['is_subscribed']),
100- );
101-}
102-
103-class ArticleDetailResponse {
104- const ArticleDetailResponse({
105- required this.currentUserDid,
106- required this.article,
107- required this.feed,
108- required this.annotations,
109- this.nextId,
110- });
111-
112- final String currentUserDid;
113- final Article article;
114- final Feed feed;
115- final List<Annotation> annotations;
116- final int? nextId;
117-
118- factory ArticleDetailResponse.fromJson(Map<String, dynamic> j) => ArticleDetailResponse(
119- currentUserDid: jsonStr(j['current_user_did']),
120- article: Article.fromJson((j['article'] as Map<String, dynamic>?) ?? const {}),
121- feed: Feed.fromJson((j['feed'] as Map<String, dynamic>?) ?? const {}),
122- annotations: jsonList(j['annotations'], Annotation.fromJson),
123- nextId: (j['next_id'] as num?)?.toInt(),
124- );
125-}
126-
127-class FeedsResponse {
128- const FeedsResponse({
129- required this.subscriptions,
130- required this.subscriptionCount,
131- required this.categories,
132- required this.category,
133- required this.deadFeeds,
134- required this.pagination,
135- });
136-
137- final List<Subscription> subscriptions;
138- final int subscriptionCount;
139- final List<String> categories;
140- final String category;
141- final List<Feed> deadFeeds;
142- final Pagination pagination;
143-
144- factory FeedsResponse.fromJson(Map<String, dynamic> j) => FeedsResponse(
145- subscriptions: jsonList(j['subscriptions'], Subscription.fromJson),
146- subscriptionCount: jsonInt(j['subscription_count']),
147- categories: jsonStrList(j['categories']),
148- category: jsonStr(j['category']),
149- deadFeeds: jsonList(j['dead_feeds'], Feed.fromJson),
150- pagination: Pagination.fromJson(j['pagination'] as Map<String, dynamic>?),
151- );
152-}
153-
154-class TrendingResponse {
155- const TrendingResponse({required this.trending, required this.scope, required this.pagination});
156-
157- final List<TrendingItem> trending;
158- final String scope;
159- final Pagination pagination;
160-
161- factory TrendingResponse.fromJson(Map<String, dynamic> j) => TrendingResponse(
162- trending: jsonList(j['trending'], TrendingItem.fromJson),
163- scope: jsonStr(j['scope']),
164- pagination: Pagination.fromJson(j['pagination'] as Map<String, dynamic>?),
165- );
166-}
167-
168-class LibraryResponse {
169- const LibraryResponse({
170- required this.articles,
171- required this.annotations,
172- required this.likedPage,
173- required this.annotPage,
174- });
175-
176- final List<Article> articles;
177- final List<Annotation> annotations;
178- final Pagination likedPage;
179- final Pagination annotPage;
180-
181- factory LibraryResponse.fromJson(Map<String, dynamic> j) => LibraryResponse(
182- articles: jsonList(j['articles'], Article.fromJson),
183- annotations: jsonList(j['annotations'], Annotation.fromJson),
184- likedPage: Pagination.fromJson(j['liked_page'] as Map<String, dynamic>?),
185- annotPage: Pagination.fromJson(j['annot_page'] as Map<String, dynamic>?),
186- );
187-}
188-
189-class ProfileResponse {
190- const ProfileResponse({
191- required this.profileUser,
192- required this.subscriptions,
193- required this.annotations,
194- required this.subscriptionCount,
195- required this.annotationCount,
196- required this.userLanguages,
197- required this.availableLanguages,
198- required this.expandedView,
199- required this.digestEnabled,
200- });
201-
202- final User profileUser;
203- final List<Subscription> subscriptions;
204- final List<Annotation> annotations;
205- final int subscriptionCount;
206- final int annotationCount;
207- final List<String> userLanguages;
208- final List<Language> availableLanguages;
209- final bool expandedView;
210- final bool digestEnabled;
211-
212- factory ProfileResponse.fromJson(Map<String, dynamic> j) => ProfileResponse(
213- profileUser: User.fromJson((j['profile_user'] as Map<String, dynamic>?) ?? const {}),
214- subscriptions: jsonList(j['subscriptions'], Subscription.fromJson),
215- annotations: jsonList(j['annotations'], Annotation.fromJson),
216- subscriptionCount: jsonInt(j['subscription_count']),
217- annotationCount: jsonInt(j['annotation_count']),
218- userLanguages: jsonStrList(j['user_languages']),
219- availableLanguages: jsonList(j['available_languages'], Language.fromJson),
220- expandedView: jsonBool(j['expanded_view']),
221- digestEnabled: jsonBool(j['digest_enabled']),
222- );
223-}
224-
225-class PeopleRecsResponse {
226- const PeopleRecsResponse({required this.followed, required this.discover});
227-
228- final List<PersonRecommendation> followed;
229- final List<PersonRecommendation> discover;
230-
231- factory PeopleRecsResponse.fromJson(Map<String, dynamic> j) => PeopleRecsResponse(
232- followed: jsonList(j['followed'], PersonRecommendation.fromJson),
233- discover: jsonList(j['discover'], PersonRecommendation.fromJson),
234- );
235-}
236-
237-class FeedRecsResponse {
238- const FeedRecsResponse({required this.feeds, required this.subscriptionCount});
239-
240- final List<FeedRecommendation> feeds;
241- final int subscriptionCount;
242-
243- factory FeedRecsResponse.fromJson(Map<String, dynamic> j) => FeedRecsResponse(
244- feeds: jsonList(j['feeds'], FeedRecommendation.fromJson),
245- subscriptionCount: jsonInt(j['subscription_count']),
246- );
247-}
248-
249-class LikeResult {
250- const LikeResult({required this.id, required this.liked, required this.likeCount});
251-
252- final int id;
253- final bool liked;
254- final int likeCount;
255-
256- factory LikeResult.fromJson(Map<String, dynamic> j) => LikeResult(
257- id: jsonInt(j['id']),
258- liked: jsonBool(j['liked']),
259- likeCount: jsonInt(j['like_count']),
260- );
261-}
deleted app/lib/src/api/session.dart +0 -162
deleted file mode 100644
@@ -1,162 +0,0 @@
1-import 'dart:io';
2-
3-import 'package:http/http.dart' as http;
4-import 'package:shared_preferences/shared_preferences.dart';
5-
6-/// Cookie-backed session against the Glean API.
7-///
8-/// The Go server authenticates with a session cookie and guards unsafe methods
9-/// with a double-submit CSRF token: the value lives in the `glean_csrf` cookie
10-/// and must be echoed back in the `X-CSRF-Token` header (internal/server/
11-/// middleware.go). Dart's http package has no cookie jar, so this class is one
12-/// -- deliberately minimal, since the server sets only a handful of cookies on
13-/// a single origin.
14-class GleanSession {
15- GleanSession({required this.baseUrl, http.Client? client})
16- : _client = client ?? http.Client();
17-
18- final String baseUrl;
19- final http.Client _client;
20- final Map<String, String> _cookies = {};
21-
22- static const _prefsKey = 'glean_cookies';
23- static const _csrfCookie = 'glean_csrf';
24-
25- bool get hasSession => _cookies.isNotEmpty;
26- String? get csrfToken => _cookies[_csrfCookie];
27-
28- /// Cookies survive restarts so a signed-in user stays signed in. They are
29- /// session credentials, so callers on mobile may prefer secure storage; this
30- /// uses shared_preferences to keep the dependency surface small.
31- Future<void> load() async {
32- final prefs = await SharedPreferences.getInstance();
33- final raw = prefs.getStringList(_prefsKey) ?? const [];
34- for (final entry in raw) {
35- final i = entry.indexOf('=');
36- if (i > 0) _cookies[entry.substring(0, i)] = entry.substring(i + 1);
37- }
38- }
39-
40- Future<void> _persist() async {
41- final prefs = await SharedPreferences.getInstance();
42- await prefs.setStringList(
43- _prefsKey,
44- _cookies.entries.map((e) => '${e.key}=${e.value}').toList(),
45- );
46- }
47-
48- Future<void> clear() async {
49- _cookies.clear();
50- await _persist();
51- }
52-
53- /// Adopt cookies captured elsewhere -- notably the webview that completes the
54- /// ATProto OAuth redirect, which is where the session cookie is first issued.
55- Future<void> adopt(Map<String, String> cookies) async {
56- _cookies.addAll(cookies);
57- await _persist();
58- }
59-
60- Map<String, String> _headers({bool unsafe = false, Map<String, String>? extra}) {
61- final h = <String, String>{'Accept': 'application/json'};
62- if (_cookies.isNotEmpty) {
63- h['Cookie'] = _cookies.entries.map((e) => '${e.key}=${e.value}').join('; ');
64- }
65- // Only unsafe methods are CSRF-checked; sending it on GET is harmless but
66- // noisy, and mirrors what the web client does.
67- if (unsafe && csrfToken != null) h['X-CSRF-Token'] = csrfToken!;
68- if (extra != null) h.addAll(extra);
69- return h;
70- }
71-
72- void _absorb(http.BaseResponse res) {
73- final raw = res.headers['set-cookie'];
74- if (raw == null) return;
75- for (final c in _splitSetCookie(raw)) {
76- try {
77- final cookie = Cookie.fromSetCookieValue(c);
78- // An expiry in the past is the server deleting the cookie (logout).
79- final expired = cookie.expires != null && cookie.expires!.isBefore(DateTime.now());
80- if (cookie.value.isEmpty || expired) {
81- _cookies.remove(cookie.name);
82- } else {
83- _cookies[cookie.name] = cookie.value;
84- }
85- } on Exception {
86- // A malformed Set-Cookie should not fail the request it rode in on.
87- continue;
88- }
89- }
90- _persist();
91- }
92-
93- /// Dart folds repeated Set-Cookie headers into one comma-joined string, but
94- /// commas also appear inside `Expires=Wed, 21 Oct 2026 ...`. Split only on
95- /// commas that begin a new `name=` pair.
96- static List<String> _splitSetCookie(String header) {
97- final parts = <String>[];
98- var start = 0;
99- for (var i = 0; i < header.length; i++) {
100- if (header[i] != ',') continue;
101- final rest = header.substring(i + 1);
102- final eq = rest.indexOf('=');
103- final semi = rest.indexOf(';');
104- if (eq > 0 && (semi < 0 || eq < semi) && !rest.substring(0, eq).contains(' ')) {
105- parts.add(header.substring(start, i).trim());
106- start = i + 1;
107- }
108- }
109- parts.add(header.substring(start).trim());
110- return parts.where((p) => p.isNotEmpty).toList();
111- }
112-
113- Uri _uri(String path, [Map<String, dynamic>? query]) {
114- final q = query?.entries
115- .where((e) => e.value != null && '${e.value}'.isNotEmpty)
116- .map((e) => MapEntry(e.key, '${e.value}'));
117- return Uri.parse('$baseUrl$path').replace(
118- queryParameters: q == null || q.isEmpty ? null : Map.fromEntries(q),
119- );
120- }
121-
122- Future<http.Response> get(String path, [Map<String, dynamic>? query]) async {
123- final res = await _client.get(_uri(path, query), headers: _headers());
124- _absorb(res);
125- return res;
126- }
127-
128- Future<http.Response> send(
129- String method,
130- String path, {
131- Map<String, dynamic>? query,
132- Map<String, String>? form,
133- }) async {
134- final req = http.Request(method, _uri(path, query))
135- ..headers.addAll(_headers(
136- unsafe: true,
137- extra: form == null ? null : {'Content-Type': 'application/x-www-form-urlencoded'},
138- ));
139- if (form != null) req.bodyFields = form;
140- final res = await http.Response.fromStream(await _client.send(req));
141- _absorb(res);
142- return res;
143- }
144-
145- /// Multipart POST, for the endpoints that take a file rather than form
146- /// fields (OPML import uses r.FormFile).
147- Future<http.Response> sendFile(
148- String path, {
149- required String field,
150- required String filename,
151- required List<int> bytes,
152- }) async {
153- final req = http.MultipartRequest('POST', _uri(path))
154- ..headers.addAll(_headers(unsafe: true))
155- ..files.add(http.MultipartFile.fromBytes(field, bytes, filename: filename));
156- final res = await http.Response.fromStream(await _client.send(req));
157- _absorb(res);
158- return res;
159- }
160-
161- void close() => _client.close();
162-}
deleted file mode 100644
@@ -1,162 +0,0 @@
1-import 'dart:io';
2-
3-import 'package:http/http.dart' as http;
4-import 'package:shared_preferences/shared_preferences.dart';
5-
6-/// Cookie-backed session against the Glean API.
7-///
8-/// The Go server authenticates with a session cookie and guards unsafe methods
9-/// with a double-submit CSRF token: the value lives in the `glean_csrf` cookie
10-/// and must be echoed back in the `X-CSRF-Token` header (internal/server/
11-/// middleware.go). Dart's http package has no cookie jar, so this class is one
12-/// -- deliberately minimal, since the server sets only a handful of cookies on
13-/// a single origin.
14-class GleanSession {
15- GleanSession({required this.baseUrl, http.Client? client})
16- : _client = client ?? http.Client();
17-
18- final String baseUrl;
19- final http.Client _client;
20- final Map<String, String> _cookies = {};
21-
22- static const _prefsKey = 'glean_cookies';
23- static const _csrfCookie = 'glean_csrf';
24-
25- bool get hasSession => _cookies.isNotEmpty;
26- String? get csrfToken => _cookies[_csrfCookie];
27-
28- /// Cookies survive restarts so a signed-in user stays signed in. They are
29- /// session credentials, so callers on mobile may prefer secure storage; this
30- /// uses shared_preferences to keep the dependency surface small.
31- Future<void> load() async {
32- final prefs = await SharedPreferences.getInstance();
33- final raw = prefs.getStringList(_prefsKey) ?? const [];
34- for (final entry in raw) {
35- final i = entry.indexOf('=');
36- if (i > 0) _cookies[entry.substring(0, i)] = entry.substring(i + 1);
37- }
38- }
39-
40- Future<void> _persist() async {
41- final prefs = await SharedPreferences.getInstance();
42- await prefs.setStringList(
43- _prefsKey,
44- _cookies.entries.map((e) => '${e.key}=${e.value}').toList(),
45- );
46- }
47-
48- Future<void> clear() async {
49- _cookies.clear();
50- await _persist();
51- }
52-
53- /// Adopt cookies captured elsewhere -- notably the webview that completes the
54- /// ATProto OAuth redirect, which is where the session cookie is first issued.
55- Future<void> adopt(Map<String, String> cookies) async {
56- _cookies.addAll(cookies);
57- await _persist();
58- }
59-
60- Map<String, String> _headers({bool unsafe = false, Map<String, String>? extra}) {
61- final h = <String, String>{'Accept': 'application/json'};
62- if (_cookies.isNotEmpty) {
63- h['Cookie'] = _cookies.entries.map((e) => '${e.key}=${e.value}').join('; ');
64- }
65- // Only unsafe methods are CSRF-checked; sending it on GET is harmless but
66- // noisy, and mirrors what the web client does.
67- if (unsafe && csrfToken != null) h['X-CSRF-Token'] = csrfToken!;
68- if (extra != null) h.addAll(extra);
69- return h;
70- }
71-
72- void _absorb(http.BaseResponse res) {
73- final raw = res.headers['set-cookie'];
74- if (raw == null) return;
75- for (final c in _splitSetCookie(raw)) {
76- try {
77- final cookie = Cookie.fromSetCookieValue(c);
78- // An expiry in the past is the server deleting the cookie (logout).
79- final expired = cookie.expires != null && cookie.expires!.isBefore(DateTime.now());
80- if (cookie.value.isEmpty || expired) {
81- _cookies.remove(cookie.name);
82- } else {
83- _cookies[cookie.name] = cookie.value;
84- }
85- } on Exception {
86- // A malformed Set-Cookie should not fail the request it rode in on.
87- continue;
88- }
89- }
90- _persist();
91- }
92-
93- /// Dart folds repeated Set-Cookie headers into one comma-joined string, but
94- /// commas also appear inside `Expires=Wed, 21 Oct 2026 ...`. Split only on
95- /// commas that begin a new `name=` pair.
96- static List<String> _splitSetCookie(String header) {
97- final parts = <String>[];
98- var start = 0;
99- for (var i = 0; i < header.length; i++) {
100- if (header[i] != ',') continue;
101- final rest = header.substring(i + 1);
102- final eq = rest.indexOf('=');
103- final semi = rest.indexOf(';');
104- if (eq > 0 && (semi < 0 || eq < semi) && !rest.substring(0, eq).contains(' ')) {
105- parts.add(header.substring(start, i).trim());
106- start = i + 1;
107- }
108- }
109- parts.add(header.substring(start).trim());
110- return parts.where((p) => p.isNotEmpty).toList();
111- }
112-
113- Uri _uri(String path, [Map<String, dynamic>? query]) {
114- final q = query?.entries
115- .where((e) => e.value != null && '${e.value}'.isNotEmpty)
116- .map((e) => MapEntry(e.key, '${e.value}'));
117- return Uri.parse('$baseUrl$path').replace(
118- queryParameters: q == null || q.isEmpty ? null : Map.fromEntries(q),
119- );
120- }
121-
122- Future<http.Response> get(String path, [Map<String, dynamic>? query]) async {
123- final res = await _client.get(_uri(path, query), headers: _headers());
124- _absorb(res);
125- return res;
126- }
127-
128- Future<http.Response> send(
129- String method,
130- String path, {
131- Map<String, dynamic>? query,
132- Map<String, String>? form,
133- }) async {
134- final req = http.Request(method, _uri(path, query))
135- ..headers.addAll(_headers(
136- unsafe: true,
137- extra: form == null ? null : {'Content-Type': 'application/x-www-form-urlencoded'},
138- ));
139- if (form != null) req.bodyFields = form;
140- final res = await http.Response.fromStream(await _client.send(req));
141- _absorb(res);
142- return res;
143- }
144-
145- /// Multipart POST, for the endpoints that take a file rather than form
146- /// fields (OPML import uses r.FormFile).
147- Future<http.Response> sendFile(
148- String path, {
149- required String field,
150- required String filename,
151- required List<int> bytes,
152- }) async {
153- final req = http.MultipartRequest('POST', _uri(path))
154- ..headers.addAll(_headers(unsafe: true))
155- ..files.add(http.MultipartFile.fromBytes(field, bytes, filename: filename));
156- final res = await http.Response.fromStream(await _client.send(req));
157- _absorb(res);
158- return res;
159- }
160-
161- void close() => _client.close();
162-}
modified app/lib/src/app_state.dart +50 -72
@@ -1,124 +1,102 @@
11 import 'package:flutter/widgets.dart';
22
33 import 'api/client.dart';
4-import 'api/responses.dart';
5-import 'api/session.dart';
64 import 'models/models.dart';
75
8-/// Default server. Override at build time:
9-/// flutter run --dart-define=GLEAN_BASE_URL=http://10.0.2.2:3000
6+/// Where the reader is. Override at build time:
7+/// flutter run --dart-define=GLEAN_BASE_URL=http://10.0.2.2:8080
8+///
9+/// 10.0.2.2 is how the Android emulator reaches the host's loopback; a real
10+/// device needs the machine's LAN address, and the server has no auth, so
11+/// only do that on a network you trust.
1012 const kDefaultBaseUrl = String.fromEnvironment(
1113 'GLEAN_BASE_URL',
12- defaultValue: 'https://codegod100--glean-serve.modal.run',
14+ defaultValue: 'http://127.0.0.1:8080',
1315 );
1416
15-/// Whole-app session state: who is signed in, and the client everything else
16-/// talks through.
17+/// Feed list and unread counts, shared across screens.
1718 ///
18-/// Deliberately a plain ChangeNotifier rather than a state-management package
19-/// -- the app has exactly one piece of global state (the session) and every
20-/// screen otherwise owns its own fetch.
19+/// Articles are not held here: each screen fetches its own, because the
20+/// filters differ and a shared list would be wrong for whichever screen did
21+/// not set them.
2122 class AppState extends ChangeNotifier {
22- /// [session] exists so tests can inject a GleanSession backed by a mock
23- /// http.Client; production callers only pass a base URL.
24- AppState({String baseUrl = kDefaultBaseUrl, GleanSession? session})
25- : session = session ?? GleanSession(baseUrl: baseUrl) {
26- client = GleanClient(this.session);
27- }
23+ AppState({String baseUrl = kDefaultBaseUrl, GleanClient? client})
24+ : client = client ?? GleanClient(baseUrl: baseUrl);
2825
29- final GleanSession session;
30- late final GleanClient client;
26+ final GleanClient client;
3127
32- String get baseUrl => session.baseUrl;
33-
34- User? _user;
28+ List<Feed> _feeds = const [];
29+ int _unread = 0;
3530 bool _loading = true;
36- bool _hasLlm = false;
37- bool _oauthConfigured = false;
3831 String? _error;
3932
40- User? get user => _user;
41- bool get signedIn => _user != null;
33+ List<Feed> get feeds => _feeds;
34+ int get unread => _unread;
4235 bool get loading => _loading;
43- bool get hasLlm => _hasLlm;
44-
45- /// False when the server has no GLEAN_OAUTH_CLIENT_ID, in which case sign-in
46- /// cannot complete and the login screen should say so rather than failing
47- /// halfway through a webview.
48- bool get oauthConfigured => _oauthConfigured;
4936 String? get error => _error;
5037
51- /// Restore any persisted cookies, then ask the server who we are.
52- Future<void> bootstrap() async {
53- await session.load();
54- await refreshUser();
55- }
38+ /// The feed list with an "all feeds" row in front, which is what the
39+ /// sidebar shows and the server does not send.
40+ List<Feed> get sidebar => [Feed.all(_unread), ..._feeds];
5641
57- Future<void> refreshUser() async {
42+ Future<void> load() async {
5843 _loading = true;
5944 notifyListeners();
6045 try {
61- final me = await client.me();
62- _apply(me);
46+ _feeds = await client.feeds();
47+ _unread = await client.unreadCount();
6348 _error = null;
6449 } on ApiException catch (e) {
65- _user = null;
6650 _error = e.message;
6751 } on Exception {
68- _user = null;
69- _error = 'Could not reach the server.';
52+ _error = 'Could not reach the reader.';
7053 } finally {
7154 _loading = false;
7255 notifyListeners();
7356 }
7457 }
7558
76- void _apply(MeResponse me) {
77- _user = me.user;
78- _hasLlm = me.hasLlm;
79- _oauthConfigured = me.oauthConfigured;
59+ Future<void> addFeed(String url) async {
60+ await client.addFeed(url);
61+ await load();
8062 }
8163
82- /// Adopt cookies captured by the OAuth webview and re-check the session.
83- Future<void> completeSignIn(Map<String, String> cookies) async {
84- await session.adopt(cookies);
85- await refreshUser();
64+ Future<void> removeFeed(String feedUrl) async {
65+ await client.removeFeed(feedUrl);
66+ await load();
8667 }
8768
88- Future<void> signOut() async {
89- try {
90- await client.logout();
91- } on Exception {
92- // A failed logout call still means the local session should go.
93- }
94- await session.clear();
95- _user = null;
69+ Future<RefreshResult> refresh() async {
70+ final result = await client.refresh();
71+ await load();
72+ return result;
73+ }
74+
75+ Future<void> markAllRead({String feedUrl = ''}) async {
76+ await client.markAllRead(feedUrl: feedUrl);
77+ await load();
78+ }
79+
80+ /// Nudge the counts after a single article changes, without a round trip.
81+ void adjustUnread(int delta) {
82+ _unread = (_unread + delta).clamp(0, 1 << 30);
9683 notifyListeners();
9784 }
9885
9986 @override
10087 void dispose() {
101- session.close();
88+ client.close();
10289 super.dispose();
10390 }
10491 }
10592
106-/// Makes [AppState] available to the widget tree and rebuilds dependents when
107-/// it changes.
10893 class AppScope extends InheritedNotifier<AppState> {
10994 const AppScope({super.key, required AppState state, required super.child})
11095 : super(notifier: state);
11196
112- static AppState of(BuildContext context) {
113- final scope = context.dependOnInheritedWidgetOfExactType<AppScope>();
114- assert(scope != null, 'No AppScope above this widget.');
115- return scope!.notifier!;
116- }
97+ static AppState of(BuildContext context) =>
98+ context.dependOnInheritedWidgetOfExactType<AppScope>()!.notifier!;
11799
118- /// For callbacks that need the state but should not subscribe to it.
119- static AppState read(BuildContext context) {
120- final scope = context.getInheritedWidgetOfExactType<AppScope>();
121- assert(scope != null, 'No AppScope above this widget.');
122- return scope!.notifier!;
123- }
100+ static AppState read(BuildContext context) =>
101+ context.getInheritedWidgetOfExactType<AppScope>()!.notifier!;
124102 }
@@ -1,124 +1,102 @@
1 import 'package:flutter/widgets.dart';1 import 'package:flutter/widgets.dart';
2 2
3 import 'api/client.dart';3 import 'api/client.dart';
4-import 'api/responses.dart';
5-import 'api/session.dart';
6 import 'models/models.dart';4 import 'models/models.dart';
7 5
8-/// Default server. Override at build time:6+/// Where the reader is. Override at build time:
9-/// flutter run --dart-define=GLEAN_BASE_URL=http://10.0.2.2:30007+/// flutter run --dart-define=GLEAN_BASE_URL=http://10.0.2.2:8080
8+///
9+/// 10.0.2.2 is how the Android emulator reaches the host's loopback; a real
10+/// device needs the machine's LAN address, and the server has no auth, so
11+/// only do that on a network you trust.
10 const kDefaultBaseUrl = String.fromEnvironment(12 const kDefaultBaseUrl = String.fromEnvironment(
11 'GLEAN_BASE_URL',13 'GLEAN_BASE_URL',
12- defaultValue: 'https://codegod100--glean-serve.modal.run',14+ defaultValue: 'http://127.0.0.1:8080',
13 );15 );
14 16
15-/// Whole-app session state: who is signed in, and the client everything else17+/// Feed list and unread counts, shared across screens.
16-/// talks through.
17 ///18 ///
18-/// Deliberately a plain ChangeNotifier rather than a state-management package19+/// Articles are not held here: each screen fetches its own, because the
19-/// -- the app has exactly one piece of global state (the session) and every20+/// filters differ and a shared list would be wrong for whichever screen did
20-/// screen otherwise owns its own fetch.21+/// not set them.
21 class AppState extends ChangeNotifier {22 class AppState extends ChangeNotifier {
22- /// [session] exists so tests can inject a GleanSession backed by a mock23+ AppState({String baseUrl = kDefaultBaseUrl, GleanClient? client})
23- /// http.Client; production callers only pass a base URL.24+ : client = client ?? GleanClient(baseUrl: baseUrl);
24- AppState({String baseUrl = kDefaultBaseUrl, GleanSession? session})
25- : session = session ?? GleanSession(baseUrl: baseUrl) {
26- client = GleanClient(this.session);
27- }
28 25
29- final GleanSession session;26+ final GleanClient client;
30- late final GleanClient client;
31 27
32- String get baseUrl => session.baseUrl;28+ List<Feed> _feeds = const [];
33-29+ int _unread = 0;
34- User? _user;
35 bool _loading = true;30 bool _loading = true;
36- bool _hasLlm = false;
37- bool _oauthConfigured = false;
38 String? _error;31 String? _error;
39 32
40- User? get user => _user;33+ List<Feed> get feeds => _feeds;
41- bool get signedIn => _user != null;34+ int get unread => _unread;
42 bool get loading => _loading;35 bool get loading => _loading;
43- bool get hasLlm => _hasLlm;
44-
45- /// False when the server has no GLEAN_OAUTH_CLIENT_ID, in which case sign-in
46- /// cannot complete and the login screen should say so rather than failing
47- /// halfway through a webview.
48- bool get oauthConfigured => _oauthConfigured;
49 String? get error => _error;36 String? get error => _error;
50 37
51- /// Restore any persisted cookies, then ask the server who we are.38+ /// The feed list with an "all feeds" row in front, which is what the
52- Future<void> bootstrap() async {39+ /// sidebar shows and the server does not send.
53- await session.load();40+ List<Feed> get sidebar => [Feed.all(_unread), ..._feeds];
54- await refreshUser();
55- }
56 41
57- Future<void> refreshUser() async {42+ Future<void> load() async {
58 _loading = true;43 _loading = true;
59 notifyListeners();44 notifyListeners();
60 try {45 try {
61- final me = await client.me();46+ _feeds = await client.feeds();
62- _apply(me);47+ _unread = await client.unreadCount();
63 _error = null;48 _error = null;
64 } on ApiException catch (e) {49 } on ApiException catch (e) {
65- _user = null;
66 _error = e.message;50 _error = e.message;
67 } on Exception {51 } on Exception {
68- _user = null;52+ _error = 'Could not reach the reader.';
69- _error = 'Could not reach the server.';
70 } finally {53 } finally {
71 _loading = false;54 _loading = false;
72 notifyListeners();55 notifyListeners();
73 }56 }
74 }57 }
75 58
76- void _apply(MeResponse me) {59+ Future<void> addFeed(String url) async {
77- _user = me.user;60+ await client.addFeed(url);
78- _hasLlm = me.hasLlm;61+ await load();
79- _oauthConfigured = me.oauthConfigured;
80 }62 }
81 63
82- /// Adopt cookies captured by the OAuth webview and re-check the session.64+ Future<void> removeFeed(String feedUrl) async {
83- Future<void> completeSignIn(Map<String, String> cookies) async {65+ await client.removeFeed(feedUrl);
84- await session.adopt(cookies);66+ await load();
85- await refreshUser();
86 }67 }
87 68
88- Future<void> signOut() async {69+ Future<RefreshResult> refresh() async {
89- try {70+ final result = await client.refresh();
90- await client.logout();71+ await load();
91- } on Exception {72+ return result;
92- // A failed logout call still means the local session should go.73+ }
93- }74+
94- await session.clear();75+ Future<void> markAllRead({String feedUrl = ''}) async {
95- _user = null;76+ await client.markAllRead(feedUrl: feedUrl);
77+ await load();
78+ }
79+
80+ /// Nudge the counts after a single article changes, without a round trip.
81+ void adjustUnread(int delta) {
82+ _unread = (_unread + delta).clamp(0, 1 << 30);
96 notifyListeners();83 notifyListeners();
97 }84 }
98 85
99 @override86 @override
100 void dispose() {87 void dispose() {
101- session.close();88+ client.close();
102 super.dispose();89 super.dispose();
103 }90 }
104 }91 }
105 92
106-/// Makes [AppState] available to the widget tree and rebuilds dependents when
107-/// it changes.
108 class AppScope extends InheritedNotifier<AppState> {93 class AppScope extends InheritedNotifier<AppState> {
109 const AppScope({super.key, required AppState state, required super.child})94 const AppScope({super.key, required AppState state, required super.child})
110 : super(notifier: state);95 : super(notifier: state);
111 96
112- static AppState of(BuildContext context) {97+ static AppState of(BuildContext context) =>
113- final scope = context.dependOnInheritedWidgetOfExactType<AppScope>();98+ context.dependOnInheritedWidgetOfExactType<AppScope>()!.notifier!;
114- assert(scope != null, 'No AppScope above this widget.');
115- return scope!.notifier!;
116- }
117 99
118- /// For callbacks that need the state but should not subscribe to it.100+ static AppState read(BuildContext context) =>
119- static AppState read(BuildContext context) {101+ context.getInheritedWidgetOfExactType<AppScope>()!.notifier!;
120- final scope = context.getInheritedWidgetOfExactType<AppScope>();
121- assert(scope != null, 'No AppScope above this widget.');
122- return scope!.notifier!;
123- }
124 }102 }
modified app/lib/src/models/models.dart +34 -329
@@ -1,127 +1,66 @@
1-/// Dart mirrors of the wire types in internal/server/api.go.
1+/// The two shapes the reader's API returns.
22 ///
3-/// Field names and nullability follow the Go structs exactly: a Go `*time.Time`
4-/// is a nullable DateTime here, and a `string` that the server coerces from
5-/// sql.NullString is a non-null String defaulting to "". Keeping the two in
6-/// step matters more than Dart-idiomatic naming, so the JSON keys are the
7-/// source of truth.
3+/// Deliberately small. The server has no accounts, no pagination envelopes
4+/// and no social records, so there is nothing here but articles and the
5+/// feeds they came from.
86 library;
97
10-DateTime? _time(Object? v) =>
11- v == null ? null : DateTime.tryParse(v as String)?.toLocal();
12-
138 String _str(Object? v) => (v as String?) ?? '';
149 int _int(Object? v) => (v as num?)?.toInt() ?? 0;
15-double _dbl(Object? v) => (v as num?)?.toDouble() ?? 0;
1610 bool _bool(Object? v) => (v as bool?) ?? false;
1711
18-List<String> _strList(Object? v) =>
19- (v as List?)?.map((e) => e as String).toList() ?? const [];
20-
21-List<T> _list<T>(Object? v, T Function(Map<String, dynamic>) f) =>
22- (v as List?)?.map((e) => f(e as Map<String, dynamic>)).toList() ?? <T>[];
23-
24-class User {
25- const User({
26- required this.did,
27- required this.handle,
28- required this.displayName,
29- required this.avatarUrl,
30- });
31-
32- final String did;
33- final String handle;
34- final String displayName;
35- final String avatarUrl;
36-
37- /// The handle is the stable identity; display name is optional decoration.
38- String get label => displayName.isNotEmpty ? displayName : handle;
39-
40- static const empty = User(did: '', handle: '', displayName: '', avatarUrl: '');
41-
42- factory User.fromJson(Map<String, dynamic> j) => User(
43- did: _str(j['did']),
44- handle: _str(j['handle']),
45- displayName: _str(j['display_name']),
46- avatarUrl: _str(j['avatar_url']),
47- );
48-}
49-
5012 class Article {
5113 const Article({
5214 required this.id,
5315 required this.feedUrl,
5416 required this.feedTitle,
55- required this.feedFaviconUrl,
5617 required this.title,
5718 required this.url,
5819 required this.author,
5920 required this.summary,
6021 required this.content,
61- required this.fullContent,
6222 required this.published,
63- required this.updated,
6423 required this.isRead,
65- required this.likeCount,
66- required this.hasLiked,
6724 });
6825
6926 final int id;
7027 final String feedUrl;
7128 final String feedTitle;
72- final String feedFaviconUrl;
7329 final String title;
7430 final String url;
7531 final String author;
7632 final String summary;
33+
34+ /// Sanitised HTML. The server runs every body through a whitelist before
35+ /// sending it, whether it was scraped or came straight from the feed.
7736 final String content;
78- final String fullContent;
7937 final DateTime? published;
80- final DateTime? updated;
8138 final bool isRead;
82- final int likeCount;
83- final bool hasLiked;
84-
85- /// What the reader view shows, preferring scraped full text over the feed's
86- /// own (often truncated) content.
87- String get readable =>
88- fullContent.isNotEmpty ? fullContent : (content.isNotEmpty ? content : summary);
8939
9040 factory Article.fromJson(Map<String, dynamic> j) => Article(
9141 id: _int(j['id']),
9242 feedUrl: _str(j['feed_url']),
9343 feedTitle: _str(j['feed_title']),
94- feedFaviconUrl: _str(j['feed_favicon_url']),
9544 title: _str(j['title']),
9645 url: _str(j['url']),
9746 author: _str(j['author']),
9847 summary: _str(j['summary']),
9948 content: _str(j['content']),
100- fullContent: _str(j['full_content']),
101- published: _time(j['published']),
102- updated: _time(j['updated']),
49+ published: DateTime.tryParse(_str(j['published']))?.toLocal(),
10350 isRead: _bool(j['is_read']),
104- likeCount: _int(j['like_count']),
105- hasLiked: _bool(j['has_liked']),
10651 );
10752
108- Article copyWith({bool? isRead, bool? hasLiked, int? likeCount, String? fullContent}) =>
109- Article(
53+ Article copyWith({bool? isRead, String? content}) => Article(
11054 id: id,
11155 feedUrl: feedUrl,
11256 feedTitle: feedTitle,
113- feedFaviconUrl: feedFaviconUrl,
11457 title: title,
11558 url: url,
11659 author: author,
11760 summary: summary,
118- content: content,
119- fullContent: fullContent ?? this.fullContent,
61+ content: content ?? this.content,
12062 published: published,
121- updated: updated,
12263 isRead: isRead ?? this.isRead,
123- likeCount: likeCount ?? this.likeCount,
124- hasLiked: hasLiked ?? this.hasLiked,
12564 );
12665 }
12766
@@ -129,282 +68,48 @@ class Feed {
12968 const Feed({
13069 required this.feedUrl,
13170 required this.title,
132- required this.siteUrl,
133- required this.description,
134- required this.feedType,
135- required this.faviconUrl,
136- required this.subscriberCount,
137- required this.errorCount,
138- required this.lastError,
139- required this.lastFetchedAt,
140- });
141-
142- final String feedUrl;
143- final String title;
144- final String siteUrl;
145- final String description;
146- final String feedType;
147- final String faviconUrl;
148- final int subscriberCount;
149- final int errorCount;
150- final String lastError;
151- final DateTime? lastFetchedAt;
152-
153- factory Feed.fromJson(Map<String, dynamic> j) => Feed(
154- feedUrl: _str(j['feed_url']),
155- title: _str(j['title']),
156- siteUrl: _str(j['site_url']),
157- description: _str(j['description']),
158- feedType: _str(j['feed_type']),
159- faviconUrl: _str(j['favicon_url']),
160- subscriberCount: _int(j['subscriber_count']),
161- errorCount: _int(j['error_count']),
162- lastError: _str(j['last_error']),
163- lastFetchedAt: _time(j['last_fetched_at']),
164- );
165-}
166-
167-class Subscription {
168- const Subscription({
169- required this.id,
170- required this.feedUrl,
171- required this.feedTitle,
17271 required this.category,
173- required this.addedAt,
174- required this.unreadCount,
72+ required this.unread,
17573 required this.faviconUrl,
17674 });
17775
178- final int id;
17976 final String feedUrl;
180- final String feedTitle;
181- final String category;
182- final DateTime? addedAt;
183- final int unreadCount;
184- final String faviconUrl;
185-
186- factory Subscription.fromJson(Map<String, dynamic> j) => Subscription(
187- id: _int(j['id']),
188- feedUrl: _str(j['feed_url']),
189- feedTitle: _str(j['feed_title']),
190- category: _str(j['category']),
191- addedAt: _time(j['added_at']),
192- unreadCount: _int(j['unread_count']),
193- faviconUrl: _str(j['favicon_url']),
194- );
195-}
196-
197-class Annotation {
198- const Annotation({
199- required this.id,
200- required this.authorDid,
201- required this.authorHandle,
202- required this.feedUrl,
203- required this.articleUrl,
204- required this.articleId,
205- required this.quote,
206- required this.note,
207- required this.tags,
208- required this.rating,
209- required this.createdAt,
210- });
211-
212- final int id;
213- final String authorDid;
214- final String authorHandle;
215- final String feedUrl;
216- final String articleUrl;
217- final int? articleId;
218- final String quote;
219- final String note;
220- final List<String> tags;
221- final int? rating;
222- final DateTime? createdAt;
223-
224- factory Annotation.fromJson(Map<String, dynamic> j) => Annotation(
225- id: _int(j['id']),
226- authorDid: _str(j['author_did']),
227- authorHandle: _str(j['author_handle']),
228- feedUrl: _str(j['feed_url']),
229- articleUrl: _str(j['article_url']),
230- articleId: (j['article_id'] as num?)?.toInt(),
231- quote: _str(j['quote']),
232- note: _str(j['note']),
233- tags: _strList(j['tags']),
234- rating: (j['rating'] as num?)?.toInt(),
235- createdAt: _time(j['created_at']),
236- );
237-}
238-
239-class TrendingItem {
240- const TrendingItem({
241- required this.articleId,
242- required this.title,
243- required this.url,
244- required this.author,
245- required this.summary,
246- required this.feedUrl,
247- required this.feedTitle,
248- required this.faviconUrl,
249- required this.likeCount,
250- required this.annotationCount,
251- required this.hasLiked,
252- });
253-
254- final int articleId;
25577 final String title;
256- final String url;
257- final String author;
258- final String summary;
259- final String feedUrl;
260- final String feedTitle;
78+ final String category;
79+ final int unread;
26180 final String faviconUrl;
262- final int likeCount;
263- final int annotationCount;
264- final bool hasLiked;
26581
266- factory TrendingItem.fromJson(Map<String, dynamic> j) => TrendingItem(
267- articleId: _int(j['article_id']),
268- title: _str(j['title']),
269- url: _str(j['url']),
270- author: _str(j['author']),
271- summary: _str(j['summary']),
272- feedUrl: _str(j['feed_url']),
273- feedTitle: _str(j['feed_title']),
274- faviconUrl: _str(j['favicon_url']),
275- likeCount: _int(j['like_count']),
276- annotationCount: _int(j['annotation_count']),
277- hasLiked: _bool(j['has_liked']),
278- );
279-
280- TrendingItem copyWith({bool? hasLiked, int? likeCount}) => TrendingItem(
281- articleId: articleId,
282- title: title,
283- url: url,
284- author: author,
285- summary: summary,
286- feedUrl: feedUrl,
287- feedTitle: feedTitle,
288- faviconUrl: faviconUrl,
289- likeCount: likeCount ?? this.likeCount,
290- annotationCount: annotationCount,
291- hasLiked: hasLiked ?? this.hasLiked,
82+ /// The "all feeds" row, which the server does not send.
83+ factory Feed.all(int unread) => Feed(
84+ feedUrl: '',
85+ title: 'All feeds',
86+ category: '',
87+ unread: unread,
88+ faviconUrl: '',
29289 );
293-}
294-
295-class FeedRecommendation {
296- const FeedRecommendation({
297- required this.feedUrl,
298- required this.title,
299- required this.siteUrl,
300- required this.description,
301- required this.subscriberCount,
302- required this.faviconUrl,
303- required this.score,
304- });
30590
306- final String feedUrl;
307- final String title;
308- final String siteUrl;
309- final String description;
310- final int subscriberCount;
311- final String faviconUrl;
312- final double score;
91+ bool get isAll => feedUrl.isEmpty;
31392
314- factory FeedRecommendation.fromJson(Map<String, dynamic> j) => FeedRecommendation(
93+ factory Feed.fromJson(Map<String, dynamic> j) => Feed(
31594 feedUrl: _str(j['feed_url']),
31695 title: _str(j['title']),
317- siteUrl: _str(j['site_url']),
318- description: _str(j['description']),
319- subscriberCount: _int(j['subscriber_count']),
96+ category: _str(j['category']),
97+ unread: _int(j['unread']),
32098 faviconUrl: _str(j['favicon_url']),
321- score: _dbl(j['score']),
32299 );
323100 }
324101
325-class PersonRecommendation {
326- const PersonRecommendation({
327- required this.did,
328- required this.handle,
329- required this.displayName,
330- required this.avatarUrl,
331- required this.commonFeeds,
332- required this.commonLikes,
333- required this.commonTags,
334- required this.isFollowed,
335- required this.score,
336- });
337-
338- final String did;
339- final String handle;
340- final String displayName;
341- final String avatarUrl;
342- final int commonFeeds;
343- final int commonLikes;
344- final int commonTags;
345- final bool isFollowed;
346- final double score;
102+/// What a refresh reports back: how many arrived, and which feeds failed.
103+class RefreshResult {
104+ const RefreshResult({required this.added, required this.errors});
347105
348- String get label => displayName.isNotEmpty ? displayName : handle;
106+ final int added;
107+ final List<String> errors;
349108
350- factory PersonRecommendation.fromJson(Map<String, dynamic> j) => PersonRecommendation(
351- did: _str(j['did']),
352- handle: _str(j['handle']),
353- displayName: _str(j['display_name']),
354- avatarUrl: _str(j['avatar_url']),
355- commonFeeds: _int(j['common_feeds']),
356- commonLikes: _int(j['common_likes']),
357- commonTags: _int(j['common_tags']),
358- isFollowed: _bool(j['is_followed']),
359- score: _dbl(j['score']),
109+ factory RefreshResult.fromJson(Map<String, dynamic> j) => RefreshResult(
110+ added: _int(j['added']),
111+ errors: ((j['errors'] as List?) ?? const [])
112+ .map((e) => _str((e as Map<String, dynamic>)['feed_url']))
113+ .toList(),
360114 );
361115 }
362-
363-class Pagination {
364- const Pagination({
365- required this.page,
366- required this.pageSize,
367- required this.hasPrev,
368- required this.hasNext,
369- required this.prevPage,
370- required this.nextPage,
371- });
372-
373- final int page;
374- final int pageSize;
375- final bool hasPrev;
376- final bool hasNext;
377- final int prevPage;
378- final int nextPage;
379-
380- static const first =
381- Pagination(page: 1, pageSize: 25, hasPrev: false, hasNext: false, prevPage: 0, nextPage: 0);
382-
383- factory Pagination.fromJson(Map<String, dynamic>? j) => j == null
384- ? first
385- : Pagination(
386- page: _int(j['page']),
387- pageSize: _int(j['page_size']),
388- hasPrev: _bool(j['has_prev']),
389- hasNext: _bool(j['has_next']),
390- prevPage: _int(j['prev_page']),
391- nextPage: _int(j['next_page']),
392- );
393-}
394-
395-class Language {
396- const Language({required this.code, required this.name});
397-
398- final String code;
399- final String name;
400-
401- factory Language.fromJson(Map<String, dynamic> j) =>
402- Language(code: _str(j['code']), name: _str(j['name']));
403-}
404-
405-/// Helpers re-exported for the response envelopes in api/responses.dart.
406-List<T> jsonList<T>(Object? v, T Function(Map<String, dynamic>) f) => _list(v, f);
407-List<String> jsonStrList(Object? v) => _strList(v);
408-String jsonStr(Object? v) => _str(v);
409-int jsonInt(Object? v) => _int(v);
410-bool jsonBool(Object? v) => _bool(v);
@@ -1,127 +1,66 @@
1-/// Dart mirrors of the wire types in internal/server/api.go.1+/// The two shapes the reader's API returns.
2 ///2 ///
3-/// Field names and nullability follow the Go structs exactly: a Go `*time.Time`3+/// Deliberately small. The server has no accounts, no pagination envelopes
4-/// is a nullable DateTime here, and a `string` that the server coerces from4+/// and no social records, so there is nothing here but articles and the
5-/// sql.NullString is a non-null String defaulting to "". Keeping the two in5+/// feeds they came from.
6-/// step matters more than Dart-idiomatic naming, so the JSON keys are the
7-/// source of truth.
8 library;6 library;
9 7
10-DateTime? _time(Object? v) =>
11- v == null ? null : DateTime.tryParse(v as String)?.toLocal();
12-
13 String _str(Object? v) => (v as String?) ?? '';8 String _str(Object? v) => (v as String?) ?? '';
14 int _int(Object? v) => (v as num?)?.toInt() ?? 0;9 int _int(Object? v) => (v as num?)?.toInt() ?? 0;
15-double _dbl(Object? v) => (v as num?)?.toDouble() ?? 0;
16 bool _bool(Object? v) => (v as bool?) ?? false;10 bool _bool(Object? v) => (v as bool?) ?? false;
17 11
18-List<String> _strList(Object? v) =>
19- (v as List?)?.map((e) => e as String).toList() ?? const [];
20-
21-List<T> _list<T>(Object? v, T Function(Map<String, dynamic>) f) =>
22- (v as List?)?.map((e) => f(e as Map<String, dynamic>)).toList() ?? <T>[];
23-
24-class User {
25- const User({
26- required this.did,
27- required this.handle,
28- required this.displayName,
29- required this.avatarUrl,
30- });
31-
32- final String did;
33- final String handle;
34- final String displayName;
35- final String avatarUrl;
36-
37- /// The handle is the stable identity; display name is optional decoration.
38- String get label => displayName.isNotEmpty ? displayName : handle;
39-
40- static const empty = User(did: '', handle: '', displayName: '', avatarUrl: '');
41-
42- factory User.fromJson(Map<String, dynamic> j) => User(
43- did: _str(j['did']),
44- handle: _str(j['handle']),
45- displayName: _str(j['display_name']),
46- avatarUrl: _str(j['avatar_url']),
47- );
48-}
49-
50 class Article {12 class Article {
51 const Article({13 const Article({
52 required this.id,14 required this.id,
53 required this.feedUrl,15 required this.feedUrl,
54 required this.feedTitle,16 required this.feedTitle,
55- required this.feedFaviconUrl,
56 required this.title,17 required this.title,
57 required this.url,18 required this.url,
58 required this.author,19 required this.author,
59 required this.summary,20 required this.summary,
60 required this.content,21 required this.content,
61- required this.fullContent,
62 required this.published,22 required this.published,
63- required this.updated,
64 required this.isRead,23 required this.isRead,
65- required this.likeCount,
66- required this.hasLiked,
67 });24 });
68 25
69 final int id;26 final int id;
70 final String feedUrl;27 final String feedUrl;
71 final String feedTitle;28 final String feedTitle;
72- final String feedFaviconUrl;
73 final String title;29 final String title;
74 final String url;30 final String url;
75 final String author;31 final String author;
76 final String summary;32 final String summary;
33+
34+ /// Sanitised HTML. The server runs every body through a whitelist before
35+ /// sending it, whether it was scraped or came straight from the feed.
77 final String content;36 final String content;
78- final String fullContent;
79 final DateTime? published;37 final DateTime? published;
80- final DateTime? updated;
81 final bool isRead;38 final bool isRead;
82- final int likeCount;
83- final bool hasLiked;
84-
85- /// What the reader view shows, preferring scraped full text over the feed's
86- /// own (often truncated) content.
87- String get readable =>
88- fullContent.isNotEmpty ? fullContent : (content.isNotEmpty ? content : summary);
89 39
90 factory Article.fromJson(Map<String, dynamic> j) => Article(40 factory Article.fromJson(Map<String, dynamic> j) => Article(
91 id: _int(j['id']),41 id: _int(j['id']),
92 feedUrl: _str(j['feed_url']),42 feedUrl: _str(j['feed_url']),
93 feedTitle: _str(j['feed_title']),43 feedTitle: _str(j['feed_title']),
94- feedFaviconUrl: _str(j['feed_favicon_url']),
95 title: _str(j['title']),44 title: _str(j['title']),
96 url: _str(j['url']),45 url: _str(j['url']),
97 author: _str(j['author']),46 author: _str(j['author']),
98 summary: _str(j['summary']),47 summary: _str(j['summary']),
99 content: _str(j['content']),48 content: _str(j['content']),
100- fullContent: _str(j['full_content']),49+ published: DateTime.tryParse(_str(j['published']))?.toLocal(),
101- published: _time(j['published']),
102- updated: _time(j['updated']),
103 isRead: _bool(j['is_read']),50 isRead: _bool(j['is_read']),
104- likeCount: _int(j['like_count']),
105- hasLiked: _bool(j['has_liked']),
106 );51 );
107 52
108- Article copyWith({bool? isRead, bool? hasLiked, int? likeCount, String? fullContent}) =>53+ Article copyWith({bool? isRead, String? content}) => Article(
109- Article(
110 id: id,54 id: id,
111 feedUrl: feedUrl,55 feedUrl: feedUrl,
112 feedTitle: feedTitle,56 feedTitle: feedTitle,
113- feedFaviconUrl: feedFaviconUrl,
114 title: title,57 title: title,
115 url: url,58 url: url,
116 author: author,59 author: author,
117 summary: summary,60 summary: summary,
118- content: content,61+ content: content ?? this.content,
119- fullContent: fullContent ?? this.fullContent,
120 published: published,62 published: published,
121- updated: updated,
122 isRead: isRead ?? this.isRead,63 isRead: isRead ?? this.isRead,
123- likeCount: likeCount ?? this.likeCount,
124- hasLiked: hasLiked ?? this.hasLiked,
125 );64 );
126 }65 }
127 66
@@ -129,282 +68,48 @@ class Feed {
129 const Feed({68 const Feed({
130 required this.feedUrl,69 required this.feedUrl,
131 required this.title,70 required this.title,
132- required this.siteUrl,
133- required this.description,
134- required this.feedType,
135- required this.faviconUrl,
136- required this.subscriberCount,
137- required this.errorCount,
138- required this.lastError,
139- required this.lastFetchedAt,
140- });
141-
142- final String feedUrl;
143- final String title;
144- final String siteUrl;
145- final String description;
146- final String feedType;
147- final String faviconUrl;
148- final int subscriberCount;
149- final int errorCount;
150- final String lastError;
151- final DateTime? lastFetchedAt;
152-
153- factory Feed.fromJson(Map<String, dynamic> j) => Feed(
154- feedUrl: _str(j['feed_url']),
155- title: _str(j['title']),
156- siteUrl: _str(j['site_url']),
157- description: _str(j['description']),
158- feedType: _str(j['feed_type']),
159- faviconUrl: _str(j['favicon_url']),
160- subscriberCount: _int(j['subscriber_count']),
161- errorCount: _int(j['error_count']),
162- lastError: _str(j['last_error']),
163- lastFetchedAt: _time(j['last_fetched_at']),
164- );
165-}
166-
167-class Subscription {
168- const Subscription({
169- required this.id,
170- required this.feedUrl,
171- required this.feedTitle,
172 required this.category,71 required this.category,
173- required this.addedAt,72+ required this.unread,
174- required this.unreadCount,
175 required this.faviconUrl,73 required this.faviconUrl,
176 });74 });
177 75
178- final int id;
179 final String feedUrl;76 final String feedUrl;
180- final String feedTitle;
181- final String category;
182- final DateTime? addedAt;
183- final int unreadCount;
184- final String faviconUrl;
185-
186- factory Subscription.fromJson(Map<String, dynamic> j) => Subscription(
187- id: _int(j['id']),
188- feedUrl: _str(j['feed_url']),
189- feedTitle: _str(j['feed_title']),
190- category: _str(j['category']),
191- addedAt: _time(j['added_at']),
192- unreadCount: _int(j['unread_count']),
193- faviconUrl: _str(j['favicon_url']),
194- );
195-}
196-
197-class Annotation {
198- const Annotation({
199- required this.id,
200- required this.authorDid,
201- required this.authorHandle,
202- required this.feedUrl,
203- required this.articleUrl,
204- required this.articleId,
205- required this.quote,
206- required this.note,
207- required this.tags,
208- required this.rating,
209- required this.createdAt,
210- });
211-
212- final int id;
213- final String authorDid;
214- final String authorHandle;
215- final String feedUrl;
216- final String articleUrl;
217- final int? articleId;
218- final String quote;
219- final String note;
220- final List<String> tags;
221- final int? rating;
222- final DateTime? createdAt;
223-
224- factory Annotation.fromJson(Map<String, dynamic> j) => Annotation(
225- id: _int(j['id']),
226- authorDid: _str(j['author_did']),
227- authorHandle: _str(j['author_handle']),
228- feedUrl: _str(j['feed_url']),
229- articleUrl: _str(j['article_url']),
230- articleId: (j['article_id'] as num?)?.toInt(),
231- quote: _str(j['quote']),
232- note: _str(j['note']),
233- tags: _strList(j['tags']),
234- rating: (j['rating'] as num?)?.toInt(),
235- createdAt: _time(j['created_at']),
236- );
237-}
238-
239-class TrendingItem {
240- const TrendingItem({
241- required this.articleId,
242- required this.title,
243- required this.url,
244- required this.author,
245- required this.summary,
246- required this.feedUrl,
247- required this.feedTitle,
248- required this.faviconUrl,
249- required this.likeCount,
250- required this.annotationCount,
251- required this.hasLiked,
252- });
253-
254- final int articleId;
255 final String title;77 final String title;
256- final String url;78+ final String category;
257- final String author;79+ final int unread;
258- final String summary;
259- final String feedUrl;
260- final String feedTitle;
261 final String faviconUrl;80 final String faviconUrl;
262- final int likeCount;
263- final int annotationCount;
264- final bool hasLiked;
265 81
266- factory TrendingItem.fromJson(Map<String, dynamic> j) => TrendingItem(82+ /// The "all feeds" row, which the server does not send.
267- articleId: _int(j['article_id']),83+ factory Feed.all(int unread) => Feed(
268- title: _str(j['title']),84+ feedUrl: '',
269- url: _str(j['url']),85+ title: 'All feeds',
270- author: _str(j['author']),86+ category: '',
271- summary: _str(j['summary']),87+ unread: unread,
272- feedUrl: _str(j['feed_url']),88+ faviconUrl: '',
273- feedTitle: _str(j['feed_title']),
274- faviconUrl: _str(j['favicon_url']),
275- likeCount: _int(j['like_count']),
276- annotationCount: _int(j['annotation_count']),
277- hasLiked: _bool(j['has_liked']),
278- );
279-
280- TrendingItem copyWith({bool? hasLiked, int? likeCount}) => TrendingItem(
281- articleId: articleId,
282- title: title,
283- url: url,
284- author: author,
285- summary: summary,
286- feedUrl: feedUrl,
287- feedTitle: feedTitle,
288- faviconUrl: faviconUrl,
289- likeCount: likeCount ?? this.likeCount,
290- annotationCount: annotationCount,
291- hasLiked: hasLiked ?? this.hasLiked,
292 );89 );
293-}
294-
295-class FeedRecommendation {
296- const FeedRecommendation({
297- required this.feedUrl,
298- required this.title,
299- required this.siteUrl,
300- required this.description,
301- required this.subscriberCount,
302- required this.faviconUrl,
303- required this.score,
304- });
305 90
306- final String feedUrl;91+ bool get isAll => feedUrl.isEmpty;
307- final String title;
308- final String siteUrl;
309- final String description;
310- final int subscriberCount;
311- final String faviconUrl;
312- final double score;
313 92
314- factory FeedRecommendation.fromJson(Map<String, dynamic> j) => FeedRecommendation(93+ factory Feed.fromJson(Map<String, dynamic> j) => Feed(
315 feedUrl: _str(j['feed_url']),94 feedUrl: _str(j['feed_url']),
316 title: _str(j['title']),95 title: _str(j['title']),
317- siteUrl: _str(j['site_url']),96+ category: _str(j['category']),
318- description: _str(j['description']),97+ unread: _int(j['unread']),
319- subscriberCount: _int(j['subscriber_count']),
320 faviconUrl: _str(j['favicon_url']),98 faviconUrl: _str(j['favicon_url']),
321- score: _dbl(j['score']),
322 );99 );
323 }100 }
324 101
325-class PersonRecommendation {102+/// What a refresh reports back: how many arrived, and which feeds failed.
326- const PersonRecommendation({103+class RefreshResult {
327- required this.did,104+ const RefreshResult({required this.added, required this.errors});
328- required this.handle,
329- required this.displayName,
330- required this.avatarUrl,
331- required this.commonFeeds,
332- required this.commonLikes,
333- required this.commonTags,
334- required this.isFollowed,
335- required this.score,
336- });
337-
338- final String did;
339- final String handle;
340- final String displayName;
341- final String avatarUrl;
342- final int commonFeeds;
343- final int commonLikes;
344- final int commonTags;
345- final bool isFollowed;
346- final double score;
347 105
348- String get label => displayName.isNotEmpty ? displayName : handle;106+ final int added;
107+ final List<String> errors;
349 108
350- factory PersonRecommendation.fromJson(Map<String, dynamic> j) => PersonRecommendation(109+ factory RefreshResult.fromJson(Map<String, dynamic> j) => RefreshResult(
351- did: _str(j['did']),110+ added: _int(j['added']),
352- handle: _str(j['handle']),111+ errors: ((j['errors'] as List?) ?? const [])
353- displayName: _str(j['display_name']),112+ .map((e) => _str((e as Map<String, dynamic>)['feed_url']))
354- avatarUrl: _str(j['avatar_url']),113+ .toList(),
355- commonFeeds: _int(j['common_feeds']),
356- commonLikes: _int(j['common_likes']),
357- commonTags: _int(j['common_tags']),
358- isFollowed: _bool(j['is_followed']),
359- score: _dbl(j['score']),
360 );114 );
361 }115 }
362-
363-class Pagination {
364- const Pagination({
365- required this.page,
366- required this.pageSize,
367- required this.hasPrev,
368- required this.hasNext,
369- required this.prevPage,
370- required this.nextPage,
371- });
372-
373- final int page;
374- final int pageSize;
375- final bool hasPrev;
376- final bool hasNext;
377- final int prevPage;
378- final int nextPage;
379-
380- static const first =
381- Pagination(page: 1, pageSize: 25, hasPrev: false, hasNext: false, prevPage: 0, nextPage: 0);
382-
383- factory Pagination.fromJson(Map<String, dynamic>? j) => j == null
384- ? first
385- : Pagination(
386- page: _int(j['page']),
387- pageSize: _int(j['page_size']),
388- hasPrev: _bool(j['has_prev']),
389- hasNext: _bool(j['has_next']),
390- prevPage: _int(j['prev_page']),
391- nextPage: _int(j['next_page']),
392- );
393-}
394-
395-class Language {
396- const Language({required this.code, required this.name});
397-
398- final String code;
399- final String name;
400-
401- factory Language.fromJson(Map<String, dynamic> j) =>
402- Language(code: _str(j['code']), name: _str(j['name']));
403-}
404-
405-/// Helpers re-exported for the response envelopes in api/responses.dart.
406-List<T> jsonList<T>(Object? v, T Function(Map<String, dynamic>) f) => _list(v, f);
407-List<String> jsonStrList(Object? v) => _strList(v);
408-String jsonStr(Object? v) => _str(v);
409-int jsonInt(Object? v) => _int(v);
410-bool jsonBool(Object? v) => _bool(v);
modified app/lib/src/screens/article_screen.dart +37 -128
@@ -1,21 +1,14 @@
1-import 'dart:async';
2-
31 import 'package:flutter/material.dart';
42 import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart';
53 import 'package:url_launcher/url_launcher.dart';
64
75 import '../api/client.dart';
8-import '../api/responses.dart';
96 import '../app_state.dart';
107 import '../models/models.dart';
118 import '../theme.dart';
129 import '../widgets/common.dart';
1310
14-/// Reader view for one article, plus its annotations.
15-///
16-/// Feeds usually carry truncated HTML, so this offers the server's scraper
17-/// (/api/articles/{id}/fetch-content) rather than sending the reader to the
18-/// browser for the full text.
11+/// Reader view for one article.
1912 class ArticleScreen extends StatefulWidget {
2013 const ArticleScreen({super.key, required this.articleId});
2114
@@ -26,7 +19,7 @@ class ArticleScreen extends StatefulWidget {
2619 }
2720
2821 class _ArticleScreenState extends State<ArticleScreen> {
29- Future<ArticleDetailResponse>? _future;
22+ Future<Article>? _future;
3023 Article? _article;
3124 bool _fetching = false;
3225
@@ -38,49 +31,29 @@ class _ArticleScreenState extends State<ArticleScreen> {
3831
3932 void _load() {
4033 setState(() {
41- _article = null;
4234 _future = _fetch();
4335 });
4436 }
4537
46- Future<ArticleDetailResponse> _fetch() async {
38+ Future<Article> _fetch() async {
4739 final client = AppScope.read(context).client;
48- final detail = await client.article(widget.articleId);
49- _article = detail.article;
50- // Opening an article is what marks it read in the web app too. It is
51- // best-effort: failing to record it must not break the reader.
52- if (!detail.article.isRead) {
53- unawaited(client.markRead(widget.articleId).catchError((_) => false));
40+ final a = await client.article(widget.articleId);
41+ _article = a;
42+ // Opening is what marks it read, as in the web interface. Best-effort:
43+ // failing to record it must not break the reader.
44+ if (!a.isRead) {
45+ client.setRead(a.id).catchError((_) {});
5446 }
55- return detail;
47+ return a;
5648 }
5749
58- Future<void> _toggleLike() async {
59- final a = _article;
60- if (a == null) return;
61- final client = AppScope.read(context).client;
62- setState(() => _article = a.copyWith(
63- hasLiked: !a.hasLiked,
64- likeCount: a.likeCount + (a.hasLiked ? -1 : 1),
65- ));
66- try {
67- final r = await client.likeArticle(a.id);
68- if (mounted) {
69- setState(() => _article = a.copyWith(hasLiked: r.liked, likeCount: r.likeCount));
70- }
71- } on ApiException catch (e) {
72- if (!mounted) return;
73- setState(() => _article = a);
74- showToast(context, e.message);
75- }
76- }
77-
78- Future<void> _fetchFullContent() async {
50+ Future<void> _fetchFullText() async {
7951 setState(() => _fetching = true);
8052 try {
81- final full = await AppScope.read(context).client.fetchContent(widget.articleId);
53+ final text = await AppScope.read(context).client
54+ .fetchFullText(widget.articleId);
8255 if (!mounted) return;
83- setState(() => _article = _article?.copyWith(fullContent: full));
56+ setState(() => _article = _article?.copyWith(content: text));
8457 } on ApiException catch (e) {
8558 if (mounted) showToast(context, e.message);
8659 } finally {
@@ -88,10 +61,10 @@ class _ArticleScreenState extends State<ArticleScreen> {
8861 }
8962 }
9063
91- Future<void> _openOriginal(String url) async {
92- if (url.isEmpty) return;
64+ Future<void> _openOriginal() async {
65+ final url = _article?.url ?? '';
9366 final uri = Uri.tryParse(url);
94- if (uri == null) return;
67+ if (uri == null || url.isEmpty) return;
9568 if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) {
9669 if (mounted) showToast(context, 'Could not open the link.');
9770 }
@@ -103,32 +76,22 @@ class _ArticleScreenState extends State<ArticleScreen> {
10376 appBar: AppBar(
10477 title: const Text('Read'),
10578 actions: [
106- if (_article != null)
107- IconButton(
108- tooltip: _article!.hasLiked ? 'Unlike' : 'Like',
109- icon: Icon(
110- _article!.hasLiked ? Icons.favorite : Icons.favorite_border,
111- color: _article!.hasLiked ? GleanColors.of(context).danger : null,
112- ),
113- onPressed: _toggleLike,
114- ),
11579 IconButton(
11680 tooltip: 'Open original',
11781 icon: const Icon(Icons.open_in_new),
118- onPressed: () => _openOriginal(_article?.url ?? ''),
82+ onPressed: _openOriginal,
11983 ),
12084 ],
12185 ),
122- body: AsyncView<ArticleDetailResponse>(
86+ body: AsyncView<Article>(
12387 future: _future,
12488 onRetry: _load,
125- builder: (context, data) => _body(context, data),
89+ builder: (context, data) => _body(context, _article ?? data),
12690 ),
12791 );
12892 }
12993
130- Widget _body(BuildContext context, ArticleDetailResponse data) {
131- final a = _article ?? data.article;
94+ Widget _body(BuildContext context, Article a) {
13295 final text = Theme.of(context).textTheme;
13396 final c = GleanColors.of(context);
13497
@@ -137,14 +100,12 @@ class _ArticleScreenState extends State<ArticleScreen> {
137100 children: [
138101 Row(
139102 children: [
140- FaviconBadge(url: a.feedFaviconUrl, seed: a.feedTitle),
141- const SizedBox(width: 8),
142103 Expanded(child: Text(a.feedTitle, style: text.bodySmall)),
143104 Text(relativeTime(a.published), style: text.bodySmall),
144105 ],
145106 ),
146107 const SizedBox(height: 12),
147- Text(a.title, style: text.headlineSmall),
108+ Text(a.title.isEmpty ? '(untitled)' : a.title, style: text.headlineSmall),
148109 if (a.author.isNotEmpty) ...[
149110 const SizedBox(height: 6),
150111 Text(a.author, style: text.bodySmall),
@@ -152,83 +113,31 @@ class _ArticleScreenState extends State<ArticleScreen> {
152113 const SizedBox(height: 16),
153114 Divider(color: c.faint, height: 1),
154115 const SizedBox(height: 16),
155- if (a.readable.isEmpty)
116+ if (a.content.isEmpty)
156117 Text('No content in the feed.', style: text.bodySmall)
157118 else
119+ // Rendering HTML from someone else's server is only safe because
120+ // the reader sanitises every body it sends through the same
121+ // whitelist its scraper uses.
158122 HtmlWidget(
159- a.readable,
123+ a.content,
160124 textStyle: text.bodyLarge,
161125 onTapUrl: (url) async {
162- await _openOriginal(url);
126+ final uri = Uri.tryParse(url);
127+ if (uri != null) {
128+ await launchUrl(uri, mode: LaunchMode.externalApplication);
129+ }
163130 return true;
164131 },
165- customStylesBuilder: (element) => switch (element.localName) {
166- 'a' => {'color': _hex(c.accent), 'text-decoration': 'underline'},
167- 'blockquote' => {'border-left': '3px solid ${_hex(c.faint)}', 'padding-left': '12px'},
168- 'pre' || 'code' => {'font-size': '13px'},
169- _ => null,
170- },
171- ),
172- const SizedBox(height: 20),
173- if (a.fullContent.isEmpty)
174- GleanButton(
175- label: 'Fetch full article',
176- busy: _fetching,
177- onPressed: _fetchFullContent,
178132 ),
179- if (data.annotations.isNotEmpty) ...[
180- const SizedBox(height: 28),
181- Text('Annotations', style: text.titleMedium),
182- const SizedBox(height: 12),
183- for (final an in data.annotations) _AnnotationCard(annotation: an),
184- ],
133+ const SizedBox(height: 24),
134+ GleanButton(
135+ label: 'Fetch full text',
136+ busy: _fetching,
137+ onPressed: _fetchFullText,
138+ ),
185139 const SizedBox(height: 40),
186140 ],
187141 );
188142 }
189143 }
190-
191-class _AnnotationCard extends StatelessWidget {
192- const _AnnotationCard({required this.annotation});
193-
194- final Annotation annotation;
195-
196- @override
197- Widget build(BuildContext context) {
198- final text = Theme.of(context).textTheme;
199- return Padding(
200- padding: const EdgeInsets.only(bottom: 12),
201- child: GleanBox(
202- filled: true,
203- child: Column(
204- crossAxisAlignment: CrossAxisAlignment.start,
205- children: [
206- Text('@${annotation.authorHandle}', style: text.bodySmall),
207- if (annotation.quote.isNotEmpty) ...[
208- const SizedBox(height: 8),
209- Text('"${annotation.quote}"',
210- style: text.bodyMedium?.copyWith(fontStyle: FontStyle.italic)),
211- ],
212- if (annotation.note.isNotEmpty) ...[
213- const SizedBox(height: 8),
214- Text(annotation.note, style: text.bodyMedium),
215- ],
216- if (annotation.tags.isNotEmpty) ...[
217- const SizedBox(height: 8),
218- Wrap(
219- spacing: 6,
220- runSpacing: 6,
221- children: [for (final t in annotation.tags) GleanTag(t)],
222- ),
223- ],
224- ],
225- ),
226- ),
227- );
228- }
229-}
230-
231-/// flutter_widget_from_html takes CSS strings, so theme colors have to be
232-/// handed over as hex rather than as Color objects.
233-String _hex(Color c) =>
234- '#${((c.a * 255).round() << 24 | (c.r * 255).round() << 16 | (c.g * 255).round() << 8 | (c.b * 255).round()).toRadixString(16).padLeft(8, '0').substring(2)}';
@@ -1,21 +1,14 @@
1-import 'dart:async';
2-
3 import 'package:flutter/material.dart';1 import 'package:flutter/material.dart';
4 import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart';2 import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart';
5 import 'package:url_launcher/url_launcher.dart';3 import 'package:url_launcher/url_launcher.dart';
6 4
7 import '../api/client.dart';5 import '../api/client.dart';
8-import '../api/responses.dart';
9 import '../app_state.dart';6 import '../app_state.dart';
10 import '../models/models.dart';7 import '../models/models.dart';
11 import '../theme.dart';8 import '../theme.dart';
12 import '../widgets/common.dart';9 import '../widgets/common.dart';
13 10
14-/// Reader view for one article, plus its annotations.11+/// Reader view for one article.
15-///
16-/// Feeds usually carry truncated HTML, so this offers the server's scraper
17-/// (/api/articles/{id}/fetch-content) rather than sending the reader to the
18-/// browser for the full text.
19 class ArticleScreen extends StatefulWidget {12 class ArticleScreen extends StatefulWidget {
20 const ArticleScreen({super.key, required this.articleId});13 const ArticleScreen({super.key, required this.articleId});
21 14
@@ -26,7 +19,7 @@ class ArticleScreen extends StatefulWidget {
26 }19 }
27 20
28 class _ArticleScreenState extends State<ArticleScreen> {21 class _ArticleScreenState extends State<ArticleScreen> {
29- Future<ArticleDetailResponse>? _future;22+ Future<Article>? _future;
30 Article? _article;23 Article? _article;
31 bool _fetching = false;24 bool _fetching = false;
32 25
@@ -38,49 +31,29 @@ class _ArticleScreenState extends State<ArticleScreen> {
38 31
39 void _load() {32 void _load() {
40 setState(() {33 setState(() {
41- _article = null;
42 _future = _fetch();34 _future = _fetch();
43 });35 });
44 }36 }
45 37
46- Future<ArticleDetailResponse> _fetch() async {38+ Future<Article> _fetch() async {
47 final client = AppScope.read(context).client;39 final client = AppScope.read(context).client;
48- final detail = await client.article(widget.articleId);40+ final a = await client.article(widget.articleId);
49- _article = detail.article;41+ _article = a;
50- // Opening an article is what marks it read in the web app too. It is42+ // Opening is what marks it read, as in the web interface. Best-effort:
51- // best-effort: failing to record it must not break the reader.43+ // failing to record it must not break the reader.
52- if (!detail.article.isRead) {44+ if (!a.isRead) {
53- unawaited(client.markRead(widget.articleId).catchError((_) => false));45+ client.setRead(a.id).catchError((_) {});
54 }46 }
55- return detail;47+ return a;
56 }48 }
57 49
58- Future<void> _toggleLike() async {50+ Future<void> _fetchFullText() async {
59- final a = _article;
60- if (a == null) return;
61- final client = AppScope.read(context).client;
62- setState(() => _article = a.copyWith(
63- hasLiked: !a.hasLiked,
64- likeCount: a.likeCount + (a.hasLiked ? -1 : 1),
65- ));
66- try {
67- final r = await client.likeArticle(a.id);
68- if (mounted) {
69- setState(() => _article = a.copyWith(hasLiked: r.liked, likeCount: r.likeCount));
70- }
71- } on ApiException catch (e) {
72- if (!mounted) return;
73- setState(() => _article = a);
74- showToast(context, e.message);
75- }
76- }
77-
78- Future<void> _fetchFullContent() async {
79 setState(() => _fetching = true);51 setState(() => _fetching = true);
80 try {52 try {
81- final full = await AppScope.read(context).client.fetchContent(widget.articleId);53+ final text = await AppScope.read(context).client
54+ .fetchFullText(widget.articleId);
82 if (!mounted) return;55 if (!mounted) return;
83- setState(() => _article = _article?.copyWith(fullContent: full));56+ setState(() => _article = _article?.copyWith(content: text));
84 } on ApiException catch (e) {57 } on ApiException catch (e) {
85 if (mounted) showToast(context, e.message);58 if (mounted) showToast(context, e.message);
86 } finally {59 } finally {
@@ -88,10 +61,10 @@ class _ArticleScreenState extends State<ArticleScreen> {
88 }61 }
89 }62 }
90 63
91- Future<void> _openOriginal(String url) async {64+ Future<void> _openOriginal() async {
92- if (url.isEmpty) return;65+ final url = _article?.url ?? '';
93 final uri = Uri.tryParse(url);66 final uri = Uri.tryParse(url);
94- if (uri == null) return;67+ if (uri == null || url.isEmpty) return;
95 if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) {68 if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) {
96 if (mounted) showToast(context, 'Could not open the link.');69 if (mounted) showToast(context, 'Could not open the link.');
97 }70 }
@@ -103,32 +76,22 @@ class _ArticleScreenState extends State<ArticleScreen> {
103 appBar: AppBar(76 appBar: AppBar(
104 title: const Text('Read'),77 title: const Text('Read'),
105 actions: [78 actions: [
106- if (_article != null)
107- IconButton(
108- tooltip: _article!.hasLiked ? 'Unlike' : 'Like',
109- icon: Icon(
110- _article!.hasLiked ? Icons.favorite : Icons.favorite_border,
111- color: _article!.hasLiked ? GleanColors.of(context).danger : null,
112- ),
113- onPressed: _toggleLike,
114- ),
115 IconButton(79 IconButton(
116 tooltip: 'Open original',80 tooltip: 'Open original',
117 icon: const Icon(Icons.open_in_new),81 icon: const Icon(Icons.open_in_new),
118- onPressed: () => _openOriginal(_article?.url ?? ''),82+ onPressed: _openOriginal,
119 ),83 ),
120 ],84 ],
121 ),85 ),
122- body: AsyncView<ArticleDetailResponse>(86+ body: AsyncView<Article>(
123 future: _future,87 future: _future,
124 onRetry: _load,88 onRetry: _load,
125- builder: (context, data) => _body(context, data),89+ builder: (context, data) => _body(context, _article ?? data),
126 ),90 ),
127 );91 );
128 }92 }
129 93
130- Widget _body(BuildContext context, ArticleDetailResponse data) {94+ Widget _body(BuildContext context, Article a) {
131- final a = _article ?? data.article;
132 final text = Theme.of(context).textTheme;95 final text = Theme.of(context).textTheme;
133 final c = GleanColors.of(context);96 final c = GleanColors.of(context);
134 97
@@ -137,14 +100,12 @@ class _ArticleScreenState extends State<ArticleScreen> {
137 children: [100 children: [
138 Row(101 Row(
139 children: [102 children: [
140- FaviconBadge(url: a.feedFaviconUrl, seed: a.feedTitle),
141- const SizedBox(width: 8),
142 Expanded(child: Text(a.feedTitle, style: text.bodySmall)),103 Expanded(child: Text(a.feedTitle, style: text.bodySmall)),
143 Text(relativeTime(a.published), style: text.bodySmall),104 Text(relativeTime(a.published), style: text.bodySmall),
144 ],105 ],
145 ),106 ),
146 const SizedBox(height: 12),107 const SizedBox(height: 12),
147- Text(a.title, style: text.headlineSmall),108+ Text(a.title.isEmpty ? '(untitled)' : a.title, style: text.headlineSmall),
148 if (a.author.isNotEmpty) ...[109 if (a.author.isNotEmpty) ...[
149 const SizedBox(height: 6),110 const SizedBox(height: 6),
150 Text(a.author, style: text.bodySmall),111 Text(a.author, style: text.bodySmall),
@@ -152,83 +113,31 @@ class _ArticleScreenState extends State<ArticleScreen> {
152 const SizedBox(height: 16),113 const SizedBox(height: 16),
153 Divider(color: c.faint, height: 1),114 Divider(color: c.faint, height: 1),
154 const SizedBox(height: 16),115 const SizedBox(height: 16),
155- if (a.readable.isEmpty)116+ if (a.content.isEmpty)
156 Text('No content in the feed.', style: text.bodySmall)117 Text('No content in the feed.', style: text.bodySmall)
157 else118 else
119+ // Rendering HTML from someone else's server is only safe because
120+ // the reader sanitises every body it sends through the same
121+ // whitelist its scraper uses.
158 HtmlWidget(122 HtmlWidget(
159- a.readable,123+ a.content,
160 textStyle: text.bodyLarge,124 textStyle: text.bodyLarge,
161 onTapUrl: (url) async {125 onTapUrl: (url) async {
162- await _openOriginal(url);126+ final uri = Uri.tryParse(url);
127+ if (uri != null) {
128+ await launchUrl(uri, mode: LaunchMode.externalApplication);
129+ }
163 return true;130 return true;
164 },131 },
165- customStylesBuilder: (element) => switch (element.localName) {
166- 'a' => {'color': _hex(c.accent), 'text-decoration': 'underline'},
167- 'blockquote' => {'border-left': '3px solid ${_hex(c.faint)}', 'padding-left': '12px'},
168- 'pre' || 'code' => {'font-size': '13px'},
169- _ => null,
170- },
171- ),
172- const SizedBox(height: 20),
173- if (a.fullContent.isEmpty)
174- GleanButton(
175- label: 'Fetch full article',
176- busy: _fetching,
177- onPressed: _fetchFullContent,
178 ),132 ),
179- if (data.annotations.isNotEmpty) ...[133+ const SizedBox(height: 24),
180- const SizedBox(height: 28),134+ GleanButton(
181- Text('Annotations', style: text.titleMedium),135+ label: 'Fetch full text',
182- const SizedBox(height: 12),136+ busy: _fetching,
183- for (final an in data.annotations) _AnnotationCard(annotation: an),137+ onPressed: _fetchFullText,
184- ],138+ ),
185 const SizedBox(height: 40),139 const SizedBox(height: 40),
186 ],140 ],
187 );141 );
188 }142 }
189 }143 }
190-
191-class _AnnotationCard extends StatelessWidget {
192- const _AnnotationCard({required this.annotation});
193-
194- final Annotation annotation;
195-
196- @override
197- Widget build(BuildContext context) {
198- final text = Theme.of(context).textTheme;
199- return Padding(
200- padding: const EdgeInsets.only(bottom: 12),
201- child: GleanBox(
202- filled: true,
203- child: Column(
204- crossAxisAlignment: CrossAxisAlignment.start,
205- children: [
206- Text('@${annotation.authorHandle}', style: text.bodySmall),
207- if (annotation.quote.isNotEmpty) ...[
208- const SizedBox(height: 8),
209- Text('"${annotation.quote}"',
210- style: text.bodyMedium?.copyWith(fontStyle: FontStyle.italic)),
211- ],
212- if (annotation.note.isNotEmpty) ...[
213- const SizedBox(height: 8),
214- Text(annotation.note, style: text.bodyMedium),
215- ],
216- if (annotation.tags.isNotEmpty) ...[
217- const SizedBox(height: 8),
218- Wrap(
219- spacing: 6,
220- runSpacing: 6,
221- children: [for (final t in annotation.tags) GleanTag(t)],
222- ),
223- ],
224- ],
225- ),
226- ),
227- );
228- }
229-}
230-
231-/// flutter_widget_from_html takes CSS strings, so theme colors have to be
232-/// handed over as hex rather than as Color objects.
233-String _hex(Color c) =>
234- '#${((c.a * 255).round() << 24 | (c.r * 255).round() << 16 | (c.g * 255).round() << 8 | (c.b * 255).round()).toRadixString(16).padLeft(8, '0').substring(2)}';
modified app/lib/src/screens/articles_screen.dart +100 -222
@@ -1,7 +1,6 @@
11 import 'package:flutter/material.dart';
22
33 import '../api/client.dart';
4-import '../api/responses.dart';
54 import '../app_state.dart';
65 import '../models/models.dart';
76 import '../theme.dart';
@@ -9,31 +8,25 @@ import '../widgets/article_tile.dart';
98 import '../widgets/common.dart';
109 import 'article_screen.dart';
1110
12-/// The main reading list: /api/articles with the same filters the web app
13-/// exposes (status, search, category, sort, per-feed).
11+/// The reading list, scoped by the feed selected in the drawer.
1412 class ArticlesScreen extends StatefulWidget {
15- const ArticlesScreen({super.key, this.feedUrl, this.title});
13+ const ArticlesScreen({super.key, required this.feed});
1614
17- /// When set, the list is scoped to one feed.
18- final String? feedUrl;
19- final String? title;
15+ final Feed feed;
2016
2117 @override
2218 State<ArticlesScreen> createState() => _ArticlesScreenState();
2319 }
2420
2521 class _ArticlesScreenState extends State<ArticlesScreen> {
26- Future<ArticlesResponse>? _future;
22+ Future<List<Article>>? _future;
2723 final _searchController = TextEditingController();
2824
29- int _page = 1;
3025 String _status = 'unread';
31- String? _category;
3226 String _search = '';
33- bool _sortOldest = false;
3427
35- /// Local overrides for rows the user has just acted on, so a like or a
36- /// mark-read is reflected immediately without refetching the page.
28+ /// Rows the reader has just acted on, so a tap reflects immediately
29+ /// without refetching the list.
3730 final Map<int, Article> _patched = {};
3831
3932 @override
@@ -42,6 +35,12 @@ class _ArticlesScreenState extends State<ArticlesScreen> {
4235 _load();
4336 }
4437
38+ @override
39+ void didUpdateWidget(ArticlesScreen old) {
40+ super.didUpdateWidget(old);
41+ if (old.feed.feedUrl != widget.feed.feedUrl) _load();
42+ }
43+
4544 @override
4645 void dispose() {
4746 _searchController.dispose();
@@ -52,177 +51,107 @@ class _ArticlesScreenState extends State<ArticlesScreen> {
5251 setState(() {
5352 _patched.clear();
5453 _future = AppScope.read(context).client.articles(
55- page: _page,
56- feedUrl: widget.feedUrl,
5754 status: _status,
58- search: _search.isEmpty ? null : _search,
59- category: _category,
60- sortOldest: _sortOldest,
55+ feedUrl: widget.feed.feedUrl,
56+ search: _search,
6157 );
6258 });
6359 }
6460
65- void _goToPage(int page) {
66- _page = page;
67- _load();
68- }
69-
7061 Future<void> _toggleRead(Article a) async {
71- final client = AppScope.read(context).client;
72- // Optimistic: the row flips now, and reverts if the server disagrees.
62+ final app = AppScope.read(context);
7363 setState(() => _patched[a.id] = a.copyWith(isRead: !a.isRead));
64+ app.adjustUnread(a.isRead ? 1 : -1);
7465 try {
75- final isRead = a.isRead ? await client.markUnread(a.id) : await client.markRead(a.id);
76- if (mounted) setState(() => _patched[a.id] = a.copyWith(isRead: isRead));
66+ await app.client.setRead(a.id, read: !a.isRead);
7767 } on ApiException catch (e) {
7868 if (!mounted) return;
7969 setState(() => _patched[a.id] = a);
70+ app.adjustUnread(a.isRead ? -1 : 1);
8071 showToast(context, e.message);
8172 }
8273 }
8374
84- Future<void> _toggleLike(Article a) async {
85- final client = AppScope.read(context).client;
86- setState(() => _patched[a.id] = a.copyWith(
87- hasLiked: !a.hasLiked,
88- likeCount: a.likeCount + (a.hasLiked ? -1 : 1),
89- ));
90- try {
91- final r = await client.likeArticle(a.id);
92- if (mounted) {
93- setState(() => _patched[a.id] = a.copyWith(hasLiked: r.liked, likeCount: r.likeCount));
94- }
95- } on ApiException catch (e) {
96- if (!mounted) return;
97- setState(() => _patched[a.id] = a);
98- showToast(context, e.message);
99- }
100- }
101-
102- Future<void> _markAllRead() async {
103- final messenger = ScaffoldMessenger.of(context);
104- try {
105- await AppScope.read(context).client.markAllRead(
106- feedUrl: widget.feedUrl,
107- category: _category,
108- );
109- _load();
110- } on ApiException catch (e) {
111- messenger.showSnackBar(SnackBar(content: Text(e.message)));
112- }
113- }
114-
115- @override
116- Widget build(BuildContext context) {
117- return Scaffold(
118- appBar: AppBar(
119- title: Text(widget.title ?? 'Articles'),
120- actions: [
121- IconButton(
122- tooltip: 'Mark all read',
123- icon: const Icon(Icons.done_all),
124- onPressed: _markAllRead,
125- ),
126- IconButton(
127- tooltip: _sortOldest ? 'Newest first' : 'Oldest first',
128- icon: Icon(_sortOldest ? Icons.arrow_upward : Icons.arrow_downward),
129- onPressed: () {
130- _sortOldest = !_sortOldest;
131- _goToPage(1);
132- },
133- ),
134- ],
135- ),
136- body: Column(
137- children: [
138- _SearchBar(
139- controller: _searchController,
140- onSubmit: (q) {
141- _search = q;
142- _goToPage(1);
143- },
144- ),
145- _StatusFilter(
146- status: _status,
147- onChanged: (s) {
148- _status = s;
149- _goToPage(1);
150- },
151- ),
152- Expanded(
153- child: AsyncView<ArticlesResponse>(
154- future: _future,
155- onRetry: _load,
156- builder: (context, data) => _list(context, data),
157- ),
158- ),
159- ],
160- ),
75+ Future<void> _open(Article a) async {
76+ // Read the state up front: after the await the element may be gone, and
77+ // a lint about context across an async gap is pointing at a real one.
78+ final app = AppScope.read(context);
79+ await Navigator.of(context).push(
80+ MaterialPageRoute(builder: (_) => ArticleScreen(articleId: a.id)),
16181 );
82+ if (!mounted || a.isRead) return;
83+ // Opening marks it read server-side.
84+ setState(() => _patched[a.id] = a.copyWith(isRead: true));
85+ app.adjustUnread(-1);
16286 }
16387
164- Widget _list(BuildContext context, ArticlesResponse data) {
165- if (data.articles.isEmpty) {
166- return const EmptyView(message: 'Nothing here.\nTry a different filter.');
167- }
168- return RefreshIndicator(
169- onRefresh: () async => _load(),
170- child: ListView.builder(
171- itemCount: data.articles.length + 1,
172- itemBuilder: (context, i) {
173- if (i == data.articles.length) {
174- return _Pager(pagination: data.pagination, onPage: _goToPage);
175- }
176- final raw = data.articles[i];
177- final a = _patched[raw.id] ?? raw;
178- return ArticleTile(
179- article: a,
180- expanded: data.expandedView,
181- onToggleRead: () => _toggleRead(a),
182- onToggleLike: () => _toggleLike(a),
183- onTap: () async {
184- await Navigator.of(context).push(
185- MaterialPageRoute(builder: (_) => ArticleScreen(articleId: a.id)),
186- );
187- // Reading marks it read server-side; reflect that on return.
188- if (mounted) setState(() => _patched[a.id] = a.copyWith(isRead: true));
189- },
190- );
191- },
192- ),
193- );
194- }
195-}
196-
197-class _SearchBar extends StatelessWidget {
198- const _SearchBar({required this.controller, required this.onSubmit});
199-
200- final TextEditingController controller;
201- final ValueChanged<String> onSubmit;
202-
20388 @override
20489 Widget build(BuildContext context) {
205- return Padding(
206- padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
207- child: TextField(
208- controller: controller,
209- textInputAction: TextInputAction.search,
210- decoration: InputDecoration(
211- hintText: 'Search articles',
212- isDense: true,
213- prefixIcon: const Icon(Icons.search, size: 18),
214- suffixIcon: controller.text.isEmpty
215- ? null
216- : IconButton(
217- icon: const Icon(Icons.clear, size: 18),
218- onPressed: () {
219- controller.clear();
220- onSubmit('');
90+ return Column(
91+ children: [
92+ Padding(
93+ padding: const EdgeInsets.fromLTRB(12, 12, 12, 6),
94+ child: Row(
95+ children: [
96+ Expanded(
97+ child: TextField(
98+ controller: _searchController,
99+ textInputAction: TextInputAction.search,
100+ decoration: const InputDecoration(
101+ hintText: 'Search',
102+ isDense: true,
103+ prefixIcon: Icon(Icons.search, size: 18),
104+ ),
105+ onSubmitted: (v) {
106+ _search = v.trim();
107+ _load();
221108 },
222109 ),
110+ ),
111+ const SizedBox(width: 8),
112+ // Search spans everything, so the status filter is meaningless
113+ // while one is active.
114+ if (_search.isEmpty)
115+ _StatusFilter(
116+ status: _status,
117+ onChanged: (s) {
118+ _status = s;
119+ _load();
120+ },
121+ ),
122+ ],
123+ ),
223124 ),
224- onSubmitted: onSubmit,
225- ),
125+ Expanded(
126+ child: AsyncView<List<Article>>(
127+ future: _future,
128+ onRetry: _load,
129+ builder: (context, items) {
130+ if (items.isEmpty) {
131+ return EmptyView(
132+ message: _search.isNotEmpty
133+ ? 'Nothing matches "$_search".'
134+ : 'Nothing here.',
135+ );
136+ }
137+ return RefreshIndicator(
138+ onRefresh: () async => _load(),
139+ child: ListView.builder(
140+ itemCount: items.length,
141+ itemBuilder: (context, i) {
142+ final a = _patched[items[i].id] ?? items[i];
143+ return ArticleTile(
144+ article: a,
145+ onToggleRead: () => _toggleRead(a),
146+ onTap: () => _open(a),
147+ );
148+ },
149+ ),
150+ );
151+ },
152+ ),
153+ ),
154+ ],
226155 );
227156 }
228157 }
@@ -233,70 +162,19 @@ class _StatusFilter extends StatelessWidget {
233162 final String status;
234163 final ValueChanged<String> onChanged;
235164
236- // Mirrors the status values handleArticles accepts.
237- static const _options = {'unread': 'Unread', 'all': 'All', 'read': 'Read'};
238-
239165 @override
240166 Widget build(BuildContext context) {
241167 final c = GleanColors.of(context);
242- return SizedBox(
243- height: 40,
244- child: ListView(
245- scrollDirection: Axis.horizontal,
246- padding: const EdgeInsets.symmetric(horizontal: 12),
247- children: [
248- for (final e in _options.entries)
249- Padding(
250- padding: const EdgeInsets.only(right: 8),
251- child: GestureDetector(
252- onTap: () => onChanged(e.key),
253- child: Container(
254- alignment: Alignment.center,
255- padding: const EdgeInsets.symmetric(horizontal: 14),
256- decoration: BoxDecoration(
257- color: status == e.key ? c.accent : Colors.transparent,
258- border: Border.all(color: c.border, width: 2),
259- ),
260- child: Text(
261- e.value,
262- style: Theme.of(context).textTheme.labelLarge?.copyWith(
263- color: status == e.key ? c.accentInk : c.fg,
264- ),
265- ),
266- ),
267- ),
268- ),
269- ],
270- ),
271- );
272- }
273-}
274-
275-class _Pager extends StatelessWidget {
276- const _Pager({required this.pagination, required this.onPage});
277-
278- final Pagination pagination;
279- final ValueChanged<int> onPage;
280-
281- @override
282- Widget build(BuildContext context) {
283- if (!pagination.hasPrev && !pagination.hasNext) return const SizedBox(height: 24);
284- return Padding(
285- padding: const EdgeInsets.all(16),
286- child: Row(
287- mainAxisAlignment: MainAxisAlignment.spaceBetween,
288- children: [
289- GleanButton(
290- label: 'Prev',
291- onPressed: pagination.hasPrev ? () => onPage(pagination.prevPage) : null,
292- ),
293- Text('page ${pagination.page}', style: Theme.of(context).textTheme.bodySmall),
294- GleanButton(
295- label: 'Next',
296- onPressed: pagination.hasNext ? () => onPage(pagination.nextPage) : null,
297- ),
298- ],
299- ),
168+ return DropdownButton<String>(
169+ value: status,
170+ underline: const SizedBox.shrink(),
171+ style: Theme.of(context).textTheme.labelLarge?.copyWith(color: c.fg),
172+ onChanged: (v) => onChanged(v ?? 'unread'),
173+ items: const [
174+ DropdownMenuItem(value: 'unread', child: Text('Unread')),
175+ DropdownMenuItem(value: 'all', child: Text('All')),
176+ DropdownMenuItem(value: 'read', child: Text('Read')),
177+ ],
300178 );
301179 }
302180 }
@@ -1,7 +1,6 @@
1 import 'package:flutter/material.dart';1 import 'package:flutter/material.dart';
2 2
3 import '../api/client.dart';3 import '../api/client.dart';
4-import '../api/responses.dart';
5 import '../app_state.dart';4 import '../app_state.dart';
6 import '../models/models.dart';5 import '../models/models.dart';
7 import '../theme.dart';6 import '../theme.dart';
@@ -9,31 +8,25 @@ import '../widgets/article_tile.dart';
9 import '../widgets/common.dart';8 import '../widgets/common.dart';
10 import 'article_screen.dart';9 import 'article_screen.dart';
11 10
12-/// The main reading list: /api/articles with the same filters the web app11+/// The reading list, scoped by the feed selected in the drawer.
13-/// exposes (status, search, category, sort, per-feed).
14 class ArticlesScreen extends StatefulWidget {12 class ArticlesScreen extends StatefulWidget {
15- const ArticlesScreen({super.key, this.feedUrl, this.title});13+ const ArticlesScreen({super.key, required this.feed});
16 14
17- /// When set, the list is scoped to one feed.15+ final Feed feed;
18- final String? feedUrl;
19- final String? title;
20 16
21 @override17 @override
22 State<ArticlesScreen> createState() => _ArticlesScreenState();18 State<ArticlesScreen> createState() => _ArticlesScreenState();
23 }19 }
24 20
25 class _ArticlesScreenState extends State<ArticlesScreen> {21 class _ArticlesScreenState extends State<ArticlesScreen> {
26- Future<ArticlesResponse>? _future;22+ Future<List<Article>>? _future;
27 final _searchController = TextEditingController();23 final _searchController = TextEditingController();
28 24
29- int _page = 1;
30 String _status = 'unread';25 String _status = 'unread';
31- String? _category;
32 String _search = '';26 String _search = '';
33- bool _sortOldest = false;
34 27
35- /// Local overrides for rows the user has just acted on, so a like or a28+ /// Rows the reader has just acted on, so a tap reflects immediately
36- /// mark-read is reflected immediately without refetching the page.29+ /// without refetching the list.
37 final Map<int, Article> _patched = {};30 final Map<int, Article> _patched = {};
38 31
39 @override32 @override
@@ -42,6 +35,12 @@ class _ArticlesScreenState extends State<ArticlesScreen> {
42 _load();35 _load();
43 }36 }
44 37
38+ @override
39+ void didUpdateWidget(ArticlesScreen old) {
40+ super.didUpdateWidget(old);
41+ if (old.feed.feedUrl != widget.feed.feedUrl) _load();
42+ }
43+
45 @override44 @override
46 void dispose() {45 void dispose() {
47 _searchController.dispose();46 _searchController.dispose();
@@ -52,177 +51,107 @@ class _ArticlesScreenState extends State<ArticlesScreen> {
52 setState(() {51 setState(() {
53 _patched.clear();52 _patched.clear();
54 _future = AppScope.read(context).client.articles(53 _future = AppScope.read(context).client.articles(
55- page: _page,
56- feedUrl: widget.feedUrl,
57 status: _status,54 status: _status,
58- search: _search.isEmpty ? null : _search,55+ feedUrl: widget.feed.feedUrl,
59- category: _category,56+ search: _search,
60- sortOldest: _sortOldest,
61 );57 );
62 });58 });
63 }59 }
64 60
65- void _goToPage(int page) {
66- _page = page;
67- _load();
68- }
69-
70 Future<void> _toggleRead(Article a) async {61 Future<void> _toggleRead(Article a) async {
71- final client = AppScope.read(context).client;62+ final app = AppScope.read(context);
72- // Optimistic: the row flips now, and reverts if the server disagrees.
73 setState(() => _patched[a.id] = a.copyWith(isRead: !a.isRead));63 setState(() => _patched[a.id] = a.copyWith(isRead: !a.isRead));
64+ app.adjustUnread(a.isRead ? 1 : -1);
74 try {65 try {
75- final isRead = a.isRead ? await client.markUnread(a.id) : await client.markRead(a.id);66+ await app.client.setRead(a.id, read: !a.isRead);
76- if (mounted) setState(() => _patched[a.id] = a.copyWith(isRead: isRead));
77 } on ApiException catch (e) {67 } on ApiException catch (e) {
78 if (!mounted) return;68 if (!mounted) return;
79 setState(() => _patched[a.id] = a);69 setState(() => _patched[a.id] = a);
70+ app.adjustUnread(a.isRead ? -1 : 1);
80 showToast(context, e.message);71 showToast(context, e.message);
81 }72 }
82 }73 }
83 74
84- Future<void> _toggleLike(Article a) async {75+ Future<void> _open(Article a) async {
85- final client = AppScope.read(context).client;76+ // Read the state up front: after the await the element may be gone, and
86- setState(() => _patched[a.id] = a.copyWith(77+ // a lint about context across an async gap is pointing at a real one.
87- hasLiked: !a.hasLiked,78+ final app = AppScope.read(context);
88- likeCount: a.likeCount + (a.hasLiked ? -1 : 1),79+ await Navigator.of(context).push(
89- ));80+ MaterialPageRoute(builder: (_) => ArticleScreen(articleId: a.id)),
90- try {
91- final r = await client.likeArticle(a.id);
92- if (mounted) {
93- setState(() => _patched[a.id] = a.copyWith(hasLiked: r.liked, likeCount: r.likeCount));
94- }
95- } on ApiException catch (e) {
96- if (!mounted) return;
97- setState(() => _patched[a.id] = a);
98- showToast(context, e.message);
99- }
100- }
101-
102- Future<void> _markAllRead() async {
103- final messenger = ScaffoldMessenger.of(context);
104- try {
105- await AppScope.read(context).client.markAllRead(
106- feedUrl: widget.feedUrl,
107- category: _category,
108- );
109- _load();
110- } on ApiException catch (e) {
111- messenger.showSnackBar(SnackBar(content: Text(e.message)));
112- }
113- }
114-
115- @override
116- Widget build(BuildContext context) {
117- return Scaffold(
118- appBar: AppBar(
119- title: Text(widget.title ?? 'Articles'),
120- actions: [
121- IconButton(
122- tooltip: 'Mark all read',
123- icon: const Icon(Icons.done_all),
124- onPressed: _markAllRead,
125- ),
126- IconButton(
127- tooltip: _sortOldest ? 'Newest first' : 'Oldest first',
128- icon: Icon(_sortOldest ? Icons.arrow_upward : Icons.arrow_downward),
129- onPressed: () {
130- _sortOldest = !_sortOldest;
131- _goToPage(1);
132- },
133- ),
134- ],
135- ),
136- body: Column(
137- children: [
138- _SearchBar(
139- controller: _searchController,
140- onSubmit: (q) {
141- _search = q;
142- _goToPage(1);
143- },
144- ),
145- _StatusFilter(
146- status: _status,
147- onChanged: (s) {
148- _status = s;
149- _goToPage(1);
150- },
151- ),
152- Expanded(
153- child: AsyncView<ArticlesResponse>(
154- future: _future,
155- onRetry: _load,
156- builder: (context, data) => _list(context, data),
157- ),
158- ),
159- ],
160- ),
161 );81 );
82+ if (!mounted || a.isRead) return;
83+ // Opening marks it read server-side.
84+ setState(() => _patched[a.id] = a.copyWith(isRead: true));
85+ app.adjustUnread(-1);
162 }86 }
163 87
164- Widget _list(BuildContext context, ArticlesResponse data) {
165- if (data.articles.isEmpty) {
166- return const EmptyView(message: 'Nothing here.\nTry a different filter.');
167- }
168- return RefreshIndicator(
169- onRefresh: () async => _load(),
170- child: ListView.builder(
171- itemCount: data.articles.length + 1,
172- itemBuilder: (context, i) {
173- if (i == data.articles.length) {
174- return _Pager(pagination: data.pagination, onPage: _goToPage);
175- }
176- final raw = data.articles[i];
177- final a = _patched[raw.id] ?? raw;
178- return ArticleTile(
179- article: a,
180- expanded: data.expandedView,
181- onToggleRead: () => _toggleRead(a),
182- onToggleLike: () => _toggleLike(a),
183- onTap: () async {
184- await Navigator.of(context).push(
185- MaterialPageRoute(builder: (_) => ArticleScreen(articleId: a.id)),
186- );
187- // Reading marks it read server-side; reflect that on return.
188- if (mounted) setState(() => _patched[a.id] = a.copyWith(isRead: true));
189- },
190- );
191- },
192- ),
193- );
194- }
195-}
196-
197-class _SearchBar extends StatelessWidget {
198- const _SearchBar({required this.controller, required this.onSubmit});
199-
200- final TextEditingController controller;
201- final ValueChanged<String> onSubmit;
202-
203 @override88 @override
204 Widget build(BuildContext context) {89 Widget build(BuildContext context) {
205- return Padding(90+ return Column(
206- padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),91+ children: [
207- child: TextField(92+ Padding(
208- controller: controller,93+ padding: const EdgeInsets.fromLTRB(12, 12, 12, 6),
209- textInputAction: TextInputAction.search,94+ child: Row(
210- decoration: InputDecoration(95+ children: [
211- hintText: 'Search articles',96+ Expanded(
212- isDense: true,97+ child: TextField(
213- prefixIcon: const Icon(Icons.search, size: 18),98+ controller: _searchController,
214- suffixIcon: controller.text.isEmpty99+ textInputAction: TextInputAction.search,
215- ? null100+ decoration: const InputDecoration(
216- : IconButton(101+ hintText: 'Search',
217- icon: const Icon(Icons.clear, size: 18),102+ isDense: true,
218- onPressed: () {103+ prefixIcon: Icon(Icons.search, size: 18),
219- controller.clear();104+ ),
220- onSubmit('');105+ onSubmitted: (v) {
106+ _search = v.trim();
107+ _load();
221 },108 },
222 ),109 ),
110+ ),
111+ const SizedBox(width: 8),
112+ // Search spans everything, so the status filter is meaningless
113+ // while one is active.
114+ if (_search.isEmpty)
115+ _StatusFilter(
116+ status: _status,
117+ onChanged: (s) {
118+ _status = s;
119+ _load();
120+ },
121+ ),
122+ ],
123+ ),
223 ),124 ),
224- onSubmitted: onSubmit,125+ Expanded(
225- ),126+ child: AsyncView<List<Article>>(
127+ future: _future,
128+ onRetry: _load,
129+ builder: (context, items) {
130+ if (items.isEmpty) {
131+ return EmptyView(
132+ message: _search.isNotEmpty
133+ ? 'Nothing matches "$_search".'
134+ : 'Nothing here.',
135+ );
136+ }
137+ return RefreshIndicator(
138+ onRefresh: () async => _load(),
139+ child: ListView.builder(
140+ itemCount: items.length,
141+ itemBuilder: (context, i) {
142+ final a = _patched[items[i].id] ?? items[i];
143+ return ArticleTile(
144+ article: a,
145+ onToggleRead: () => _toggleRead(a),
146+ onTap: () => _open(a),
147+ );
148+ },
149+ ),
150+ );
151+ },
152+ ),
153+ ),
154+ ],
226 );155 );
227 }156 }
228 }157 }
@@ -233,70 +162,19 @@ class _StatusFilter extends StatelessWidget {
233 final String status;162 final String status;
234 final ValueChanged<String> onChanged;163 final ValueChanged<String> onChanged;
235 164
236- // Mirrors the status values handleArticles accepts.
237- static const _options = {'unread': 'Unread', 'all': 'All', 'read': 'Read'};
238-
239 @override165 @override
240 Widget build(BuildContext context) {166 Widget build(BuildContext context) {
241 final c = GleanColors.of(context);167 final c = GleanColors.of(context);
242- return SizedBox(168+ return DropdownButton<String>(
243- height: 40,169+ value: status,
244- child: ListView(170+ underline: const SizedBox.shrink(),
245- scrollDirection: Axis.horizontal,171+ style: Theme.of(context).textTheme.labelLarge?.copyWith(color: c.fg),
246- padding: const EdgeInsets.symmetric(horizontal: 12),172+ onChanged: (v) => onChanged(v ?? 'unread'),
247- children: [173+ items: const [
248- for (final e in _options.entries)174+ DropdownMenuItem(value: 'unread', child: Text('Unread')),
249- Padding(175+ DropdownMenuItem(value: 'all', child: Text('All')),
250- padding: const EdgeInsets.only(right: 8),176+ DropdownMenuItem(value: 'read', child: Text('Read')),
251- child: GestureDetector(177+ ],
252- onTap: () => onChanged(e.key),
253- child: Container(
254- alignment: Alignment.center,
255- padding: const EdgeInsets.symmetric(horizontal: 14),
256- decoration: BoxDecoration(
257- color: status == e.key ? c.accent : Colors.transparent,
258- border: Border.all(color: c.border, width: 2),
259- ),
260- child: Text(
261- e.value,
262- style: Theme.of(context).textTheme.labelLarge?.copyWith(
263- color: status == e.key ? c.accentInk : c.fg,
264- ),
265- ),
266- ),
267- ),
268- ),
269- ],
270- ),
271- );
272- }
273-}
274-
275-class _Pager extends StatelessWidget {
276- const _Pager({required this.pagination, required this.onPage});
277-
278- final Pagination pagination;
279- final ValueChanged<int> onPage;
280-
281- @override
282- Widget build(BuildContext context) {
283- if (!pagination.hasPrev && !pagination.hasNext) return const SizedBox(height: 24);
284- return Padding(
285- padding: const EdgeInsets.all(16),
286- child: Row(
287- mainAxisAlignment: MainAxisAlignment.spaceBetween,
288- children: [
289- GleanButton(
290- label: 'Prev',
291- onPressed: pagination.hasPrev ? () => onPage(pagination.prevPage) : null,
292- ),
293- Text('page ${pagination.page}', style: Theme.of(context).textTheme.bodySmall),
294- GleanButton(
295- label: 'Next',
296- onPressed: pagination.hasNext ? () => onPage(pagination.nextPage) : null,
297- ),
298- ],
299- ),
300 );178 );
301 }179 }
302 }180 }
deleted app/lib/src/screens/dashboard_screen.dart +0 -192
deleted file mode 100644
@@ -1,192 +0,0 @@
1-import 'package:flutter/material.dart';
2-
3-import '../api/responses.dart';
4-import '../app_state.dart';
5-import '../models/models.dart';
6-import '../widgets/article_tile.dart';
7-import '../widgets/common.dart';
8-import 'article_screen.dart';
9-
10-/// Signed-in landing screen: unread count, latest articles, and the two
11-/// trending rails the server computes (personal and global).
12-class DashboardScreen extends StatefulWidget {
13- const DashboardScreen({super.key});
14-
15- @override
16- State<DashboardScreen> createState() => _DashboardScreenState();
17-}
18-
19-class _DashboardScreenState extends State<DashboardScreen> {
20- Future<DashboardResponse>? _future;
21-
22- @override
23- void initState() {
24- super.initState();
25- _load();
26- }
27-
28- void _load() {
29- setState(() {
30- _future = AppScope.read(context).client.dashboard();
31- });
32- }
33-
34- @override
35- Widget build(BuildContext context) {
36- return AsyncView<DashboardResponse>(
37- future: _future,
38- onRetry: _load,
39- builder: (context, data) => RefreshIndicator(
40- onRefresh: () async => _load(),
41- child: ListView(
42- padding: const EdgeInsets.only(bottom: 32),
43- children: [
44- _Counts(data: data),
45- if (data.articles.isNotEmpty) ...[
46- const _SectionHeading('Latest'),
47- for (final a in data.articles)
48- ArticleTile(
49- article: a,
50- onTap: () => Navigator.of(context).push(
51- MaterialPageRoute(builder: (_) => ArticleScreen(articleId: a.id)),
52- ),
53- ),
54- ] else
55- const Padding(
56- padding: EdgeInsets.all(24),
57- child: EmptyView(message: 'No unread articles.\nSubscribe to a feed to get started.'),
58- ),
59- if (data.personalTrending.isNotEmpty) ...[
60- const _SectionHeading('Trending in your feeds'),
61- _TrendingRail(items: data.personalTrending),
62- ],
63- if (data.globalTrending.isNotEmpty) ...[
64- const _SectionHeading('Trending everywhere'),
65- _TrendingRail(items: data.globalTrending),
66- ],
67- ],
68- ),
69- ),
70- );
71- }
72-}
73-
74-class _Counts extends StatelessWidget {
75- const _Counts({required this.data});
76-
77- final DashboardResponse data;
78-
79- @override
80- Widget build(BuildContext context) {
81- return Padding(
82- padding: const EdgeInsets.all(16),
83- child: Row(
84- children: [
85- Expanded(child: _Stat(value: '${data.unreadCount}', label: 'unread')),
86- const SizedBox(width: 12),
87- Expanded(child: _Stat(value: '${data.subscriptionCount}', label: 'feeds')),
88- ],
89- ),
90- );
91- }
92-}
93-
94-class _Stat extends StatelessWidget {
95- const _Stat({required this.value, required this.label});
96-
97- final String value;
98- final String label;
99-
100- @override
101- Widget build(BuildContext context) {
102- final text = Theme.of(context).textTheme;
103- return GleanBox(
104- filled: true,
105- child: Column(
106- crossAxisAlignment: CrossAxisAlignment.start,
107- children: [
108- Text(value, style: text.displaySmall),
109- Text(label, style: text.bodySmall),
110- ],
111- ),
112- );
113- }
114-}
115-
116-class _SectionHeading extends StatelessWidget {
117- const _SectionHeading(this.label);
118-
119- final String label;
120-
121- @override
122- Widget build(BuildContext context) {
123- return Padding(
124- padding: const EdgeInsets.fromLTRB(16, 20, 16, 10),
125- child: Text(label, style: Theme.of(context).textTheme.titleMedium),
126- );
127- }
128-}
129-
130-/// Horizontal rail of trending articles.
131-class _TrendingRail extends StatelessWidget {
132- const _TrendingRail({required this.items});
133-
134- final List<TrendingItem> items;
135-
136- @override
137- Widget build(BuildContext context) {
138- final text = Theme.of(context).textTheme;
139- return SizedBox(
140- height: 168,
141- child: ListView.separated(
142- scrollDirection: Axis.horizontal,
143- padding: const EdgeInsets.symmetric(horizontal: 16),
144- itemCount: items.length,
145- separatorBuilder: (_, _) => const SizedBox(width: 12),
146- itemBuilder: (context, i) {
147- final t = items[i];
148- return SizedBox(
149- width: 240,
150- child: GleanBox(
151- onTap: () => Navigator.of(context).push(
152- MaterialPageRoute(builder: (_) => ArticleScreen(articleId: t.articleId)),
153- ),
154- child: Column(
155- crossAxisAlignment: CrossAxisAlignment.start,
156- children: [
157- Row(
158- children: [
159- FaviconBadge(url: t.faviconUrl, seed: t.feedTitle, size: 14),
160- const SizedBox(width: 6),
161- Expanded(
162- child: Text(t.feedTitle,
163- maxLines: 1, overflow: TextOverflow.ellipsis, style: text.bodySmall),
164- ),
165- ],
166- ),
167- const SizedBox(height: 8),
168- Expanded(
169- child: Text(
170- t.title,
171- maxLines: 4,
172- overflow: TextOverflow.ellipsis,
173- style: text.bodyMedium?.copyWith(fontWeight: FontWeight.w700),
174- ),
175- ),
176- const SizedBox(height: 6),
177- Row(
178- children: [
179- GleanTag('${t.likeCount} likes'),
180- const SizedBox(width: 6),
181- if (t.annotationCount > 0) GleanTag('${t.annotationCount} notes'),
182- ],
183- ),
184- ],
185- ),
186- ),
187- );
188- },
189- ),
190- );
191- }
192-}
deleted file mode 100644
@@ -1,192 +0,0 @@
1-import 'package:flutter/material.dart';
2-
3-import '../api/responses.dart';
4-import '../app_state.dart';
5-import '../models/models.dart';
6-import '../widgets/article_tile.dart';
7-import '../widgets/common.dart';
8-import 'article_screen.dart';
9-
10-/// Signed-in landing screen: unread count, latest articles, and the two
11-/// trending rails the server computes (personal and global).
12-class DashboardScreen extends StatefulWidget {
13- const DashboardScreen({super.key});
14-
15- @override
16- State<DashboardScreen> createState() => _DashboardScreenState();
17-}
18-
19-class _DashboardScreenState extends State<DashboardScreen> {
20- Future<DashboardResponse>? _future;
21-
22- @override
23- void initState() {
24- super.initState();
25- _load();
26- }
27-
28- void _load() {
29- setState(() {
30- _future = AppScope.read(context).client.dashboard();
31- });
32- }
33-
34- @override
35- Widget build(BuildContext context) {
36- return AsyncView<DashboardResponse>(
37- future: _future,
38- onRetry: _load,
39- builder: (context, data) => RefreshIndicator(
40- onRefresh: () async => _load(),
41- child: ListView(
42- padding: const EdgeInsets.only(bottom: 32),
43- children: [
44- _Counts(data: data),
45- if (data.articles.isNotEmpty) ...[
46- const _SectionHeading('Latest'),
47- for (final a in data.articles)
48- ArticleTile(
49- article: a,
50- onTap: () => Navigator.of(context).push(
51- MaterialPageRoute(builder: (_) => ArticleScreen(articleId: a.id)),
52- ),
53- ),
54- ] else
55- const Padding(
56- padding: EdgeInsets.all(24),
57- child: EmptyView(message: 'No unread articles.\nSubscribe to a feed to get started.'),
58- ),
59- if (data.personalTrending.isNotEmpty) ...[
60- const _SectionHeading('Trending in your feeds'),
61- _TrendingRail(items: data.personalTrending),
62- ],
63- if (data.globalTrending.isNotEmpty) ...[
64- const _SectionHeading('Trending everywhere'),
65- _TrendingRail(items: data.globalTrending),
66- ],
67- ],
68- ),
69- ),
70- );
71- }
72-}
73-
74-class _Counts extends StatelessWidget {
75- const _Counts({required this.data});
76-
77- final DashboardResponse data;
78-
79- @override
80- Widget build(BuildContext context) {
81- return Padding(
82- padding: const EdgeInsets.all(16),
83- child: Row(
84- children: [
85- Expanded(child: _Stat(value: '${data.unreadCount}', label: 'unread')),
86- const SizedBox(width: 12),
87- Expanded(child: _Stat(value: '${data.subscriptionCount}', label: 'feeds')),
88- ],
89- ),
90- );
91- }
92-}
93-
94-class _Stat extends StatelessWidget {
95- const _Stat({required this.value, required this.label});
96-
97- final String value;
98- final String label;
99-
100- @override
101- Widget build(BuildContext context) {
102- final text = Theme.of(context).textTheme;
103- return GleanBox(
104- filled: true,
105- child: Column(
106- crossAxisAlignment: CrossAxisAlignment.start,
107- children: [
108- Text(value, style: text.displaySmall),
109- Text(label, style: text.bodySmall),
110- ],
111- ),
112- );
113- }
114-}
115-
116-class _SectionHeading extends StatelessWidget {
117- const _SectionHeading(this.label);
118-
119- final String label;
120-
121- @override
122- Widget build(BuildContext context) {
123- return Padding(
124- padding: const EdgeInsets.fromLTRB(16, 20, 16, 10),
125- child: Text(label, style: Theme.of(context).textTheme.titleMedium),
126- );
127- }
128-}
129-
130-/// Horizontal rail of trending articles.
131-class _TrendingRail extends StatelessWidget {
132- const _TrendingRail({required this.items});
133-
134- final List<TrendingItem> items;
135-
136- @override
137- Widget build(BuildContext context) {
138- final text = Theme.of(context).textTheme;
139- return SizedBox(
140- height: 168,
141- child: ListView.separated(
142- scrollDirection: Axis.horizontal,
143- padding: const EdgeInsets.symmetric(horizontal: 16),
144- itemCount: items.length,
145- separatorBuilder: (_, _) => const SizedBox(width: 12),
146- itemBuilder: (context, i) {
147- final t = items[i];
148- return SizedBox(
149- width: 240,
150- child: GleanBox(
151- onTap: () => Navigator.of(context).push(
152- MaterialPageRoute(builder: (_) => ArticleScreen(articleId: t.articleId)),
153- ),
154- child: Column(
155- crossAxisAlignment: CrossAxisAlignment.start,
156- children: [
157- Row(
158- children: [
159- FaviconBadge(url: t.faviconUrl, seed: t.feedTitle, size: 14),
160- const SizedBox(width: 6),
161- Expanded(
162- child: Text(t.feedTitle,
163- maxLines: 1, overflow: TextOverflow.ellipsis, style: text.bodySmall),
164- ),
165- ],
166- ),
167- const SizedBox(height: 8),
168- Expanded(
169- child: Text(
170- t.title,
171- maxLines: 4,
172- overflow: TextOverflow.ellipsis,
173- style: text.bodyMedium?.copyWith(fontWeight: FontWeight.w700),
174- ),
175- ),
176- const SizedBox(height: 6),
177- Row(
178- children: [
179- GleanTag('${t.likeCount} likes'),
180- const SizedBox(width: 6),
181- if (t.annotationCount > 0) GleanTag('${t.annotationCount} notes'),
182- ],
183- ),
184- ],
185- ),
186- ),
187- );
188- },
189- ),
190- );
191- }
192-}
deleted app/lib/src/screens/discover_screen.dart +0 -277
deleted file mode 100644
@@ -1,277 +0,0 @@
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-}
deleted file mode 100644
@@ -1,277 +0,0 @@
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-}
deleted app/lib/src/screens/feeds_screen.dart +0 -499
deleted file mode 100644
@@ -1,499 +0,0 @@
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-}
deleted file mode 100644
@@ -1,499 +0,0 @@
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 +185 -113
@@ -1,19 +1,17 @@
11 import 'package:flutter/material.dart';
22
3+import '../api/client.dart';
34 import '../app_state.dart';
5+import '../models/models.dart';
46 import '../theme.dart';
7+import '../widgets/common.dart';
58 import 'articles_screen.dart';
6-import 'dashboard_screen.dart';
7-import 'discover_screen.dart';
8-import 'feeds_screen.dart';
9-import 'library_screen.dart';
10-import 'login_screen.dart';
11-import 'profile_screen.dart';
12-import 'trending_screen.dart';
13-
14-/// Bottom-tab shell. Trending is public; the rest require a session, so a
15-/// signed-out visitor sees Trending with a prompt to sign in rather than a
16-/// wall of failed requests.
9+
10+/// Articles, with the feed list in a drawer.
11+///
12+/// One screen rather than a tab bar: the reader has exactly one thing to
13+/// look at, and choosing a feed is navigation within it rather than a
14+/// separate destination.
1715 class HomeShell extends StatefulWidget {
1816 const HomeShell({super.key});
1917
@@ -22,135 +20,209 @@ class HomeShell extends StatefulWidget {
2220 }
2321
2422 class _HomeShellState extends State<HomeShell> {
25- int _index = 0;
23+ Feed _selected = Feed.all(0);
24+ bool _refreshing = false;
25+
26+ @override
27+ void initState() {
28+ super.initState();
29+ WidgetsBinding.instance.addPostFrameCallback((_) {
30+ AppScope.read(context).load();
31+ });
32+ }
33+
34+ Future<void> _addFeed() async {
35+ final url = await showDialog<String>(
36+ context: context,
37+ builder: (_) => const _AddFeedDialog(),
38+ );
39+ if (url == null || url.isEmpty || !mounted) return;
40+ try {
41+ await AppScope.read(context).addFeed(url);
42+ } on ApiException catch (e) {
43+ if (mounted) showToast(context, e.message);
44+ }
45+ }
46+
47+ Future<void> _refresh() async {
48+ setState(() => _refreshing = true);
49+ try {
50+ final result = await AppScope.read(context).refresh();
51+ if (!mounted) return;
52+ showToast(
53+ context,
54+ result.errors.isEmpty
55+ ? '${result.added} new'
56+ : '${result.added} new, ${result.errors.length} feed(s) failed',
57+ );
58+ } on ApiException catch (e) {
59+ if (mounted) showToast(context, e.message);
60+ } finally {
61+ if (mounted) setState(() => _refreshing = false);
62+ }
63+ }
64+
65+ Future<void> _markAllRead() async {
66+ final scope = _selected.isAll ? 'everything' : _selected.title;
67+ final ok = await showDialog<bool>(
68+ context: context,
69+ builder: (ctx) => AlertDialog(
70+ shape: const RoundedRectangleBorder(),
71+ title: Text('Mark $scope read?'),
72+ actions: [
73+ TextButton(
74+ onPressed: () => Navigator.pop(ctx, false),
75+ child: const Text('Cancel')),
76+ TextButton(
77+ onPressed: () => Navigator.pop(ctx, true),
78+ child: const Text('Mark read')),
79+ ],
80+ ),
81+ );
82+ if (ok != true || !mounted) return;
83+ await AppScope.read(context).markAllRead(feedUrl: _selected.feedUrl);
84+ }
85+
86+ Future<void> _remove(Feed f) async {
87+ final ok = await showDialog<bool>(
88+ context: context,
89+ builder: (ctx) => AlertDialog(
90+ shape: const RoundedRectangleBorder(),
91+ title: const Text('Unsubscribe?'),
92+ content: Text(f.title),
93+ actions: [
94+ TextButton(
95+ onPressed: () => Navigator.pop(ctx, false),
96+ child: const Text('Cancel')),
97+ TextButton(
98+ onPressed: () => Navigator.pop(ctx, true),
99+ child: const Text('Unsubscribe')),
100+ ],
101+ ),
102+ );
103+ if (ok != true || !mounted) return;
104+ await AppScope.read(context).removeFeed(f.feedUrl);
105+ if (mounted && _selected.feedUrl == f.feedUrl) {
106+ setState(() => _selected = Feed.all(0));
107+ }
108+ }
26109
27110 @override
28111 Widget build(BuildContext context) {
29112 final app = AppScope.of(context);
30113 final c = GleanColors.of(context);
31114
32- // Tab set depends on the session: no point offering Home to a visitor who
33- // would only get a 401.
34- final tabs = <_Tab>[
35- if (app.signedIn) ...[
36- const _Tab(icon: Icons.home_outlined, label: 'Home', child: DashboardScreen()),
37- const _Tab(icon: Icons.article_outlined, label: 'Articles', child: ArticlesScreen()),
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()),
43- ];
44- final index = _index.clamp(0, tabs.length - 1);
45-
46115 return Scaffold(
47116 appBar: AppBar(
48- title: Text(tabs[index].label == 'Home' ? 'glean' : tabs[index].label),
117+ title: Text(_selected.isAll ? 'glean' : _selected.title),
49118 actions: [
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- ),
71- IconButton(
72- tooltip: 'Sign out',
73- icon: const Icon(Icons.logout),
74- onPressed: () => AppScope.read(context).signOut(),
75- ),
76- ]
77- else
78- TextButton(
79- onPressed: () => Navigator.of(context).push(
80- MaterialPageRoute(builder: (_) => const LoginScreen()),
81- ),
82- child: const Text('Sign in'),
83- ),
119+ IconButton(
120+ tooltip: 'Mark all read',
121+ icon: const Icon(Icons.done_all),
122+ onPressed: _markAllRead,
123+ ),
124+ IconButton(
125+ tooltip: 'Refresh',
126+ icon: _refreshing
127+ ? const SizedBox(
128+ width: 18,
129+ height: 18,
130+ child: CircularProgressIndicator(strokeWidth: 2))
131+ : const Icon(Icons.refresh),
132+ onPressed: _refreshing ? null : _refresh,
133+ ),
84134 ],
85135 ),
86- body: _LazyIndexedStack(index: index, children: [for (final t in tabs) t.child]),
87- bottomNavigationBar: tabs.length < 2
88- ? null
89- : Container(
90- decoration: BoxDecoration(
91- border: Border(top: BorderSide(color: c.border, width: 2)),
136+ drawer: Drawer(
137+ backgroundColor: c.bg,
138+ child: SafeArea(
139+ child: Column(
140+ children: [
141+ ListTile(
142+ title: Text('Feeds',
143+ style: Theme.of(context).textTheme.titleMedium),
144+ trailing: IconButton(
145+ icon: const Icon(Icons.add),
146+ tooltip: 'Add feed',
147+ onPressed: () {
148+ Navigator.of(context).pop();
149+ _addFeed();
150+ },
151+ ),
92152 ),
93- child: NavigationBar(
94- selectedIndex: index,
95- backgroundColor: c.bg,
96- indicatorColor: c.accent,
97- onDestinationSelected: (i) => setState(() => _index = i),
98- destinations: [
99- for (final t in tabs)
100- NavigationDestination(icon: Icon(t.icon), label: t.label),
101- ],
153+ if (app.error != null)
154+ Padding(
155+ padding: const EdgeInsets.all(16),
156+ child: Text(app.error!,
157+ style: Theme.of(context)
158+ .textTheme
159+ .bodySmall
160+ ?.copyWith(color: c.danger)),
161+ ),
162+ Expanded(
163+ child: ListView(
164+ children: [
165+ for (final f in app.sidebar)
166+ ListTile(
167+ selected: f.feedUrl == _selected.feedUrl,
168+ title: Text(f.title,
169+ maxLines: 1, overflow: TextOverflow.ellipsis),
170+ trailing: f.unread > 0
171+ ? GleanTag('${f.unread}', emphasis: !f.isAll)
172+ : null,
173+ onLongPress: f.isAll ? null : () => _remove(f),
174+ onTap: () {
175+ setState(() => _selected = f);
176+ Navigator.of(context).pop();
177+ },
178+ ),
179+ ],
180+ ),
102181 ),
103- ),
182+ ],
183+ ),
184+ ),
185+ ),
186+ body: ArticlesScreen(key: ValueKey(_selected.feedUrl), feed: _selected),
104187 );
105188 }
106189 }
107190
108-class _Tab {
109- const _Tab({required this.icon, required this.label, required this.child});
110-
111- final IconData icon;
112- final String label;
113- final Widget child;
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;
191+class _AddFeedDialog extends StatefulWidget {
192+ const _AddFeedDialog();
127193
128194 @override
129- State<_LazyIndexedStack> createState() => _LazyIndexedStackState();
195+ State<_AddFeedDialog> createState() => _AddFeedDialogState();
130196 }
131197
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- }
198+class _AddFeedDialogState extends State<_AddFeedDialog> {
199+ final _controller = TextEditingController();
140200
141201 @override
142- void didUpdateWidget(_LazyIndexedStack old) {
143- super.didUpdateWidget(old);
144- _visited.add(widget.index);
202+ void dispose() {
203+ _controller.dispose();
204+ super.dispose();
145205 }
146206
147207 @override
148208 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(),
209+ return AlertDialog(
210+ shape: const RoundedRectangleBorder(),
211+ title: const Text('Add feed'),
212+ content: TextField(
213+ controller: _controller,
214+ autofocus: true,
215+ keyboardType: TextInputType.url,
216+ decoration: const InputDecoration(hintText: 'https://example.com/feed.xml'),
217+ onSubmitted: (v) => Navigator.pop(context, v.trim()),
218+ ),
219+ actions: [
220+ TextButton(
221+ onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
222+ TextButton(
223+ onPressed: () => Navigator.pop(context, _controller.text.trim()),
224+ child: const Text('Add'),
225+ ),
154226 ],
155227 );
156228 }
@@ -1,19 +1,17 @@
1 import 'package:flutter/material.dart';1 import 'package:flutter/material.dart';
2 2
3+import '../api/client.dart';
3 import '../app_state.dart';4 import '../app_state.dart';
5+import '../models/models.dart';
4 import '../theme.dart';6 import '../theme.dart';
7+import '../widgets/common.dart';
5 import 'articles_screen.dart';8 import 'articles_screen.dart';
6-import 'dashboard_screen.dart';9+
7-import 'discover_screen.dart';10+/// Articles, with the feed list in a drawer.
8-import 'feeds_screen.dart';11+///
9-import 'library_screen.dart';12+/// One screen rather than a tab bar: the reader has exactly one thing to
10-import 'login_screen.dart';13+/// look at, and choosing a feed is navigation within it rather than a
11-import 'profile_screen.dart';14+/// separate destination.
12-import 'trending_screen.dart';
13-
14-/// Bottom-tab shell. Trending is public; the rest require a session, so a
15-/// signed-out visitor sees Trending with a prompt to sign in rather than a
16-/// wall of failed requests.
17 class HomeShell extends StatefulWidget {15 class HomeShell extends StatefulWidget {
18 const HomeShell({super.key});16 const HomeShell({super.key});
19 17
@@ -22,135 +20,209 @@ class HomeShell extends StatefulWidget {
22 }20 }
23 21
24 class _HomeShellState extends State<HomeShell> {22 class _HomeShellState extends State<HomeShell> {
25- int _index = 0;23+ Feed _selected = Feed.all(0);
24+ bool _refreshing = false;
25+
26+ @override
27+ void initState() {
28+ super.initState();
29+ WidgetsBinding.instance.addPostFrameCallback((_) {
30+ AppScope.read(context).load();
31+ });
32+ }
33+
34+ Future<void> _addFeed() async {
35+ final url = await showDialog<String>(
36+ context: context,
37+ builder: (_) => const _AddFeedDialog(),
38+ );
39+ if (url == null || url.isEmpty || !mounted) return;
40+ try {
41+ await AppScope.read(context).addFeed(url);
42+ } on ApiException catch (e) {
43+ if (mounted) showToast(context, e.message);
44+ }
45+ }
46+
47+ Future<void> _refresh() async {
48+ setState(() => _refreshing = true);
49+ try {
50+ final result = await AppScope.read(context).refresh();
51+ if (!mounted) return;
52+ showToast(
53+ context,
54+ result.errors.isEmpty
55+ ? '${result.added} new'
56+ : '${result.added} new, ${result.errors.length} feed(s) failed',
57+ );
58+ } on ApiException catch (e) {
59+ if (mounted) showToast(context, e.message);
60+ } finally {
61+ if (mounted) setState(() => _refreshing = false);
62+ }
63+ }
64+
65+ Future<void> _markAllRead() async {
66+ final scope = _selected.isAll ? 'everything' : _selected.title;
67+ final ok = await showDialog<bool>(
68+ context: context,
69+ builder: (ctx) => AlertDialog(
70+ shape: const RoundedRectangleBorder(),
71+ title: Text('Mark $scope read?'),
72+ actions: [
73+ TextButton(
74+ onPressed: () => Navigator.pop(ctx, false),
75+ child: const Text('Cancel')),
76+ TextButton(
77+ onPressed: () => Navigator.pop(ctx, true),
78+ child: const Text('Mark read')),
79+ ],
80+ ),
81+ );
82+ if (ok != true || !mounted) return;
83+ await AppScope.read(context).markAllRead(feedUrl: _selected.feedUrl);
84+ }
85+
86+ Future<void> _remove(Feed f) async {
87+ final ok = await showDialog<bool>(
88+ context: context,
89+ builder: (ctx) => AlertDialog(
90+ shape: const RoundedRectangleBorder(),
91+ title: const Text('Unsubscribe?'),
92+ content: Text(f.title),
93+ actions: [
94+ TextButton(
95+ onPressed: () => Navigator.pop(ctx, false),
96+ child: const Text('Cancel')),
97+ TextButton(
98+ onPressed: () => Navigator.pop(ctx, true),
99+ child: const Text('Unsubscribe')),
100+ ],
101+ ),
102+ );
103+ if (ok != true || !mounted) return;
104+ await AppScope.read(context).removeFeed(f.feedUrl);
105+ if (mounted && _selected.feedUrl == f.feedUrl) {
106+ setState(() => _selected = Feed.all(0));
107+ }
108+ }
26 109
27 @override110 @override
28 Widget build(BuildContext context) {111 Widget build(BuildContext context) {
29 final app = AppScope.of(context);112 final app = AppScope.of(context);
30 final c = GleanColors.of(context);113 final c = GleanColors.of(context);
31 114
32- // Tab set depends on the session: no point offering Home to a visitor who
33- // would only get a 401.
34- final tabs = <_Tab>[
35- if (app.signedIn) ...[
36- const _Tab(icon: Icons.home_outlined, label: 'Home', child: DashboardScreen()),
37- const _Tab(icon: Icons.article_outlined, label: 'Articles', child: ArticlesScreen()),
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()),
43- ];
44- final index = _index.clamp(0, tabs.length - 1);
45-
46 return Scaffold(115 return Scaffold(
47 appBar: AppBar(116 appBar: AppBar(
48- title: Text(tabs[index].label == 'Home' ? 'glean' : tabs[index].label),117+ title: Text(_selected.isAll ? 'glean' : _selected.title),
49 actions: [118 actions: [
50- if (app.signedIn) ...[119+ IconButton(
51- IconButton(120+ tooltip: 'Mark all read',
52- tooltip: 'Trending',121+ icon: const Icon(Icons.done_all),
53- icon: const Icon(Icons.trending_up),122+ onPressed: _markAllRead,
54- onPressed: () => Navigator.of(context).push(MaterialPageRoute(123+ ),
55- builder: (_) => Scaffold(124+ IconButton(
56- appBar: AppBar(title: const Text('Trending')),125+ tooltip: 'Refresh',
57- body: const TrendingScreen(),126+ icon: _refreshing
58- ),127+ ? const SizedBox(
59- )),128+ width: 18,
60- ),129+ height: 18,
61- IconButton(130+ child: CircularProgressIndicator(strokeWidth: 2))
62- tooltip: 'Profile',131+ : const Icon(Icons.refresh),
63- icon: const Icon(Icons.person_outline),132+ onPressed: _refreshing ? null : _refresh,
64- onPressed: () => Navigator.of(context).push(MaterialPageRoute(133+ ),
65- builder: (_) => Scaffold(
66- appBar: AppBar(title: const Text('Profile')),
67- body: const ProfileScreen(),
68- ),
69- )),
70- ),
71- IconButton(
72- tooltip: 'Sign out',
73- icon: const Icon(Icons.logout),
74- onPressed: () => AppScope.read(context).signOut(),
75- ),
76- ]
77- else
78- TextButton(
79- onPressed: () => Navigator.of(context).push(
80- MaterialPageRoute(builder: (_) => const LoginScreen()),
81- ),
82- child: const Text('Sign in'),
83- ),
84 ],134 ],
85 ),135 ),
86- body: _LazyIndexedStack(index: index, children: [for (final t in tabs) t.child]),136+ drawer: Drawer(
87- bottomNavigationBar: tabs.length < 2137+ backgroundColor: c.bg,
88- ? null138+ child: SafeArea(
89- : Container(139+ child: Column(
90- decoration: BoxDecoration(140+ children: [
91- border: Border(top: BorderSide(color: c.border, width: 2)),141+ ListTile(
142+ title: Text('Feeds',
143+ style: Theme.of(context).textTheme.titleMedium),
144+ trailing: IconButton(
145+ icon: const Icon(Icons.add),
146+ tooltip: 'Add feed',
147+ onPressed: () {
148+ Navigator.of(context).pop();
149+ _addFeed();
150+ },
151+ ),
92 ),152 ),
93- child: NavigationBar(153+ if (app.error != null)
94- selectedIndex: index,154+ Padding(
95- backgroundColor: c.bg,155+ padding: const EdgeInsets.all(16),
96- indicatorColor: c.accent,156+ child: Text(app.error!,
97- onDestinationSelected: (i) => setState(() => _index = i),157+ style: Theme.of(context)
98- destinations: [158+ .textTheme
99- for (final t in tabs)159+ .bodySmall
100- NavigationDestination(icon: Icon(t.icon), label: t.label),160+ ?.copyWith(color: c.danger)),
101- ],161+ ),
162+ Expanded(
163+ child: ListView(
164+ children: [
165+ for (final f in app.sidebar)
166+ ListTile(
167+ selected: f.feedUrl == _selected.feedUrl,
168+ title: Text(f.title,
169+ maxLines: 1, overflow: TextOverflow.ellipsis),
170+ trailing: f.unread > 0
171+ ? GleanTag('${f.unread}', emphasis: !f.isAll)
172+ : null,
173+ onLongPress: f.isAll ? null : () => _remove(f),
174+ onTap: () {
175+ setState(() => _selected = f);
176+ Navigator.of(context).pop();
177+ },
178+ ),
179+ ],
180+ ),
102 ),181 ),
103- ),182+ ],
183+ ),
184+ ),
185+ ),
186+ body: ArticlesScreen(key: ValueKey(_selected.feedUrl), feed: _selected),
104 );187 );
105 }188 }
106 }189 }
107 190
108-class _Tab {191+class _AddFeedDialog extends StatefulWidget {
109- const _Tab({required this.icon, required this.label, required this.child});192+ const _AddFeedDialog();
110-
111- final IconData icon;
112- final String label;
113- final Widget child;
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 193
128 @override194 @override
129- State<_LazyIndexedStack> createState() => _LazyIndexedStackState();195+ State<_AddFeedDialog> createState() => _AddFeedDialogState();
130 }196 }
131 197
132-class _LazyIndexedStackState extends State<_LazyIndexedStack> {198+class _AddFeedDialogState extends State<_AddFeedDialog> {
133- final _visited = <int>{};199+ final _controller = TextEditingController();
134-
135- @override
136- void initState() {
137- super.initState();
138- _visited.add(widget.index);
139- }
140 200
141 @override201 @override
142- void didUpdateWidget(_LazyIndexedStack old) {202+ void dispose() {
143- super.didUpdateWidget(old);203+ _controller.dispose();
144- _visited.add(widget.index);204+ super.dispose();
145 }205 }
146 206
147 @override207 @override
148 Widget build(BuildContext context) {208 Widget build(BuildContext context) {
149- return IndexedStack(209+ return AlertDialog(
150- index: widget.index,210+ shape: const RoundedRectangleBorder(),
151- children: [211+ title: const Text('Add feed'),
152- for (var i = 0; i < widget.children.length; i++)212+ content: TextField(
153- if (_visited.contains(i)) widget.children[i] else const SizedBox.shrink(),213+ controller: _controller,
214+ autofocus: true,
215+ keyboardType: TextInputType.url,
216+ decoration: const InputDecoration(hintText: 'https://example.com/feed.xml'),
217+ onSubmitted: (v) => Navigator.pop(context, v.trim()),
218+ ),
219+ actions: [
220+ TextButton(
221+ onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
222+ TextButton(
223+ onPressed: () => Navigator.pop(context, _controller.text.trim()),
224+ child: const Text('Add'),
225+ ),
154 ],226 ],
155 );227 );
156 }228 }
deleted app/lib/src/screens/library_screen.dart +0 -200
deleted file mode 100644
@@ -1,200 +0,0 @@
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-}
deleted file mode 100644
@@ -1,200 +0,0 @@
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-}
deleted app/lib/src/screens/login_screen.dart +0 -215
deleted file mode 100644
@@ -1,215 +0,0 @@
1-import 'package:flutter/material.dart';
2-import 'package:webview_cookie_manager/webview_cookie_manager.dart';
3-import 'package:webview_flutter/webview_flutter.dart';
4-
5-import '../api/client.dart';
6-import '../app_state.dart';
7-import '../theme.dart';
8-import '../widgets/common.dart';
9-
10-/// Sign-in via ATProto OAuth.
11-///
12-/// The Go server drives the whole flow in a browser: POST /api/auth/start
13-/// returns the PDS authorization URL, the user authorizes there, and the PDS
14-/// redirects back to /api/auth/callback, which sets the session cookie and
15-/// redirects on. A native app has no browser to inherit that cookie from, so
16-/// the flow runs in a webview and the cookie is lifted out of it once the
17-/// callback has landed. That keeps the server contract untouched -- the
18-/// alternative, a native deep-link redirect URI, would mean changing the
19-/// published OAuth client metadata.
20-class LoginScreen extends StatefulWidget {
21- const LoginScreen({super.key});
22-
23- @override
24- State<LoginScreen> createState() => _LoginScreenState();
25-}
26-
27-class _LoginScreenState extends State<LoginScreen> {
28- final _controller = TextEditingController();
29- bool _busy = false;
30- String? _error;
31-
32- @override
33- void dispose() {
34- _controller.dispose();
35- super.dispose();
36- }
37-
38- Future<void> _start() async {
39- final handle = _controller.text.trim().replaceFirst(RegExp(r'^@'), '');
40- if (handle.isEmpty) {
41- setState(() => _error = 'Enter your handle.');
42- return;
43- }
44- setState(() {
45- _busy = true;
46- _error = null;
47- });
48-
49- final app = AppScope.read(context);
50- try {
51- final target = await app.client.startAuth(handle);
52- if (!mounted) return;
53-
54- // The server answers with /dashboard when it fell back to creating a
55- // local account without OAuth; there is nothing to authorize.
56- if (!target.startsWith('http')) {
57- await app.refreshUser();
58- return;
59- }
60-
61- final cookies = await Navigator.of(context).push<Map<String, String>>(
62- MaterialPageRoute(
63- builder: (_) => _OAuthWebView(authUrl: target, baseUrl: app.baseUrl),
64- ),
65- );
66- if (!mounted) return;
67- if (cookies == null || cookies.isEmpty) {
68- setState(() => _error = 'Sign-in was cancelled.');
69- return;
70- }
71- await app.completeSignIn(cookies);
72- } on ApiException catch (e) {
73- if (mounted) setState(() => _error = e.message);
74- } on Exception {
75- if (mounted) setState(() => _error = 'Could not reach the server.');
76- } finally {
77- if (mounted) setState(() => _busy = false);
78- }
79- }
80-
81- @override
82- Widget build(BuildContext context) {
83- final app = AppScope.of(context);
84- final c = GleanColors.of(context);
85- final text = Theme.of(context).textTheme;
86-
87- return Scaffold(
88- body: SafeArea(
89- child: Center(
90- child: SingleChildScrollView(
91- padding: const EdgeInsets.all(24),
92- child: ConstrainedBox(
93- constraints: const BoxConstraints(maxWidth: 420),
94- child: Column(
95- crossAxisAlignment: CrossAxisAlignment.stretch,
96- mainAxisSize: MainAxisSize.min,
97- children: [
98- Text('glean', style: text.displaySmall),
99- const SizedBox(height: 4),
100- Text('a reader for the open social web', style: text.bodySmall),
101- const SizedBox(height: 28),
102- if (!app.oauthConfigured)
103- Padding(
104- padding: const EdgeInsets.only(bottom: 16),
105- child: GleanBox(
106- filled: true,
107- child: Text(
108- 'This server has no OAuth client configured '
109- '(GLEAN_OAUTH_CLIENT_ID), so sign-in cannot complete. '
110- 'Browse trending in the meantime.',
111- style: text.bodySmall,
112- ),
113- ),
114- ),
115- TextField(
116- controller: _controller,
117- autocorrect: false,
118- enableSuggestions: false,
119- keyboardType: TextInputType.url,
120- decoration: const InputDecoration(hintText: 'you.bsky.social'),
121- onSubmitted: (_) => _start(),
122- ),
123- if (_error != null) ...[
124- const SizedBox(height: 10),
125- Text(_error!, style: text.bodySmall?.copyWith(color: c.danger)),
126- ],
127- const SizedBox(height: 16),
128- GleanButton(
129- label: 'Sign in',
130- accent: true,
131- busy: _busy,
132- onPressed: app.oauthConfigured ? _start : null,
133- ),
134- const SizedBox(height: 12),
135- Center(
136- child: TextButton(
137- onPressed: () => Navigator.of(context).maybePop(),
138- child: Text('Keep browsing', style: text.bodySmall),
139- ),
140- ),
141- ],
142- ),
143- ),
144- ),
145- ),
146- ),
147- );
148- }
149-}
150-
151-/// Hosts the PDS authorization page and pops the session cookies once the
152-/// server's callback has completed.
153-class _OAuthWebView extends StatefulWidget {
154- const _OAuthWebView({required this.authUrl, required this.baseUrl});
155-
156- final String authUrl;
157- final String baseUrl;
158-
159- @override
160- State<_OAuthWebView> createState() => _OAuthWebViewState();
161-}
162-
163-class _OAuthWebViewState extends State<_OAuthWebView> {
164- late final WebViewController _controller;
165- bool _finishing = false;
166-
167- @override
168- void initState() {
169- super.initState();
170- _controller = WebViewController()
171- ..setJavaScriptMode(JavaScriptMode.unrestricted)
172- ..setNavigationDelegate(
173- NavigationDelegate(onPageFinished: _onPageFinished),
174- )
175- ..loadRequest(Uri.parse(widget.authUrl));
176- }
177-
178- Future<void> _onPageFinished(String url) async {
179- if (_finishing) return;
180- final uri = Uri.tryParse(url);
181- final base = Uri.tryParse(widget.baseUrl);
182- if (uri == null || base == null || uri.host != base.host) return;
183-
184- // The callback redirects onward once the cookie is set, so treat any
185- // same-origin page that is not the login screen as a completed flow.
186- if (uri.path.startsWith('/auth/login')) {
187- if (uri.queryParameters['error'] != null && mounted) {
188- Navigator.of(context).pop(<String, String>{});
189- }
190- return;
191- }
192-
193- _finishing = true;
194- final cookies = await _readCookies();
195- if (!mounted) return;
196- Navigator.of(context).pop(cookies);
197- }
198-
199- /// Read the cookies out of the platform cookie store rather than via
200- /// document.cookie: the session cookie is HttpOnly (internal/server/
201- /// session.go), so JavaScript can only ever see the CSRF one, and adopting
202- /// that alone would leave the app looking signed in but unauthenticated.
203- Future<Map<String, String>> _readCookies() async {
204- final jar = await WebviewCookieManager().getCookies(widget.baseUrl);
205- return {for (final c in jar) c.name: c.value};
206- }
207-
208- @override
209- Widget build(BuildContext context) {
210- return Scaffold(
211- appBar: AppBar(title: const Text('Authorize')),
212- body: WebViewWidget(controller: _controller),
213- );
214- }
215-}
deleted file mode 100644
@@ -1,215 +0,0 @@
1-import 'package:flutter/material.dart';
2-import 'package:webview_cookie_manager/webview_cookie_manager.dart';
3-import 'package:webview_flutter/webview_flutter.dart';
4-
5-import '../api/client.dart';
6-import '../app_state.dart';
7-import '../theme.dart';
8-import '../widgets/common.dart';
9-
10-/// Sign-in via ATProto OAuth.
11-///
12-/// The Go server drives the whole flow in a browser: POST /api/auth/start
13-/// returns the PDS authorization URL, the user authorizes there, and the PDS
14-/// redirects back to /api/auth/callback, which sets the session cookie and
15-/// redirects on. A native app has no browser to inherit that cookie from, so
16-/// the flow runs in a webview and the cookie is lifted out of it once the
17-/// callback has landed. That keeps the server contract untouched -- the
18-/// alternative, a native deep-link redirect URI, would mean changing the
19-/// published OAuth client metadata.
20-class LoginScreen extends StatefulWidget {
21- const LoginScreen({super.key});
22-
23- @override
24- State<LoginScreen> createState() => _LoginScreenState();
25-}
26-
27-class _LoginScreenState extends State<LoginScreen> {
28- final _controller = TextEditingController();
29- bool _busy = false;
30- String? _error;
31-
32- @override
33- void dispose() {
34- _controller.dispose();
35- super.dispose();
36- }
37-
38- Future<void> _start() async {
39- final handle = _controller.text.trim().replaceFirst(RegExp(r'^@'), '');
40- if (handle.isEmpty) {
41- setState(() => _error = 'Enter your handle.');
42- return;
43- }
44- setState(() {
45- _busy = true;
46- _error = null;
47- });
48-
49- final app = AppScope.read(context);
50- try {
51- final target = await app.client.startAuth(handle);
52- if (!mounted) return;
53-
54- // The server answers with /dashboard when it fell back to creating a
55- // local account without OAuth; there is nothing to authorize.
56- if (!target.startsWith('http')) {
57- await app.refreshUser();
58- return;
59- }
60-
61- final cookies = await Navigator.of(context).push<Map<String, String>>(
62- MaterialPageRoute(
63- builder: (_) => _OAuthWebView(authUrl: target, baseUrl: app.baseUrl),
64- ),
65- );
66- if (!mounted) return;
67- if (cookies == null || cookies.isEmpty) {
68- setState(() => _error = 'Sign-in was cancelled.');
69- return;
70- }
71- await app.completeSignIn(cookies);
72- } on ApiException catch (e) {
73- if (mounted) setState(() => _error = e.message);
74- } on Exception {
75- if (mounted) setState(() => _error = 'Could not reach the server.');
76- } finally {
77- if (mounted) setState(() => _busy = false);
78- }
79- }
80-
81- @override
82- Widget build(BuildContext context) {
83- final app = AppScope.of(context);
84- final c = GleanColors.of(context);
85- final text = Theme.of(context).textTheme;
86-
87- return Scaffold(
88- body: SafeArea(
89- child: Center(
90- child: SingleChildScrollView(
91- padding: const EdgeInsets.all(24),
92- child: ConstrainedBox(
93- constraints: const BoxConstraints(maxWidth: 420),
94- child: Column(
95- crossAxisAlignment: CrossAxisAlignment.stretch,
96- mainAxisSize: MainAxisSize.min,
97- children: [
98- Text('glean', style: text.displaySmall),
99- const SizedBox(height: 4),
100- Text('a reader for the open social web', style: text.bodySmall),
101- const SizedBox(height: 28),
102- if (!app.oauthConfigured)
103- Padding(
104- padding: const EdgeInsets.only(bottom: 16),
105- child: GleanBox(
106- filled: true,
107- child: Text(
108- 'This server has no OAuth client configured '
109- '(GLEAN_OAUTH_CLIENT_ID), so sign-in cannot complete. '
110- 'Browse trending in the meantime.',
111- style: text.bodySmall,
112- ),
113- ),
114- ),
115- TextField(
116- controller: _controller,
117- autocorrect: false,
118- enableSuggestions: false,
119- keyboardType: TextInputType.url,
120- decoration: const InputDecoration(hintText: 'you.bsky.social'),
121- onSubmitted: (_) => _start(),
122- ),
123- if (_error != null) ...[
124- const SizedBox(height: 10),
125- Text(_error!, style: text.bodySmall?.copyWith(color: c.danger)),
126- ],
127- const SizedBox(height: 16),
128- GleanButton(
129- label: 'Sign in',
130- accent: true,
131- busy: _busy,
132- onPressed: app.oauthConfigured ? _start : null,
133- ),
134- const SizedBox(height: 12),
135- Center(
136- child: TextButton(
137- onPressed: () => Navigator.of(context).maybePop(),
138- child: Text('Keep browsing', style: text.bodySmall),
139- ),
140- ),
141- ],
142- ),
143- ),
144- ),
145- ),
146- ),
147- );
148- }
149-}
150-
151-/// Hosts the PDS authorization page and pops the session cookies once the
152-/// server's callback has completed.
153-class _OAuthWebView extends StatefulWidget {
154- const _OAuthWebView({required this.authUrl, required this.baseUrl});
155-
156- final String authUrl;
157- final String baseUrl;
158-
159- @override
160- State<_OAuthWebView> createState() => _OAuthWebViewState();
161-}
162-
163-class _OAuthWebViewState extends State<_OAuthWebView> {
164- late final WebViewController _controller;
165- bool _finishing = false;
166-
167- @override
168- void initState() {
169- super.initState();
170- _controller = WebViewController()
171- ..setJavaScriptMode(JavaScriptMode.unrestricted)
172- ..setNavigationDelegate(
173- NavigationDelegate(onPageFinished: _onPageFinished),
174- )
175- ..loadRequest(Uri.parse(widget.authUrl));
176- }
177-
178- Future<void> _onPageFinished(String url) async {
179- if (_finishing) return;
180- final uri = Uri.tryParse(url);
181- final base = Uri.tryParse(widget.baseUrl);
182- if (uri == null || base == null || uri.host != base.host) return;
183-
184- // The callback redirects onward once the cookie is set, so treat any
185- // same-origin page that is not the login screen as a completed flow.
186- if (uri.path.startsWith('/auth/login')) {
187- if (uri.queryParameters['error'] != null && mounted) {
188- Navigator.of(context).pop(<String, String>{});
189- }
190- return;
191- }
192-
193- _finishing = true;
194- final cookies = await _readCookies();
195- if (!mounted) return;
196- Navigator.of(context).pop(cookies);
197- }
198-
199- /// Read the cookies out of the platform cookie store rather than via
200- /// document.cookie: the session cookie is HttpOnly (internal/server/
201- /// session.go), so JavaScript can only ever see the CSRF one, and adopting
202- /// that alone would leave the app looking signed in but unauthenticated.
203- Future<Map<String, String>> _readCookies() async {
204- final jar = await WebviewCookieManager().getCookies(widget.baseUrl);
205- return {for (final c in jar) c.name: c.value};
206- }
207-
208- @override
209- Widget build(BuildContext context) {
210- return Scaffold(
211- appBar: AppBar(title: const Text('Authorize')),
212- body: WebViewWidget(controller: _controller),
213- );
214- }
215-}
deleted app/lib/src/screens/profile_screen.dart +0 -237
deleted file mode 100644
@@ -1,237 +0,0 @@
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-}
deleted file mode 100644
@@ -1,237 +0,0 @@
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/lib/src/widgets/article_tile.dart +24 -91
@@ -4,27 +4,19 @@ import '../models/models.dart';
44 import '../theme.dart';
55 import 'common.dart';
66
7-/// One row in any article list.
8-///
9-/// Read state is carried by weight and opacity rather than a separate badge,
10-/// matching the web list where unread items simply read louder.
7+/// One row in the article list. Read state shows as weight and opacity
8+/// rather than a badge, so an unread item simply reads louder.
119 class ArticleTile extends StatelessWidget {
1210 const ArticleTile({
1311 super.key,
1412 required this.article,
1513 required this.onTap,
1614 this.onToggleRead,
17- this.onToggleLike,
18- this.expanded = false,
1915 });
2016
2117 final Article article;
2218 final VoidCallback onTap;
2319 final VoidCallback? onToggleRead;
24- final VoidCallback? onToggleLike;
25-
26- /// Mirrors the server's expanded_view preference: show the summary too.
27- final bool expanded;
2820
2921 @override
3022 Widget build(BuildContext context) {
@@ -44,15 +36,11 @@ class ArticleTile extends StatelessWidget {
4436 children: [
4537 Row(
4638 children: [
47- FaviconBadge(url: article.feedFaviconUrl, seed: article.feedTitle),
48- const SizedBox(width: 8),
4939 Expanded(
50- child: Text(
51- article.feedTitle,
52- maxLines: 1,
53- overflow: TextOverflow.ellipsis,
54- style: text.bodySmall,
55- ),
40+ child: Text(article.feedTitle,
41+ maxLines: 1,
42+ overflow: TextOverflow.ellipsis,
43+ style: text.bodySmall),
5644 ),
5745 if (article.published != null)
5846 Text(relativeTime(article.published), style: text.bodySmall),
@@ -62,7 +50,7 @@ class ArticleTile extends StatelessWidget {
6250 Opacity(
6351 opacity: read ? 0.55 : 1,
6452 child: Text(
65- article.title,
53+ article.title.isEmpty ? '(untitled)' : article.title,
6654 maxLines: 3,
6755 overflow: TextOverflow.ellipsis,
6856 style: text.bodyLarge?.copyWith(
@@ -70,85 +58,30 @@ class ArticleTile extends StatelessWidget {
7058 ),
7159 ),
7260 ),
73- if (expanded && article.summary.isNotEmpty) ...[
61+ if (article.summary.isNotEmpty) ...[
7462 const SizedBox(height: 6),
7563 Text(
76- article.summary,
77- maxLines: 3,
64+ // Feed summaries are HTML; show them as the text they read as.
65+ stripHtml(article.summary),
66+ maxLines: 2,
7867 overflow: TextOverflow.ellipsis,
7968 style: text.bodySmall,
8069 ),
8170 ],
82- const SizedBox(height: 8),
83- Row(
84- children: [
85- if (article.author.isNotEmpty) ...[
86- Flexible(
87- child: Text(
88- article.author,
89- maxLines: 1,
90- overflow: TextOverflow.ellipsis,
91- style: text.bodySmall,
92- ),
71+ if (onToggleRead != null)
72+ Align(
73+ alignment: Alignment.centerRight,
74+ child: IconButton(
75+ visualDensity: VisualDensity.compact,
76+ tooltip: read ? 'Mark unread' : 'Mark read',
77+ icon: Icon(
78+ read ? Icons.mark_email_unread_outlined : Icons.check,
79+ size: 18,
80+ color: c.muted,
9381 ),
94- const SizedBox(width: 12),
95- ],
96- const Spacer(),
97- if (onToggleLike != null)
98- _IconCount(
99- icon: article.hasLiked ? Icons.favorite : Icons.favorite_border,
100- count: article.likeCount,
101- active: article.hasLiked,
102- onTap: onToggleLike!,
103- ),
104- if (onToggleRead != null)
105- IconButton(
106- visualDensity: VisualDensity.compact,
107- tooltip: read ? 'Mark unread' : 'Mark read',
108- icon: Icon(
109- read ? Icons.mark_email_unread_outlined : Icons.check,
110- size: 18,
111- color: c.muted,
112- ),
113- onPressed: onToggleRead,
114- ),
115- ],
116- ),
117- ],
118- ),
119- ),
120- );
121- }
122-}
123-
124-class _IconCount extends StatelessWidget {
125- const _IconCount({
126- required this.icon,
127- required this.count,
128- required this.active,
129- required this.onTap,
130- });
131-
132- final IconData icon;
133- final int count;
134- final bool active;
135- final VoidCallback onTap;
136-
137- @override
138- Widget build(BuildContext context) {
139- final c = GleanColors.of(context);
140- return InkWell(
141- onTap: onTap,
142- child: Padding(
143- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
144- child: Row(
145- mainAxisSize: MainAxisSize.min,
146- children: [
147- Icon(icon, size: 16, color: active ? c.danger : c.muted),
148- if (count > 0) ...[
149- const SizedBox(width: 4),
150- Text('$count', style: Theme.of(context).textTheme.bodySmall),
151- ],
82+ onPressed: onToggleRead,
83+ ),
84+ ),
15285 ],
15386 ),
15487 ),
@@ -4,27 +4,19 @@ import '../models/models.dart';
4 import '../theme.dart';4 import '../theme.dart';
5 import 'common.dart';5 import 'common.dart';
6 6
7-/// One row in any article list.7+/// One row in the article list. Read state shows as weight and opacity
8-///8+/// rather than a badge, so an unread item simply reads louder.
9-/// Read state is carried by weight and opacity rather than a separate badge,
10-/// matching the web list where unread items simply read louder.
11 class ArticleTile extends StatelessWidget {9 class ArticleTile extends StatelessWidget {
12 const ArticleTile({10 const ArticleTile({
13 super.key,11 super.key,
14 required this.article,12 required this.article,
15 required this.onTap,13 required this.onTap,
16 this.onToggleRead,14 this.onToggleRead,
17- this.onToggleLike,
18- this.expanded = false,
19 });15 });
20 16
21 final Article article;17 final Article article;
22 final VoidCallback onTap;18 final VoidCallback onTap;
23 final VoidCallback? onToggleRead;19 final VoidCallback? onToggleRead;
24- final VoidCallback? onToggleLike;
25-
26- /// Mirrors the server's expanded_view preference: show the summary too.
27- final bool expanded;
28 20
29 @override21 @override
30 Widget build(BuildContext context) {22 Widget build(BuildContext context) {
@@ -44,15 +36,11 @@ class ArticleTile extends StatelessWidget {
44 children: [36 children: [
45 Row(37 Row(
46 children: [38 children: [
47- FaviconBadge(url: article.feedFaviconUrl, seed: article.feedTitle),
48- const SizedBox(width: 8),
49 Expanded(39 Expanded(
50- child: Text(40+ child: Text(article.feedTitle,
51- article.feedTitle,41+ maxLines: 1,
52- maxLines: 1,42+ overflow: TextOverflow.ellipsis,
53- overflow: TextOverflow.ellipsis,43+ style: text.bodySmall),
54- style: text.bodySmall,
55- ),
56 ),44 ),
57 if (article.published != null)45 if (article.published != null)
58 Text(relativeTime(article.published), style: text.bodySmall),46 Text(relativeTime(article.published), style: text.bodySmall),
@@ -62,7 +50,7 @@ class ArticleTile extends StatelessWidget {
62 Opacity(50 Opacity(
63 opacity: read ? 0.55 : 1,51 opacity: read ? 0.55 : 1,
64 child: Text(52 child: Text(
65- article.title,53+ article.title.isEmpty ? '(untitled)' : article.title,
66 maxLines: 3,54 maxLines: 3,
67 overflow: TextOverflow.ellipsis,55 overflow: TextOverflow.ellipsis,
68 style: text.bodyLarge?.copyWith(56 style: text.bodyLarge?.copyWith(
@@ -70,85 +58,30 @@ class ArticleTile extends StatelessWidget {
70 ),58 ),
71 ),59 ),
72 ),60 ),
73- if (expanded && article.summary.isNotEmpty) ...[61+ if (article.summary.isNotEmpty) ...[
74 const SizedBox(height: 6),62 const SizedBox(height: 6),
75 Text(63 Text(
76- article.summary,64+ // Feed summaries are HTML; show them as the text they read as.
77- maxLines: 3,65+ stripHtml(article.summary),
66+ maxLines: 2,
78 overflow: TextOverflow.ellipsis,67 overflow: TextOverflow.ellipsis,
79 style: text.bodySmall,68 style: text.bodySmall,
80 ),69 ),
81 ],70 ],
82- const SizedBox(height: 8),71+ if (onToggleRead != null)
83- Row(72+ Align(
84- children: [73+ alignment: Alignment.centerRight,
85- if (article.author.isNotEmpty) ...[74+ child: IconButton(
86- Flexible(75+ visualDensity: VisualDensity.compact,
87- child: Text(76+ tooltip: read ? 'Mark unread' : 'Mark read',
88- article.author,77+ icon: Icon(
89- maxLines: 1,78+ read ? Icons.mark_email_unread_outlined : Icons.check,
90- overflow: TextOverflow.ellipsis,79+ size: 18,
91- style: text.bodySmall,80+ color: c.muted,
92- ),
93 ),81 ),
94- const SizedBox(width: 12),82+ onPressed: onToggleRead,
95- ],83+ ),
96- const Spacer(),84+ ),
97- if (onToggleLike != null)
98- _IconCount(
99- icon: article.hasLiked ? Icons.favorite : Icons.favorite_border,
100- count: article.likeCount,
101- active: article.hasLiked,
102- onTap: onToggleLike!,
103- ),
104- if (onToggleRead != null)
105- IconButton(
106- visualDensity: VisualDensity.compact,
107- tooltip: read ? 'Mark unread' : 'Mark read',
108- icon: Icon(
109- read ? Icons.mark_email_unread_outlined : Icons.check,
110- size: 18,
111- color: c.muted,
112- ),
113- onPressed: onToggleRead,
114- ),
115- ],
116- ),
117- ],
118- ),
119- ),
120- );
121- }
122-}
123-
124-class _IconCount extends StatelessWidget {
125- const _IconCount({
126- required this.icon,
127- required this.count,
128- required this.active,
129- required this.onTap,
130- });
131-
132- final IconData icon;
133- final int count;
134- final bool active;
135- final VoidCallback onTap;
136-
137- @override
138- Widget build(BuildContext context) {
139- final c = GleanColors.of(context);
140- return InkWell(
141- onTap: onTap,
142- child: Padding(
143- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
144- child: Row(
145- mainAxisSize: MainAxisSize.min,
146- children: [
147- Icon(icon, size: 16, color: active ? c.danger : c.muted),
148- if (count > 0) ...[
149- const SizedBox(width: 4),
150- Text('$count', style: Theme.of(context).textTheme.bodySmall),
151- ],
152 ],85 ],
153 ),86 ),
154 ),87 ),
modified app/lib/src/widgets/common.dart +19 -8
@@ -126,23 +126,22 @@ class GleanButton extends StatelessWidget {
126126 ///
127127 /// Every screen fetches independently, so this is where retry and the
128128 /// "session expired" path live rather than being re-written per screen.
129+/// Renders a future with consistent loading, error and empty states.
130+///
131+/// Every screen fetches independently, so retry lives here rather than being
132+/// re-written per screen.
129133 class AsyncView<T> extends StatelessWidget {
130134 const AsyncView({
131135 super.key,
132136 required this.future,
133137 required this.builder,
134138 required this.onRetry,
135- this.onUnauthorized,
136139 });
137140
138141 final Future<T>? future;
139142 final Widget Function(BuildContext, T) builder;
140143 final VoidCallback onRetry;
141144
142- /// Called when the server rejects the session, so the shell can send the
143- /// user back to sign-in instead of showing a bare error.
144- final VoidCallback? onUnauthorized;
145-
146145 @override
147146 Widget build(BuildContext context) {
148147 return FutureBuilder<T>(
@@ -153,9 +152,6 @@ class AsyncView<T> extends StatelessWidget {
153152 }
154153 if (snap.hasError) {
155154 final err = snap.error;
156- if (err is ApiException && err.isUnauthorized && onUnauthorized != null) {
157- WidgetsBinding.instance.addPostFrameCallback((_) => onUnauthorized!());
158- }
159155 return ErrorView(
160156 message: err is ApiException ? err.message : 'Something went wrong.',
161157 onRetry: onRetry,
@@ -275,3 +271,18 @@ void showToast(BuildContext context, String message) {
275271 ..hideCurrentSnackBar()
276272 ..showSnackBar(SnackBar(content: Text(message)));
277273 }
274+
275+
276+/// Flatten HTML to the text it reads as.
277+///
278+/// For summaries and previews only. Article bodies are rendered as HTML,
279+/// which is safe because the server sanitises every body it sends.
280+String stripHtml(String html) => html
281+ .replaceAll(RegExp(r'<[^>]*>'), ' ')
282+ .replaceAll('&nbsp;', ' ')
283+ .replaceAll('&amp;', '&')
284+ .replaceAll('&lt;', '<')
285+ .replaceAll('&gt;', '>')
286+ .replaceAll('&quot;', '"')
287+ .replaceAll(RegExp(r'\s+'), ' ')
288+ .trim();
@@ -126,23 +126,22 @@ class GleanButton extends StatelessWidget {
126 ///126 ///
127 /// Every screen fetches independently, so this is where retry and the127 /// Every screen fetches independently, so this is where retry and the
128 /// "session expired" path live rather than being re-written per screen.128 /// "session expired" path live rather than being re-written per screen.
129+/// Renders a future with consistent loading, error and empty states.
130+///
131+/// Every screen fetches independently, so retry lives here rather than being
132+/// re-written per screen.
129 class AsyncView<T> extends StatelessWidget {133 class AsyncView<T> extends StatelessWidget {
130 const AsyncView({134 const AsyncView({
131 super.key,135 super.key,
132 required this.future,136 required this.future,
133 required this.builder,137 required this.builder,
134 required this.onRetry,138 required this.onRetry,
135- this.onUnauthorized,
136 });139 });
137 140
138 final Future<T>? future;141 final Future<T>? future;
139 final Widget Function(BuildContext, T) builder;142 final Widget Function(BuildContext, T) builder;
140 final VoidCallback onRetry;143 final VoidCallback onRetry;
141 144
142- /// Called when the server rejects the session, so the shell can send the
143- /// user back to sign-in instead of showing a bare error.
144- final VoidCallback? onUnauthorized;
145-
146 @override145 @override
147 Widget build(BuildContext context) {146 Widget build(BuildContext context) {
148 return FutureBuilder<T>(147 return FutureBuilder<T>(
@@ -153,9 +152,6 @@ class AsyncView<T> extends StatelessWidget {
153 }152 }
154 if (snap.hasError) {153 if (snap.hasError) {
155 final err = snap.error;154 final err = snap.error;
156- if (err is ApiException && err.isUnauthorized && onUnauthorized != null) {
157- WidgetsBinding.instance.addPostFrameCallback((_) => onUnauthorized!());
158- }
159 return ErrorView(155 return ErrorView(
160 message: err is ApiException ? err.message : 'Something went wrong.',156 message: err is ApiException ? err.message : 'Something went wrong.',
161 onRetry: onRetry,157 onRetry: onRetry,
@@ -275,3 +271,18 @@ void showToast(BuildContext context, String message) {
275 ..hideCurrentSnackBar()271 ..hideCurrentSnackBar()
276 ..showSnackBar(SnackBar(content: Text(message)));272 ..showSnackBar(SnackBar(content: Text(message)));
277 }273 }
274+
275+
276+/// Flatten HTML to the text it reads as.
277+///
278+/// For summaries and previews only. Article bodies are rendered as HTML,
279+/// which is safe because the server sanitises every body it sends.
280+String stripHtml(String html) => html
281+ .replaceAll(RegExp(r'<[^>]*>'), ' ')
282+ .replaceAll('&nbsp;', ' ')
283+ .replaceAll('&amp;', '&')
284+ .replaceAll('&lt;', '<')
285+ .replaceAll('&gt;', '>')
286+ .replaceAll('&quot;', '"')
287+ .replaceAll(RegExp(r'\s+'), ' ')
288+ .trim();
deleted app/test/live_api_test.dart +0 -64
deleted file mode 100644
@@ -1,64 +0,0 @@
1-@Tags(['live'])
2-library;
3-
4-import 'dart:io';
5-
6-import 'package:flutter_test/flutter_test.dart';
7-import 'package:shared_preferences/shared_preferences.dart';
8-import 'package:glean_app/src/api/client.dart';
9-import 'package:glean_app/src/api/session.dart';
10-
11-/// Integration checks against a real Glean server. These assert that the Dart
12-/// wire models still line up with what the Go API actually emits -- the thing
13-/// fixture-based unit tests cannot catch when the server changes.
14-///
15-/// flutter test --tags live
16-void main() {
17- // flutter_test installs an HttpOverrides whose client answers every request
18- // with a bodiless 400, so that tests never accidentally hit the network.
19- // These tests are the deliberate exception: clear it to restore real sockets.
20- setUpAll(() {
21- TestWidgetsFlutterBinding.ensureInitialized();
22- HttpOverrides.global = null;
23- SharedPreferences.setMockInitialValues({});
24- });
25-
26- const base = String.fromEnvironment(
27- 'GLEAN_BASE_URL',
28- defaultValue: 'https://codegod100--glean-serve.modal.run',
29- );
30-
31- late GleanSession session;
32- late GleanClient client;
33-
34- setUp(() {
35- session = GleanSession(baseUrl: base);
36- client = GleanClient(session);
37- });
38-
39- tearDown(() => session.close());
40-
41- test('me() parses for a signed-out visitor', () async {
42- final me = await client.me();
43- expect(me.signedIn, isFalse);
44- expect(me.user, isNull);
45- // The server issues the CSRF cookie on this call; the session must keep it
46- // or every subsequent write would be rejected by the CSRF middleware.
47- expect(session.csrfToken, isNotNull);
48- });
49-
50- test('trending() parses the paginated envelope', () async {
51- final t = await client.trending();
52- expect(t.scope, isNotEmpty);
53- expect(t.pagination.page, greaterThanOrEqualTo(1));
54- });
55-
56- test('authenticated routes reject a signed-out caller', () async {
57- // Proves ApiException carries the server's own message and flags auth
58- // failures, which is what the UI keys off to send the user to login.
59- await expectLater(
60- client.dashboard(),
61- throwsA(isA<ApiException>().having((e) => e.isUnauthorized, 'isUnauthorized', isTrue)),
62- );
63- });
64-}
deleted file mode 100644
@@ -1,64 +0,0 @@
1-@Tags(['live'])
2-library;
3-
4-import 'dart:io';
5-
6-import 'package:flutter_test/flutter_test.dart';
7-import 'package:shared_preferences/shared_preferences.dart';
8-import 'package:glean_app/src/api/client.dart';
9-import 'package:glean_app/src/api/session.dart';
10-
11-/// Integration checks against a real Glean server. These assert that the Dart
12-/// wire models still line up with what the Go API actually emits -- the thing
13-/// fixture-based unit tests cannot catch when the server changes.
14-///
15-/// flutter test --tags live
16-void main() {
17- // flutter_test installs an HttpOverrides whose client answers every request
18- // with a bodiless 400, so that tests never accidentally hit the network.
19- // These tests are the deliberate exception: clear it to restore real sockets.
20- setUpAll(() {
21- TestWidgetsFlutterBinding.ensureInitialized();
22- HttpOverrides.global = null;
23- SharedPreferences.setMockInitialValues({});
24- });
25-
26- const base = String.fromEnvironment(
27- 'GLEAN_BASE_URL',
28- defaultValue: 'https://codegod100--glean-serve.modal.run',
29- );
30-
31- late GleanSession session;
32- late GleanClient client;
33-
34- setUp(() {
35- session = GleanSession(baseUrl: base);
36- client = GleanClient(session);
37- });
38-
39- tearDown(() => session.close());
40-
41- test('me() parses for a signed-out visitor', () async {
42- final me = await client.me();
43- expect(me.signedIn, isFalse);
44- expect(me.user, isNull);
45- // The server issues the CSRF cookie on this call; the session must keep it
46- // or every subsequent write would be rejected by the CSRF middleware.
47- expect(session.csrfToken, isNotNull);
48- });
49-
50- test('trending() parses the paginated envelope', () async {
51- final t = await client.trending();
52- expect(t.scope, isNotEmpty);
53- expect(t.pagination.page, greaterThanOrEqualTo(1));
54- });
55-
56- test('authenticated routes reject a signed-out caller', () async {
57- // Proves ApiException carries the server's own message and flags auth
58- // failures, which is what the UI keys off to send the user to login.
59- await expectLater(
60- client.dashboard(),
61- throwsA(isA<ApiException>().having((e) => e.isUnauthorized, 'isUnauthorized', isTrue)),
62- );
63- });
64-}
added app/test/live_reader_test.dart +66 -0
new file mode 100644
@@ -0,0 +1,66 @@
1+@Tags(['live'])
2+library;
3+
4+import 'dart:io';
5+
6+import 'package:flutter_test/flutter_test.dart';
7+import 'package:glean_app/src/api/client.dart';
8+
9+/// Checks the client against a running reader, which is the only thing that
10+/// proves the two agree on field names. A mock returns whatever the test
11+/// author believed the server sends.
12+///
13+/// flutter test --tags live --dart-define=GLEAN_BASE_URL=http://127.0.0.1:8080
14+void main() {
15+ const base = String.fromEnvironment('GLEAN_BASE_URL',
16+ defaultValue: 'http://127.0.0.1:8080');
17+
18+ late GleanClient client;
19+
20+ setUpAll(() {
21+ TestWidgetsFlutterBinding.ensureInitialized();
22+ // flutter_test installs an HttpOverrides whose client answers every
23+ // request with a bodiless 400. These tests are the deliberate exception.
24+ HttpOverrides.global = null;
25+ });
26+
27+ setUp(() => client = GleanClient(baseUrl: base));
28+ tearDown(() => client.close());
29+
30+ test('feeds and unread parse', () async {
31+ final feeds = await client.feeds();
32+ expect(feeds, isNotEmpty);
33+ expect(feeds.first.title, isNotEmpty);
34+ expect(await client.unreadCount(), greaterThan(0));
35+ });
36+
37+ test('articles parse, with dates and sanitised bodies', () async {
38+ final items = await client.articles(limit: 5);
39+ expect(items, isNotEmpty);
40+ final a = items.first;
41+ expect(a.title, isNotEmpty);
42+ expect(a.feedTitle, isNotEmpty);
43+ // The Nim side formats these explicitly; a struct dump would not parse.
44+ expect(a.published, isNotNull);
45+
46+ final full = await client.article(a.id);
47+ expect(full.id, a.id);
48+ expect(full.content, isNot(contains('<script')));
49+ });
50+
51+ test('read state round-trips', () async {
52+ final before = await client.unreadCount();
53+ final a = (await client.articles(limit: 1)).first;
54+
55+ await client.setRead(a.id);
56+ expect(await client.unreadCount(), before - 1);
57+ expect((await client.article(a.id)).isRead, isTrue);
58+
59+ await client.setRead(a.id, read: false);
60+ expect(await client.unreadCount(), before);
61+ });
62+
63+ test('search reaches FTS5', () async {
64+ expect(await client.articles(search: 'rust'), isNotEmpty);
65+ });
66+}
new file mode 100644
@@ -0,0 +1,66 @@
1+@Tags(['live'])
2+library;
3+
4+import 'dart:io';
5+
6+import 'package:flutter_test/flutter_test.dart';
7+import 'package:glean_app/src/api/client.dart';
8+
9+/// Checks the client against a running reader, which is the only thing that
10+/// proves the two agree on field names. A mock returns whatever the test
11+/// author believed the server sends.
12+///
13+/// flutter test --tags live --dart-define=GLEAN_BASE_URL=http://127.0.0.1:8080
14+void main() {
15+ const base = String.fromEnvironment('GLEAN_BASE_URL',
16+ defaultValue: 'http://127.0.0.1:8080');
17+
18+ late GleanClient client;
19+
20+ setUpAll(() {
21+ TestWidgetsFlutterBinding.ensureInitialized();
22+ // flutter_test installs an HttpOverrides whose client answers every
23+ // request with a bodiless 400. These tests are the deliberate exception.
24+ HttpOverrides.global = null;
25+ });
26+
27+ setUp(() => client = GleanClient(baseUrl: base));
28+ tearDown(() => client.close());
29+
30+ test('feeds and unread parse', () async {
31+ final feeds = await client.feeds();
32+ expect(feeds, isNotEmpty);
33+ expect(feeds.first.title, isNotEmpty);
34+ expect(await client.unreadCount(), greaterThan(0));
35+ });
36+
37+ test('articles parse, with dates and sanitised bodies', () async {
38+ final items = await client.articles(limit: 5);
39+ expect(items, isNotEmpty);
40+ final a = items.first;
41+ expect(a.title, isNotEmpty);
42+ expect(a.feedTitle, isNotEmpty);
43+ // The Nim side formats these explicitly; a struct dump would not parse.
44+ expect(a.published, isNotNull);
45+
46+ final full = await client.article(a.id);
47+ expect(full.id, a.id);
48+ expect(full.content, isNot(contains('<script')));
49+ });
50+
51+ test('read state round-trips', () async {
52+ final before = await client.unreadCount();
53+ final a = (await client.articles(limit: 1)).first;
54+
55+ await client.setRead(a.id);
56+ expect(await client.unreadCount(), before - 1);
57+ expect((await client.article(a.id)).isRead, isTrue);
58+
59+ await client.setRead(a.id, read: false);
60+ expect(await client.unreadCount(), before);
61+ });
62+
63+ test('search reaches FTS5', () async {
64+ expect(await client.articles(search: 'rust'), isNotEmpty);
65+ });
66+}
modified app/test/widget_test.dart +103 -112
@@ -2,147 +2,138 @@ import 'dart:convert';
22
33 import 'package:flutter/material.dart';
44 import 'package:flutter_test/flutter_test.dart';
5-import 'package:http/testing.dart';
65 import 'package:http/http.dart' as http;
7-import 'package:shared_preferences/shared_preferences.dart';
6+import 'package:http/testing.dart';
87
9-import 'package:glean_app/src/api/session.dart';
8+import 'package:glean_app/src/api/client.dart';
109 import 'package:glean_app/src/app_state.dart';
1110 import 'package:glean_app/src/screens/home_shell.dart';
1211 import 'package:glean_app/src/theme.dart';
1312
14-/// Fake server covering just the routes the shell touches on startup.
15-http.Client _fakeServer({required bool signedIn, List<String>? seen}) {
13+/// A stand-in for the reader, covering the routes the shell touches.
14+http.Client fakeReader({List<String>? seen, bool empty = false}) {
1615 return MockClient((req) async {
17- final path = req.url.path;
18- seen?.add(path);
19- if (path == '/api/me') {
20- return http.Response(
21- jsonEncode({
22- 'user': signedIn
23- ? {
24- 'did': 'did:plc:abc',
25- 'handle': 'reader.bsky.social',
26- 'display_name': 'Reader',
27- 'avatar_url': '',
28- }
29- : null,
30- 'csrf_token': 'tok',
31- 'has_llm': false,
32- 'client_id': 'https://example.test/client-metadata',
33- }),
34- 200,
35- headers: {'content-type': 'application/json'},
36- );
37- }
38- if (path == '/api/trending/') {
39- return http.Response(
40- jsonEncode({
41- 'user': null,
42- 'trending': [
43- {
44- 'article_id': 1,
45- 'title': 'A trending article',
46- 'url': 'https://example.test/a',
47- 'author': '',
48- 'summary': '',
49- 'feed_url': 'https://example.test/feed',
50- 'feed_title': 'Example',
51- 'favicon_url': '',
52- 'like_count': 3,
53- 'annotation_count': 0,
54- 'has_liked': false,
55- }
56- ],
57- 'scope': 'all',
58- 'pagination': {
59- 'page': 1,
60- 'page_size': 25,
61- 'has_prev': false,
62- 'has_next': false,
63- 'prev_page': 0,
64- 'next_page': 0,
65- },
66- }),
67- 200,
68- headers: {'content-type': 'application/json'},
69- );
70- }
71- if (path == '/api/dashboard/') {
72- return http.Response(
73- jsonEncode({
74- 'user': {'did': 'did:plc:abc', 'handle': 'reader.bsky.social', 'display_name': '', 'avatar_url': ''},
75- 'subscription_count': 2,
76- 'unread_count': 7,
77- 'articles': [],
78- 'personal_trending': [],
79- 'global_trending': [],
80- 'digest_enabled': false,
81- 'has_llm': false,
82- 'now': 0,
83- }),
84- 200,
85- headers: {'content-type': 'application/json'},
86- );
16+ seen?.add('${req.method} ${req.url.path}');
17+ Map<String, String> q = req.url.queryParameters;
18+ late final Object body;
19+
20+ switch (req.url.path) {
21+ case '/feeds':
22+ if (req.method == 'GET') {
23+ body = empty
24+ ? []
25+ : [
26+ {
27+ 'feed_url': 'https://a.test/feed',
28+ 'title': 'Feed A',
29+ 'category': '',
30+ 'unread': 2,
31+ 'favicon_url': '',
32+ },
33+ ];
34+ } else {
35+ body = {'feed_url': q['url'] ?? '', 'added': 3};
36+ }
37+ case '/unread':
38+ body = {'count': empty ? 0 : 2};
39+ case '/articles':
40+ body = empty
41+ ? []
42+ : [
43+ {
44+ 'id': 1,
45+ 'feed_url': 'https://a.test/feed',
46+ 'feed_title': 'Feed A',
47+ 'title': 'First article',
48+ 'url': 'https://a.test/1',
49+ 'author': 'Alice',
50+ 'summary': '<p>a <b>summary</b></p>',
51+ 'content': '<p>body</p>',
52+ 'published': '2026-09-20T10:00:00Z',
53+ 'is_read': false,
54+ },
55+ ];
56+ case '/refresh':
57+ body = {'added': 4, 'errors': []};
58+ default:
59+ body = {'ok': true};
8760 }
88- return http.Response('{"error":"not found"}', 404,
61+ return http.Response(jsonEncode(body), 200,
8962 headers: {'content-type': 'application/json'});
9063 });
9164 }
9265
93-Future<void> _pump(WidgetTester tester,
94- {required bool signedIn, List<String>? seen}) async {
95- SharedPreferences.setMockInitialValues({});
66+Future<AppState> pump(WidgetTester tester,
67+ {List<String>? seen, bool empty = false}) async {
9668 final state = AppState(
97- session: GleanSession(
98- baseUrl: 'https://example.test',
99- client: _fakeServer(signedIn: signedIn, seen: seen),
100- ),
69+ client: GleanClient(
70+ baseUrl: 'https://reader.test',
71+ client: fakeReader(seen: seen, empty: empty)),
10172 );
102- await state.bootstrap();
103- await tester.pumpWidget(
104- AppScope(
105- state: state,
106- child: MaterialApp(theme: gleanTheme(Brightness.light), home: const HomeShell()),
73+ await tester.pumpWidget(AppScope(
74+ state: state,
75+ child: MaterialApp(
76+ theme: gleanTheme(Brightness.light),
77+ home: const HomeShell(),
10778 ),
108- );
79+ ));
10980 await tester.pumpAndSettle();
81+ return state;
11082 }
11183
11284 void main() {
113- testWidgets('a signed-out visitor gets Trending and a sign-in affordance',
114- (tester) async {
115- await _pump(tester, signedIn: false);
85+ testWidgets('articles and unread counts load on open', (tester) async {
86+ final seen = <String>[];
87+ await pump(tester, seen: seen);
88+
89+ expect(find.text('First article'), findsOneWidget);
90+ expect(find.text('Feed A'), findsWidgets);
91+ // Summaries are HTML in most feeds; the tile shows the text they read as.
92+ expect(find.textContaining('a summary'), findsOneWidget);
93+ expect(find.textContaining('<b>'), findsNothing);
11694
117- expect(find.text('Sign in'), findsOneWidget);
118- expect(find.text('A trending article'), findsOneWidget);
119- // Home and Articles would only 401 for a visitor, so they are not offered.
120- expect(find.text('Home'), findsNothing);
121- expect(find.text('Articles'), findsNothing);
95+ expect(seen, contains('GET /articles'));
96+ expect(seen, contains('GET /feeds'));
12297 });
12398
124- testWidgets('a signed-in reader gets the full tab set and dashboard counts',
99+ testWidgets('an empty reader says so rather than showing a spinner',
125100 (tester) async {
126- await _pump(tester, signedIn: true);
101+ await pump(tester, empty: true);
102+ expect(find.textContaining('Nothing here'), findsOneWidget);
103+ });
104+
105+ testWidgets('the drawer lists feeds with an all-feeds row', (tester) async {
106+ await pump(tester);
107+ await tester.tap(find.byTooltip('Open navigation menu'));
108+ await tester.pumpAndSettle();
127109
128- expect(find.text('Sign in'), findsNothing);
129- expect(find.text('Articles'), findsWidgets);
130- expect(find.text('7'), findsOneWidget);
131- expect(find.text('unread'), findsOneWidget);
110+ // The server does not send this row; the client adds it.
111+ expect(find.text('All feeds'), findsOneWidget);
112+ expect(find.text('Feed A'), findsWidgets);
132113 });
133114
134- testWidgets('tabs do not fetch until they are opened', (tester) async {
115+ testWidgets('refresh reports what arrived', (tester) async {
135116 final seen = <String>[];
136- await _pump(tester, signedIn: true, seen: seen);
117+ await pump(tester, seen: seen);
137118
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/')));
119+ await tester.tap(find.byTooltip('Refresh'));
120+ await tester.pumpAndSettle();
143121
144- await tester.tap(find.text('Feeds'));
122+ expect(seen, contains('POST /refresh'));
123+ expect(find.textContaining('4 new'), findsOneWidget);
124+ });
125+
126+ testWidgets('marking an article read updates the count without a refetch',
127+ (tester) async {
128+ final state = await pump(tester);
129+ expect(state.unread, 2);
130+
131+ // The per-article button, not the app bar's "Mark all read".
132+ await tester.tap(find.byTooltip('Mark read'));
145133 await tester.pumpAndSettle();
146- expect(seen, contains('/api/feeds/'));
134+
135+ // The tile flips locally and the sidebar count follows, rather than the
136+ // whole list being fetched again.
137+ expect(state.unread, 1);
147138 });
148139 }
@@ -2,147 +2,138 @@ import 'dart:convert';
2 2
3 import 'package:flutter/material.dart';3 import 'package:flutter/material.dart';
4 import 'package:flutter_test/flutter_test.dart';4 import 'package:flutter_test/flutter_test.dart';
5-import 'package:http/testing.dart';
6 import 'package:http/http.dart' as http;5 import 'package:http/http.dart' as http;
7-import 'package:shared_preferences/shared_preferences.dart';6+import 'package:http/testing.dart';
8 7
9-import 'package:glean_app/src/api/session.dart';8+import 'package:glean_app/src/api/client.dart';
10 import 'package:glean_app/src/app_state.dart';9 import 'package:glean_app/src/app_state.dart';
11 import 'package:glean_app/src/screens/home_shell.dart';10 import 'package:glean_app/src/screens/home_shell.dart';
12 import 'package:glean_app/src/theme.dart';11 import 'package:glean_app/src/theme.dart';
13 12
14-/// Fake server covering just the routes the shell touches on startup.13+/// A stand-in for the reader, covering the routes the shell touches.
15-http.Client _fakeServer({required bool signedIn, List<String>? seen}) {14+http.Client fakeReader({List<String>? seen, bool empty = false}) {
16 return MockClient((req) async {15 return MockClient((req) async {
17- final path = req.url.path;16+ seen?.add('${req.method} ${req.url.path}');
18- seen?.add(path);17+ Map<String, String> q = req.url.queryParameters;
19- if (path == '/api/me') {18+ late final Object body;
20- return http.Response(19+
21- jsonEncode({20+ switch (req.url.path) {
22- 'user': signedIn21+ case '/feeds':
23- ? {22+ if (req.method == 'GET') {
24- 'did': 'did:plc:abc',23+ body = empty
25- 'handle': 'reader.bsky.social',24+ ? []
26- 'display_name': 'Reader',25+ : [
27- 'avatar_url': '',26+ {
28- }27+ 'feed_url': 'https://a.test/feed',
29- : null,28+ 'title': 'Feed A',
30- 'csrf_token': 'tok',29+ 'category': '',
31- 'has_llm': false,30+ 'unread': 2,
32- 'client_id': 'https://example.test/client-metadata',31+ 'favicon_url': '',
33- }),32+ },
34- 200,33+ ];
35- headers: {'content-type': 'application/json'},34+ } else {
36- );35+ body = {'feed_url': q['url'] ?? '', 'added': 3};
37- }36+ }
38- if (path == '/api/trending/') {37+ case '/unread':
39- return http.Response(38+ body = {'count': empty ? 0 : 2};
40- jsonEncode({39+ case '/articles':
41- 'user': null,40+ body = empty
42- 'trending': [41+ ? []
43- {42+ : [
44- 'article_id': 1,43+ {
45- 'title': 'A trending article',44+ 'id': 1,
46- 'url': 'https://example.test/a',45+ 'feed_url': 'https://a.test/feed',
47- 'author': '',46+ 'feed_title': 'Feed A',
48- 'summary': '',47+ 'title': 'First article',
49- 'feed_url': 'https://example.test/feed',48+ 'url': 'https://a.test/1',
50- 'feed_title': 'Example',49+ 'author': 'Alice',
51- 'favicon_url': '',50+ 'summary': '<p>a <b>summary</b></p>',
52- 'like_count': 3,51+ 'content': '<p>body</p>',
53- 'annotation_count': 0,52+ 'published': '2026-09-20T10:00:00Z',
54- 'has_liked': false,53+ 'is_read': false,
55- }54+ },
56- ],55+ ];
57- 'scope': 'all',56+ case '/refresh':
58- 'pagination': {57+ body = {'added': 4, 'errors': []};
59- 'page': 1,58+ default:
60- 'page_size': 25,59+ body = {'ok': true};
61- 'has_prev': false,
62- 'has_next': false,
63- 'prev_page': 0,
64- 'next_page': 0,
65- },
66- }),
67- 200,
68- headers: {'content-type': 'application/json'},
69- );
70- }
71- if (path == '/api/dashboard/') {
72- return http.Response(
73- jsonEncode({
74- 'user': {'did': 'did:plc:abc', 'handle': 'reader.bsky.social', 'display_name': '', 'avatar_url': ''},
75- 'subscription_count': 2,
76- 'unread_count': 7,
77- 'articles': [],
78- 'personal_trending': [],
79- 'global_trending': [],
80- 'digest_enabled': false,
81- 'has_llm': false,
82- 'now': 0,
83- }),
84- 200,
85- headers: {'content-type': 'application/json'},
86- );
87 }60 }
88- return http.Response('{"error":"not found"}', 404,61+ return http.Response(jsonEncode(body), 200,
89 headers: {'content-type': 'application/json'});62 headers: {'content-type': 'application/json'});
90 });63 });
91 }64 }
92 65
93-Future<void> _pump(WidgetTester tester,66+Future<AppState> pump(WidgetTester tester,
94- {required bool signedIn, List<String>? seen}) async {67+ {List<String>? seen, bool empty = false}) async {
95- SharedPreferences.setMockInitialValues({});
96 final state = AppState(68 final state = AppState(
97- session: GleanSession(69+ client: GleanClient(
98- baseUrl: 'https://example.test',70+ baseUrl: 'https://reader.test',
99- client: _fakeServer(signedIn: signedIn, seen: seen),71+ client: fakeReader(seen: seen, empty: empty)),
100- ),
101 );72 );
102- await state.bootstrap();73+ await tester.pumpWidget(AppScope(
103- await tester.pumpWidget(74+ state: state,
104- AppScope(75+ child: MaterialApp(
105- state: state,76+ theme: gleanTheme(Brightness.light),
106- child: MaterialApp(theme: gleanTheme(Brightness.light), home: const HomeShell()),77+ home: const HomeShell(),
107 ),78 ),
108- );79+ ));
109 await tester.pumpAndSettle();80 await tester.pumpAndSettle();
81+ return state;
110 }82 }
111 83
112 void main() {84 void main() {
113- testWidgets('a signed-out visitor gets Trending and a sign-in affordance',85+ testWidgets('articles and unread counts load on open', (tester) async {
114- (tester) async {86+ final seen = <String>[];
115- await _pump(tester, signedIn: false);87+ await pump(tester, seen: seen);
88+
89+ expect(find.text('First article'), findsOneWidget);
90+ expect(find.text('Feed A'), findsWidgets);
91+ // Summaries are HTML in most feeds; the tile shows the text they read as.
92+ expect(find.textContaining('a summary'), findsOneWidget);
93+ expect(find.textContaining('<b>'), findsNothing);
116 94
117- expect(find.text('Sign in'), findsOneWidget);95+ expect(seen, contains('GET /articles'));
118- expect(find.text('A trending article'), findsOneWidget);96+ expect(seen, contains('GET /feeds'));
119- // Home and Articles would only 401 for a visitor, so they are not offered.
120- expect(find.text('Home'), findsNothing);
121- expect(find.text('Articles'), findsNothing);
122 });97 });
123 98
124- testWidgets('a signed-in reader gets the full tab set and dashboard counts',99+ testWidgets('an empty reader says so rather than showing a spinner',
125 (tester) async {100 (tester) async {
126- await _pump(tester, signedIn: true);101+ await pump(tester, empty: true);
102+ expect(find.textContaining('Nothing here'), findsOneWidget);
103+ });
104+
105+ testWidgets('the drawer lists feeds with an all-feeds row', (tester) async {
106+ await pump(tester);
107+ await tester.tap(find.byTooltip('Open navigation menu'));
108+ await tester.pumpAndSettle();
127 109
128- expect(find.text('Sign in'), findsNothing);110+ // The server does not send this row; the client adds it.
129- expect(find.text('Articles'), findsWidgets);111+ expect(find.text('All feeds'), findsOneWidget);
130- expect(find.text('7'), findsOneWidget);112+ expect(find.text('Feed A'), findsWidgets);
131- expect(find.text('unread'), findsOneWidget);
132 });113 });
133 114
134- testWidgets('tabs do not fetch until they are opened', (tester) async {115+ testWidgets('refresh reports what arrived', (tester) async {
135 final seen = <String>[];116 final seen = <String>[];
136- await _pump(tester, signedIn: true, seen: seen);117+ await pump(tester, seen: seen);
137 118
138- // Home is the initial tab, so only it should have fetched. Building every119+ await tester.tap(find.byTooltip('Refresh'));
139- // tab up front would fire five requests at startup.120+ await tester.pumpAndSettle();
140- expect(seen, contains('/api/dashboard/'));
141- expect(seen.where((p) => p.startsWith('/api/recs')), isEmpty);
142- expect(seen, isNot(contains('/api/feeds/')));
143 121
144- await tester.tap(find.text('Feeds'));122+ expect(seen, contains('POST /refresh'));
123+ expect(find.textContaining('4 new'), findsOneWidget);
124+ });
125+
126+ testWidgets('marking an article read updates the count without a refetch',
127+ (tester) async {
128+ final state = await pump(tester);
129+ expect(state.unread, 2);
130+
131+ // The per-article button, not the app bar's "Mark all read".
132+ await tester.tap(find.byTooltip('Mark read'));
145 await tester.pumpAndSettle();133 await tester.pumpAndSettle();
146- expect(seen, contains('/api/feeds/'));134+
135+ // The tile flips locally and the sidebar count follows, rather than the
136+ // whole list being fetched again.
137+ expect(state.unread, 1);
147 });138 });
148 }139 }
deleted app/test/wire_contract_test.dart +0 -132
deleted file mode 100644
@@ -1,132 +0,0 @@
1-import 'dart:convert';
2-
3-import 'package:flutter_test/flutter_test.dart';
4-import 'package:http/http.dart' as http;
5-import 'package:http/testing.dart';
6-import 'package:shared_preferences/shared_preferences.dart';
7-
8-import 'package:glean_app/src/api/client.dart';
9-import 'package:glean_app/src/api/session.dart';
10-
11-/// Locks the exact field names the Go handlers read.
12-///
13-/// These are invisible when wrong: the server happily accepts a request with
14-/// an unknown field and acts on the zero value, so `mark-all-read` with the
15-/// wrong key silently marks *everything* read rather than one feed. Each
16-/// expectation below cites the handler it mirrors.
17-void main() {
18- setUpAll(() => SharedPreferences.setMockInitialValues({}));
19-
20- late http.BaseRequest captured;
21- late String capturedBody;
22-
23- GleanClient clientCapturing({String responseJson = '{}'}) {
24- final mock = MockClient((req) async {
25- captured = req;
26- capturedBody = req.body;
27- return http.Response(responseJson, 200,
28- headers: {'content-type': 'application/json'});
29- });
30- return GleanClient(GleanSession(baseUrl: 'https://example.test', client: mock));
31- }
32-
33- Map<String, String> form() => Uri.splitQueryString(capturedBody);
34- Map<String, String> query() => captured.url.queryParameters;
35-
36- test('articles filters on "feed" (articles_handler.go:22)', () async {
37- await clientCapturing(responseJson: '{"articles":[]}')
38- .articles(feedUrl: 'https://ex.test/f', status: 'unread', search: 'go', sortOldest: true);
39- expect(query()['feed'], 'https://ex.test/f');
40- expect(query().containsKey('feed_url'), isFalse);
41- expect(query()['status'], 'unread');
42- expect(query()['q'], 'go');
43- expect(query()['sort'], 'oldest');
44- });
45-
46- test('mark-all-read scopes on "feed" (articles_handler.go:384)', () async {
47- await clientCapturing().markAllRead(feedUrl: 'https://ex.test/f');
48- expect(form()['feed'], 'https://ex.test/f');
49- // The dangerous case: an unknown key means an unscoped mark-all-read.
50- expect(form().containsKey('feed_url'), isFalse);
51- });
52-
53- test('add feed posts "feed_url" (feeds_handler.go:128)', () async {
54- await clientCapturing(responseJson: '{"subscription":{}}').addFeed('https://ex.test/f');
55- expect(form()['feed_url'], 'https://ex.test/f');
56- });
57-
58- test('remove feed sends "url" (feeds_handler.go:287)', () async {
59- await clientCapturing().removeFeed('https://ex.test/f');
60- expect(captured.method, 'DELETE');
61- expect(form()['url'], 'https://ex.test/f');
62- });
63-
64- test('retry feed sends "url" (feeds_handler.go:516)', () async {
65- await clientCapturing().retryFeed('https://ex.test/f');
66- expect(form()['url'], 'https://ex.test/f');
67- });
68-
69- test('OPML import uploads a file part named "opml" (feeds_handler.go:359)', () async {
70- await clientCapturing(responseJson: '{"added":3}').uploadOpml('<opml/>');
71- expect(captured.headers['content-type'], startsWith('multipart/form-data'));
72- expect(capturedBody, contains('name="opml"'));
73- });
74-
75- test('settings endpoints set a value rather than toggling '
76- '(settings_handler.go:66,85)', () async {
77- var c = clientCapturing(responseJson: '{"expanded_view":true}');
78- await c.setExpandedView(true);
79- expect(form()['expanded_view'], '1');
80-
81- c = clientCapturing(responseJson: '{"expanded_view":false}');
82- await c.setExpandedView(false);
83- expect(form()['expanded_view'], '0');
84-
85- c = clientCapturing(responseJson: '{"digest_enabled":true}');
86- await c.setDigestEnabled(true);
87- expect(form()['digest_enabled'], '1');
88- });
89-
90- test('dismiss endpoints key on article_url and target_did '
91- '(recs_handler.go:7-17)', () async {
92- await clientCapturing().dismissArticleRec('https://ex.test/a');
93- expect(form()['article_url'], 'https://ex.test/a');
94-
95- await clientCapturing().dismissPersonRec('did:plc:abc');
96- expect(form()['target_did'], 'did:plc:abc');
97-
98- await clientCapturing().dismissFeedRec('https://ex.test/f');
99- expect(form()['feed_url'], 'https://ex.test/f');
100- });
101-
102- test('unsafe methods carry the CSRF token, safe ones do not '
103- '(middleware.go:90)', () async {
104- final mock = MockClient((req) async {
105- captured = req;
106- return http.Response('{}', 200, headers: {
107- 'content-type': 'application/json',
108- 'set-cookie': 'glean_csrf=tok123; Path=/',
109- });
110- });
111- final session = GleanSession(baseUrl: 'https://example.test', client: mock);
112- final c = GleanClient(session);
113-
114- await c.me();
115- expect(captured.headers.containsKey('X-CSRF-Token'), isFalse);
116-
117- await c.refreshFeeds();
118- expect(captured.headers['X-CSRF-Token'], 'tok123');
119- expect(captured.headers['Cookie'], contains('glean_csrf=tok123'));
120- });
121-
122- test('error responses surface the server message', () async {
123- final mock = MockClient((_) async => http.Response(
124- jsonEncode({'error': 'Please enter your handle.'}), 400,
125- headers: {'content-type': 'application/json'}));
126- final c = GleanClient(GleanSession(baseUrl: 'https://example.test', client: mock));
127- await expectLater(
128- c.startAuth('x'),
129- throwsA(isA<ApiException>().having((e) => e.message, 'message', 'Please enter your handle.')),
130- );
131- });
132-}
deleted file mode 100644
@@ -1,132 +0,0 @@
1-import 'dart:convert';
2-
3-import 'package:flutter_test/flutter_test.dart';
4-import 'package:http/http.dart' as http;
5-import 'package:http/testing.dart';
6-import 'package:shared_preferences/shared_preferences.dart';
7-
8-import 'package:glean_app/src/api/client.dart';
9-import 'package:glean_app/src/api/session.dart';
10-
11-/// Locks the exact field names the Go handlers read.
12-///
13-/// These are invisible when wrong: the server happily accepts a request with
14-/// an unknown field and acts on the zero value, so `mark-all-read` with the
15-/// wrong key silently marks *everything* read rather than one feed. Each
16-/// expectation below cites the handler it mirrors.
17-void main() {
18- setUpAll(() => SharedPreferences.setMockInitialValues({}));
19-
20- late http.BaseRequest captured;
21- late String capturedBody;
22-
23- GleanClient clientCapturing({String responseJson = '{}'}) {
24- final mock = MockClient((req) async {
25- captured = req;
26- capturedBody = req.body;
27- return http.Response(responseJson, 200,
28- headers: {'content-type': 'application/json'});
29- });
30- return GleanClient(GleanSession(baseUrl: 'https://example.test', client: mock));
31- }
32-
33- Map<String, String> form() => Uri.splitQueryString(capturedBody);
34- Map<String, String> query() => captured.url.queryParameters;
35-
36- test('articles filters on "feed" (articles_handler.go:22)', () async {
37- await clientCapturing(responseJson: '{"articles":[]}')
38- .articles(feedUrl: 'https://ex.test/f', status: 'unread', search: 'go', sortOldest: true);
39- expect(query()['feed'], 'https://ex.test/f');
40- expect(query().containsKey('feed_url'), isFalse);
41- expect(query()['status'], 'unread');
42- expect(query()['q'], 'go');
43- expect(query()['sort'], 'oldest');
44- });
45-
46- test('mark-all-read scopes on "feed" (articles_handler.go:384)', () async {
47- await clientCapturing().markAllRead(feedUrl: 'https://ex.test/f');
48- expect(form()['feed'], 'https://ex.test/f');
49- // The dangerous case: an unknown key means an unscoped mark-all-read.
50- expect(form().containsKey('feed_url'), isFalse);
51- });
52-
53- test('add feed posts "feed_url" (feeds_handler.go:128)', () async {
54- await clientCapturing(responseJson: '{"subscription":{}}').addFeed('https://ex.test/f');
55- expect(form()['feed_url'], 'https://ex.test/f');
56- });
57-
58- test('remove feed sends "url" (feeds_handler.go:287)', () async {
59- await clientCapturing().removeFeed('https://ex.test/f');
60- expect(captured.method, 'DELETE');
61- expect(form()['url'], 'https://ex.test/f');
62- });
63-
64- test('retry feed sends "url" (feeds_handler.go:516)', () async {
65- await clientCapturing().retryFeed('https://ex.test/f');
66- expect(form()['url'], 'https://ex.test/f');
67- });
68-
69- test('OPML import uploads a file part named "opml" (feeds_handler.go:359)', () async {
70- await clientCapturing(responseJson: '{"added":3}').uploadOpml('<opml/>');
71- expect(captured.headers['content-type'], startsWith('multipart/form-data'));
72- expect(capturedBody, contains('name="opml"'));
73- });
74-
75- test('settings endpoints set a value rather than toggling '
76- '(settings_handler.go:66,85)', () async {
77- var c = clientCapturing(responseJson: '{"expanded_view":true}');
78- await c.setExpandedView(true);
79- expect(form()['expanded_view'], '1');
80-
81- c = clientCapturing(responseJson: '{"expanded_view":false}');
82- await c.setExpandedView(false);
83- expect(form()['expanded_view'], '0');
84-
85- c = clientCapturing(responseJson: '{"digest_enabled":true}');
86- await c.setDigestEnabled(true);
87- expect(form()['digest_enabled'], '1');
88- });
89-
90- test('dismiss endpoints key on article_url and target_did '
91- '(recs_handler.go:7-17)', () async {
92- await clientCapturing().dismissArticleRec('https://ex.test/a');
93- expect(form()['article_url'], 'https://ex.test/a');
94-
95- await clientCapturing().dismissPersonRec('did:plc:abc');
96- expect(form()['target_did'], 'did:plc:abc');
97-
98- await clientCapturing().dismissFeedRec('https://ex.test/f');
99- expect(form()['feed_url'], 'https://ex.test/f');
100- });
101-
102- test('unsafe methods carry the CSRF token, safe ones do not '
103- '(middleware.go:90)', () async {
104- final mock = MockClient((req) async {
105- captured = req;
106- return http.Response('{}', 200, headers: {
107- 'content-type': 'application/json',
108- 'set-cookie': 'glean_csrf=tok123; Path=/',
109- });
110- });
111- final session = GleanSession(baseUrl: 'https://example.test', client: mock);
112- final c = GleanClient(session);
113-
114- await c.me();
115- expect(captured.headers.containsKey('X-CSRF-Token'), isFalse);
116-
117- await c.refreshFeeds();
118- expect(captured.headers['X-CSRF-Token'], 'tok123');
119- expect(captured.headers['Cookie'], contains('glean_csrf=tok123'));
120- });
121-
122- test('error responses surface the server message', () async {
123- final mock = MockClient((_) async => http.Response(
124- jsonEncode({'error': 'Please enter your handle.'}), 400,
125- headers: {'content-type': 'application/json'}));
126- final c = GleanClient(GleanSession(baseUrl: 'https://example.test', client: mock));
127- await expectLater(
128- c.startAuth('x'),
129- throwsA(isA<ApiException>().having((e) => e.message, 'message', 'Please enter your handle.')),
130- );
131- });
132-}