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>
9c32350 parent: 4ecfc2c modified
app/lib/main.dart +3 -15 | @@ -21,7 +21,8 @@ class _GleanAppState extends State<GleanApp> { | ||
| 21 | 21 | @override |
| 22 | 22 | void initState() { |
| 23 | 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 | 28 | @override |
| @@ -39,23 +40,10 @@ class _GleanAppState extends State<GleanApp> { | ||
| 39 | 40 | debugShowCheckedModeBanner: false, |
| 40 | 41 | theme: gleanTheme(Brightness.light), |
| 41 | 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 | -} | |
| @@ -21,7 +21,8 @@ class _GleanAppState extends State<GleanApp> { | |||
| 21 | @override | 21 | @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 | @override | 28 | @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'; | ||
| 3 | 3 | import 'package:http/http.dart' as http; |
| 4 | 4 | |
| 5 | 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-readable | |
| 10 | -/// message in `{"error": "..."}` (writeAPIError), so surface that when present. | |
| 7 | +/// Thrown for any non-2xx response, or one carrying `{"error": …}`. | |
| 11 | 8 | class ApiException implements Exception { |
| 12 | - ApiException(this.statusCode, this.message); | |
| 13 | - | |
| 14 | - final int statusCode; | |
| 9 | + ApiException(this.message); | |
| 15 | 10 | final String message; |
| 16 | - | |
| 17 | - bool get isUnauthorized => statusCode == 401 || statusCode == 403; | |
| 18 | - | |
| 19 | 11 | @override |
| 20 | 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 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. | |
| 27 | 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) { | |
| 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; | |
| 37 | 35 | try { |
| 38 | - json = jsonDecode(body) as Map<String, dynamic>; | |
| 36 | + body = jsonDecode(res.body.isEmpty ? 'null' : res.body); | |
| 39 | 37 | } 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}).'); | |
| 43 | 45 | } |
| 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; | |
| 47 | 47 | } |
| 48 | 48 | |
| 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))); | |
| 51 | 51 | |
| 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 {})); | |
| 111 | 54 | |
| 112 | 55 | // --- feeds --- |
| 113 | 56 | |
| 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(); | |
| 116 | 61 | |
| 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; | |
| 124 | 64 | |
| 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; | |
| 131 | 71 | } |
| 132 | 72 | |
| 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}))); | |
| 135 | 75 | |
| 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>); | |
| 145 | 78 | |
| 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 --- | |
| 179 | 80 | |
| 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, | |
| 187 | 86 | }) 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(); | |
| 197 | 95 | } |
| 198 | 96 | |
| 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>); | |
| 205 | 99 | |
| 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'}); | |
| 208 | 102 | |
| 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; | |
| 248 | 107 | } |
| 249 | 108 | |
| 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 | + } | |
| 255 | 115 | |
| 256 | - Future<void> logout() => _post('/api/auth/logout'); | |
| 116 | + void close() => _client.close(); | |
| 257 | 117 | } |
| @@ -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-readable | 7 | +/// 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 | @override | 11 | @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 order | 17 | +/// 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 proxy | 38 | + 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 with | 109 | + /// Scrape the original page for feeds that only ship an excerpt. |
| 251 | - /// `{"redirect": ...}` -- either the PDS authorization endpoint, or | 110 | + 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 @@ | ||
| 1 | 1 | import 'package:flutter/widgets.dart'; |
| 2 | 2 | |
| 3 | 3 | import 'api/client.dart'; |
| 4 | -import 'api/responses.dart'; | |
| 5 | -import 'api/session.dart'; | |
| 6 | 4 | import 'models/models.dart'; |
| 7 | 5 | |
| 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. | |
| 10 | 12 | const kDefaultBaseUrl = String.fromEnvironment( |
| 11 | 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 else | |
| 16 | -/// talks through. | |
| 17 | +/// Feed list and unread counts, shared across screens. | |
| 17 | 18 | /// |
| 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. | |
| 21 | 22 | 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); | |
| 28 | 25 | |
| 29 | - final GleanSession session; | |
| 30 | - late final GleanClient client; | |
| 26 | + final GleanClient client; | |
| 31 | 27 | |
| 32 | - String get baseUrl => session.baseUrl; | |
| 33 | - | |
| 34 | - User? _user; | |
| 28 | + List<Feed> _feeds = const []; | |
| 29 | + int _unread = 0; | |
| 35 | 30 | bool _loading = true; |
| 36 | - bool _hasLlm = false; | |
| 37 | - bool _oauthConfigured = false; | |
| 38 | 31 | String? _error; |
| 39 | 32 | |
| 40 | - User? get user => _user; | |
| 41 | - bool get signedIn => _user != null; | |
| 33 | + List<Feed> get feeds => _feeds; | |
| 34 | + int get unread => _unread; | |
| 42 | 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 | 36 | String? get error => _error; |
| 50 | 37 | |
| 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]; | |
| 56 | 41 | |
| 57 | - Future<void> refreshUser() async { | |
| 42 | + Future<void> load() async { | |
| 58 | 43 | _loading = true; |
| 59 | 44 | notifyListeners(); |
| 60 | 45 | try { |
| 61 | - final me = await client.me(); | |
| 62 | - _apply(me); | |
| 46 | + _feeds = await client.feeds(); | |
| 47 | + _unread = await client.unreadCount(); | |
| 63 | 48 | _error = null; |
| 64 | 49 | } on ApiException catch (e) { |
| 65 | - _user = null; | |
| 66 | 50 | _error = e.message; |
| 67 | 51 | } on Exception { |
| 68 | - _user = null; | |
| 69 | - _error = 'Could not reach the server.'; | |
| 52 | + _error = 'Could not reach the reader.'; | |
| 70 | 53 | } finally { |
| 71 | 54 | _loading = false; |
| 72 | 55 | notifyListeners(); |
| 73 | 56 | } |
| 74 | 57 | } |
| 75 | 58 | |
| 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(); | |
| 80 | 62 | } |
| 81 | 63 | |
| 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(); | |
| 86 | 67 | } |
| 87 | 68 | |
| 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); | |
| 96 | 83 | notifyListeners(); |
| 97 | 84 | } |
| 98 | 85 | |
| 99 | 86 | @override |
| 100 | 87 | void dispose() { |
| 101 | - session.close(); | |
| 88 | + client.close(); | |
| 102 | 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 | 93 | class AppScope extends InheritedNotifier<AppState> { |
| 109 | 94 | const AppScope({super.key, required AppState state, required super.child}) |
| 110 | 95 | : super(notifier: state); |
| 111 | 96 | |
| 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!; | |
| 117 | 99 | |
| 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!; | |
| 124 | 102 | } |
| @@ -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:3000 | 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. | ||
| 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 else | 17 | +/// Feed list and unread counts, shared across screens. |
| 16 | -/// talks through. | ||
| 17 | /// | 18 | /// |
| 18 | -/// Deliberately a plain ChangeNotifier rather than a state-management package | 19 | +/// 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 every | 20 | +/// 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 mock | 23 | + 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 | @override | 86 | @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. | |
| 2 | 2 | /// |
| 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. | |
| 8 | 6 | library; |
| 9 | 7 | |
| 10 | -DateTime? _time(Object? v) => | |
| 11 | - v == null ? null : DateTime.tryParse(v as String)?.toLocal(); | |
| 12 | - | |
| 13 | 8 | String _str(Object? v) => (v as String?) ?? ''; |
| 14 | 9 | int _int(Object? v) => (v as num?)?.toInt() ?? 0; |
| 15 | -double _dbl(Object? v) => (v as num?)?.toDouble() ?? 0; | |
| 16 | 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 | 12 | class Article { |
| 51 | 13 | const Article({ |
| 52 | 14 | required this.id, |
| 53 | 15 | required this.feedUrl, |
| 54 | 16 | required this.feedTitle, |
| 55 | - required this.feedFaviconUrl, | |
| 56 | 17 | required this.title, |
| 57 | 18 | required this.url, |
| 58 | 19 | required this.author, |
| 59 | 20 | required this.summary, |
| 60 | 21 | required this.content, |
| 61 | - required this.fullContent, | |
| 62 | 22 | required this.published, |
| 63 | - required this.updated, | |
| 64 | 23 | required this.isRead, |
| 65 | - required this.likeCount, | |
| 66 | - required this.hasLiked, | |
| 67 | 24 | }); |
| 68 | 25 | |
| 69 | 26 | final int id; |
| 70 | 27 | final String feedUrl; |
| 71 | 28 | final String feedTitle; |
| 72 | - final String feedFaviconUrl; | |
| 73 | 29 | final String title; |
| 74 | 30 | final String url; |
| 75 | 31 | final String author; |
| 76 | 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 | 36 | final String content; |
| 78 | - final String fullContent; | |
| 79 | 37 | final DateTime? published; |
| 80 | - final DateTime? updated; | |
| 81 | 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 | 40 | factory Article.fromJson(Map<String, dynamic> j) => Article( |
| 91 | 41 | id: _int(j['id']), |
| 92 | 42 | feedUrl: _str(j['feed_url']), |
| 93 | 43 | feedTitle: _str(j['feed_title']), |
| 94 | - feedFaviconUrl: _str(j['feed_favicon_url']), | |
| 95 | 44 | title: _str(j['title']), |
| 96 | 45 | url: _str(j['url']), |
| 97 | 46 | author: _str(j['author']), |
| 98 | 47 | summary: _str(j['summary']), |
| 99 | 48 | 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(), | |
| 103 | 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}) => | |
| 109 | - Article( | |
| 53 | + Article copyWith({bool? isRead, String? content}) => Article( | |
| 110 | 54 | id: id, |
| 111 | 55 | feedUrl: feedUrl, |
| 112 | 56 | feedTitle: feedTitle, |
| 113 | - feedFaviconUrl: feedFaviconUrl, | |
| 114 | 57 | title: title, |
| 115 | 58 | url: url, |
| 116 | 59 | author: author, |
| 117 | 60 | summary: summary, |
| 118 | - content: content, | |
| 119 | - fullContent: fullContent ?? this.fullContent, | |
| 61 | + content: content ?? this.content, | |
| 120 | 62 | published: published, |
| 121 | - updated: updated, | |
| 122 | 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 | 68 | const Feed({ |
| 130 | 69 | required this.feedUrl, |
| 131 | 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 | 71 | required this.category, |
| 173 | - required this.addedAt, | |
| 174 | - required this.unreadCount, | |
| 72 | + required this.unread, | |
| 175 | 73 | required this.faviconUrl, |
| 176 | 74 | }); |
| 177 | 75 | |
| 178 | - final int id; | |
| 179 | 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 | 77 | 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; | |
| 261 | 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( | |
| 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: '', | |
| 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; | |
| 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; | |
| 313 | 92 | |
| 314 | - factory FeedRecommendation.fromJson(Map<String, dynamic> j) => FeedRecommendation( | |
| 93 | + factory Feed.fromJson(Map<String, dynamic> j) => Feed( | |
| 315 | 94 | feedUrl: _str(j['feed_url']), |
| 316 | 95 | 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']), | |
| 320 | 98 | faviconUrl: _str(j['favicon_url']), |
| 321 | - score: _dbl(j['score']), | |
| 322 | 99 | ); |
| 323 | 100 | } |
| 324 | 101 | |
| 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}); | |
| 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( | |
| 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(), | |
| 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); | |
| @@ -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 from | 4 | +/// 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 in | 5 | +/// 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 | - | |
| 3 | 1 | import 'package:flutter/material.dart'; |
| 4 | 2 | import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart'; |
| 5 | 3 | import 'package:url_launcher/url_launcher.dart'; |
| 6 | 4 | |
| 7 | 5 | import '../api/client.dart'; |
| 8 | -import '../api/responses.dart'; | |
| 9 | 6 | import '../app_state.dart'; |
| 10 | 7 | import '../models/models.dart'; |
| 11 | 8 | import '../theme.dart'; |
| 12 | 9 | import '../widgets/common.dart'; |
| 13 | 10 | |
| 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. | |
| 19 | 12 | class ArticleScreen extends StatefulWidget { |
| 20 | 13 | const ArticleScreen({super.key, required this.articleId}); |
| 21 | 14 | |
| @@ -26,7 +19,7 @@ class ArticleScreen extends StatefulWidget { | ||
| 26 | 19 | } |
| 27 | 20 | |
| 28 | 21 | class _ArticleScreenState extends State<ArticleScreen> { |
| 29 | - Future<ArticleDetailResponse>? _future; | |
| 22 | + Future<Article>? _future; | |
| 30 | 23 | Article? _article; |
| 31 | 24 | bool _fetching = false; |
| 32 | 25 | |
| @@ -38,49 +31,29 @@ class _ArticleScreenState extends State<ArticleScreen> { | ||
| 38 | 31 | |
| 39 | 32 | void _load() { |
| 40 | 33 | setState(() { |
| 41 | - _article = null; | |
| 42 | 34 | _future = _fetch(); |
| 43 | 35 | }); |
| 44 | 36 | } |
| 45 | 37 | |
| 46 | - Future<ArticleDetailResponse> _fetch() async { | |
| 38 | + Future<Article> _fetch() async { | |
| 47 | 39 | 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((_) {}); | |
| 54 | 46 | } |
| 55 | - return detail; | |
| 47 | + return a; | |
| 56 | 48 | } |
| 57 | 49 | |
| 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 { | |
| 79 | 51 | setState(() => _fetching = true); |
| 80 | 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 | 55 | if (!mounted) return; |
| 83 | - setState(() => _article = _article?.copyWith(fullContent: full)); | |
| 56 | + setState(() => _article = _article?.copyWith(content: text)); | |
| 84 | 57 | } on ApiException catch (e) { |
| 85 | 58 | if (mounted) showToast(context, e.message); |
| 86 | 59 | } finally { |
| @@ -88,10 +61,10 @@ class _ArticleScreenState extends State<ArticleScreen> { | ||
| 88 | 61 | } |
| 89 | 62 | } |
| 90 | 63 | |
| 91 | - Future<void> _openOriginal(String url) async { | |
| 92 | - if (url.isEmpty) return; | |
| 64 | + Future<void> _openOriginal() async { | |
| 65 | + final url = _article?.url ?? ''; | |
| 93 | 66 | final uri = Uri.tryParse(url); |
| 94 | - if (uri == null) return; | |
| 67 | + if (uri == null || url.isEmpty) return; | |
| 95 | 68 | if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) { |
| 96 | 69 | if (mounted) showToast(context, 'Could not open the link.'); |
| 97 | 70 | } |
| @@ -103,32 +76,22 @@ class _ArticleScreenState extends State<ArticleScreen> { | ||
| 103 | 76 | appBar: AppBar( |
| 104 | 77 | title: const Text('Read'), |
| 105 | 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 | 79 | IconButton( |
| 116 | 80 | tooltip: 'Open original', |
| 117 | 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 | 87 | future: _future, |
| 124 | 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) { | |
| 131 | - final a = _article ?? data.article; | |
| 94 | + Widget _body(BuildContext context, Article a) { | |
| 132 | 95 | final text = Theme.of(context).textTheme; |
| 133 | 96 | final c = GleanColors.of(context); |
| 134 | 97 | |
| @@ -137,14 +100,12 @@ class _ArticleScreenState extends State<ArticleScreen> { | ||
| 137 | 100 | children: [ |
| 138 | 101 | Row( |
| 139 | 102 | children: [ |
| 140 | - FaviconBadge(url: a.feedFaviconUrl, seed: a.feedTitle), | |
| 141 | - const SizedBox(width: 8), | |
| 142 | 103 | Expanded(child: Text(a.feedTitle, style: text.bodySmall)), |
| 143 | 104 | Text(relativeTime(a.published), style: text.bodySmall), |
| 144 | 105 | ], |
| 145 | 106 | ), |
| 146 | 107 | const SizedBox(height: 12), |
| 147 | - Text(a.title, style: text.headlineSmall), | |
| 108 | + Text(a.title.isEmpty ? '(untitled)' : a.title, style: text.headlineSmall), | |
| 148 | 109 | if (a.author.isNotEmpty) ...[ |
| 149 | 110 | const SizedBox(height: 6), |
| 150 | 111 | Text(a.author, style: text.bodySmall), |
| @@ -152,83 +113,31 @@ class _ArticleScreenState extends State<ArticleScreen> { | ||
| 152 | 113 | const SizedBox(height: 16), |
| 153 | 114 | Divider(color: c.faint, height: 1), |
| 154 | 115 | const SizedBox(height: 16), |
| 155 | - if (a.readable.isEmpty) | |
| 116 | + if (a.content.isEmpty) | |
| 156 | 117 | Text('No content in the feed.', style: text.bodySmall) |
| 157 | 118 | 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 | 122 | HtmlWidget( |
| 159 | - a.readable, | |
| 123 | + a.content, | |
| 160 | 124 | textStyle: text.bodyLarge, |
| 161 | 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 | 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) ...[ | |
| 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 | + ), | |
| 185 | 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)}'; | |
| @@ -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 is | 42 | + // 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 | else | 118 | 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 @@ | ||
| 1 | 1 | import 'package:flutter/material.dart'; |
| 2 | 2 | |
| 3 | 3 | import '../api/client.dart'; |
| 4 | -import '../api/responses.dart'; | |
| 5 | 4 | import '../app_state.dart'; |
| 6 | 5 | import '../models/models.dart'; |
| 7 | 6 | import '../theme.dart'; |
| @@ -9,31 +8,25 @@ import '../widgets/article_tile.dart'; | ||
| 9 | 8 | import '../widgets/common.dart'; |
| 10 | 9 | import 'article_screen.dart'; |
| 11 | 10 | |
| 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. | |
| 14 | 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. | |
| 18 | - final String? feedUrl; | |
| 19 | - final String? title; | |
| 15 | + final Feed feed; | |
| 20 | 16 | |
| 21 | 17 | @override |
| 22 | 18 | State<ArticlesScreen> createState() => _ArticlesScreenState(); |
| 23 | 19 | } |
| 24 | 20 | |
| 25 | 21 | class _ArticlesScreenState extends State<ArticlesScreen> { |
| 26 | - Future<ArticlesResponse>? _future; | |
| 22 | + Future<List<Article>>? _future; | |
| 27 | 23 | final _searchController = TextEditingController(); |
| 28 | 24 | |
| 29 | - int _page = 1; | |
| 30 | 25 | String _status = 'unread'; |
| 31 | - String? _category; | |
| 32 | 26 | String _search = ''; |
| 33 | - bool _sortOldest = false; | |
| 34 | 27 | |
| 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. | |
| 37 | 30 | final Map<int, Article> _patched = {}; |
| 38 | 31 | |
| 39 | 32 | @override |
| @@ -42,6 +35,12 @@ class _ArticlesScreenState extends State<ArticlesScreen> { | ||
| 42 | 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 | 44 | @override |
| 46 | 45 | void dispose() { |
| 47 | 46 | _searchController.dispose(); |
| @@ -52,177 +51,107 @@ class _ArticlesScreenState extends State<ArticlesScreen> { | ||
| 52 | 51 | setState(() { |
| 53 | 52 | _patched.clear(); |
| 54 | 53 | _future = AppScope.read(context).client.articles( |
| 55 | - page: _page, | |
| 56 | - feedUrl: widget.feedUrl, | |
| 57 | 54 | status: _status, |
| 58 | - search: _search.isEmpty ? null : _search, | |
| 59 | - category: _category, | |
| 60 | - sortOldest: _sortOldest, | |
| 55 | + feedUrl: widget.feed.feedUrl, | |
| 56 | + search: _search, | |
| 61 | 57 | ); |
| 62 | 58 | }); |
| 63 | 59 | } |
| 64 | 60 | |
| 65 | - void _goToPage(int page) { | |
| 66 | - _page = page; | |
| 67 | - _load(); | |
| 68 | - } | |
| 69 | - | |
| 70 | 61 | 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); | |
| 73 | 63 | setState(() => _patched[a.id] = a.copyWith(isRead: !a.isRead)); |
| 64 | + app.adjustUnread(a.isRead ? 1 : -1); | |
| 74 | 65 | 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); | |
| 77 | 67 | } on ApiException catch (e) { |
| 78 | 68 | if (!mounted) return; |
| 79 | 69 | setState(() => _patched[a.id] = a); |
| 70 | + app.adjustUnread(a.isRead ? -1 : 1); | |
| 80 | 71 | showToast(context, e.message); |
| 81 | 72 | } |
| 82 | 73 | } |
| 83 | 74 | |
| 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)), | |
| 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 | 88 | @override |
| 204 | 89 | 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(); | |
| 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, | |
| 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 | + ], | |
| 226 | 155 | ); |
| 227 | 156 | } |
| 228 | 157 | } |
| @@ -233,70 +162,19 @@ class _StatusFilter extends StatelessWidget { | ||
| 233 | 162 | final String status; |
| 234 | 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 | 165 | @override |
| 240 | 166 | Widget build(BuildContext context) { |
| 241 | 167 | 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 | + ], | |
| 300 | 178 | ); |
| 301 | 179 | } |
| 302 | 180 | } |
| @@ -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 app | 11 | +/// 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 | @override | 17 | @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 a | 28 | + /// 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 | @override | 32 | @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 | @override | 44 | @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 | @override | 88 | @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.isEmpty | 99 | + textInputAction: TextInputAction.search, |
| 215 | - ? null | 100 | + 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 | @override | 165 | @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 @@ | ||
| 1 | 1 | import 'package:flutter/material.dart'; |
| 2 | 2 | |
| 3 | +import '../api/client.dart'; | |
| 3 | 4 | import '../app_state.dart'; |
| 5 | +import '../models/models.dart'; | |
| 4 | 6 | import '../theme.dart'; |
| 7 | +import '../widgets/common.dart'; | |
| 5 | 8 | 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. | |
| 17 | 15 | class HomeShell extends StatefulWidget { |
| 18 | 16 | const HomeShell({super.key}); |
| 19 | 17 | |
| @@ -22,135 +20,209 @@ class HomeShell extends StatefulWidget { | ||
| 22 | 20 | } |
| 23 | 21 | |
| 24 | 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 | 110 | @override |
| 28 | 111 | Widget build(BuildContext context) { |
| 29 | 112 | final app = AppScope.of(context); |
| 30 | 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 | 115 | return Scaffold( |
| 47 | 116 | appBar: AppBar( |
| 48 | - title: Text(tabs[index].label == 'Home' ? 'glean' : tabs[index].label), | |
| 117 | + title: Text(_selected.isAll ? 'glean' : _selected.title), | |
| 49 | 118 | 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 | + ), | |
| 84 | 134 | ], |
| 85 | 135 | ), |
| 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 | + ), | |
| 92 | 152 | ), |
| 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 | + ), | |
| 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 { | |
| 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(); | |
| 127 | 193 | |
| 128 | 194 | @override |
| 129 | - State<_LazyIndexedStack> createState() => _LazyIndexedStackState(); | |
| 195 | + State<_AddFeedDialog> createState() => _AddFeedDialogState(); | |
| 130 | 196 | } |
| 131 | 197 | |
| 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(); | |
| 140 | 200 | |
| 141 | 201 | @override |
| 142 | - void didUpdateWidget(_LazyIndexedStack old) { | |
| 143 | - super.didUpdateWidget(old); | |
| 144 | - _visited.add(widget.index); | |
| 202 | + void dispose() { | |
| 203 | + _controller.dispose(); | |
| 204 | + super.dispose(); | |
| 145 | 205 | } |
| 146 | 206 | |
| 147 | 207 | @override |
| 148 | 208 | 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 | + ), | |
| 154 | 226 | ], |
| 155 | 227 | ); |
| 156 | 228 | } |
| @@ -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 | @override | 110 | @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 < 2 | 137 | + backgroundColor: c.bg, |
| 88 | - ? null | 138 | + 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 | @override | 194 | @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 | @override | 201 | @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 | @override | 207 | @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 | -} | ||
deleted
app/lib/src/screens/trending_screen.dart +0 -128 | deleted file mode 100644 | ||
| @@ -1,128 +0,0 @@ | ||
| 1 | -import 'package:flutter/material.dart'; | |
| 2 | - | |
| 3 | -import '../api/responses.dart'; | |
| 4 | -import '../app_state.dart'; | |
| 5 | -import '../theme.dart'; | |
| 6 | -import '../widgets/common.dart'; | |
| 7 | -import 'article_screen.dart'; | |
| 8 | - | |
| 9 | -/// Public trending list. This is the one signed-out screen, so it doubles as | |
| 10 | -/// the landing page for visitors who have not signed in. | |
| 11 | -class TrendingScreen extends StatefulWidget { | |
| 12 | - const TrendingScreen({super.key}); | |
| 13 | - | |
| 14 | - @override | |
| 15 | - State<TrendingScreen> createState() => _TrendingScreenState(); | |
| 16 | -} | |
| 17 | - | |
| 18 | -class _TrendingScreenState extends State<TrendingScreen> { | |
| 19 | - Future<TrendingResponse>? _future; | |
| 20 | - int _page = 1; | |
| 21 | - | |
| 22 | - @override | |
| 23 | - void initState() { | |
| 24 | - super.initState(); | |
| 25 | - _load(); | |
| 26 | - } | |
| 27 | - | |
| 28 | - void _load() { | |
| 29 | - setState(() { | |
| 30 | - _future = AppScope.read(context).client.trending(page: _page); | |
| 31 | - }); | |
| 32 | - } | |
| 33 | - | |
| 34 | - @override | |
| 35 | - Widget build(BuildContext context) { | |
| 36 | - final text = Theme.of(context).textTheme; | |
| 37 | - final c = GleanColors.of(context); | |
| 38 | - | |
| 39 | - return AsyncView<TrendingResponse>( | |
| 40 | - future: _future, | |
| 41 | - onRetry: _load, | |
| 42 | - builder: (context, data) { | |
| 43 | - if (data.trending.isEmpty) { | |
| 44 | - return const EmptyView(message: 'Nothing trending yet.'); | |
| 45 | - } | |
| 46 | - return RefreshIndicator( | |
| 47 | - onRefresh: () async => _load(), | |
| 48 | - child: ListView.builder( | |
| 49 | - itemCount: data.trending.length + 1, | |
| 50 | - itemBuilder: (context, i) { | |
| 51 | - if (i == data.trending.length) { | |
| 52 | - return Padding( | |
| 53 | - padding: const EdgeInsets.all(16), | |
| 54 | - child: Row( | |
| 55 | - mainAxisAlignment: MainAxisAlignment.spaceBetween, | |
| 56 | - children: [ | |
| 57 | - GleanButton( | |
| 58 | - label: 'Prev', | |
| 59 | - onPressed: data.pagination.hasPrev | |
| 60 | - ? () { | |
| 61 | - _page = data.pagination.prevPage; | |
| 62 | - _load(); | |
| 63 | - } | |
| 64 | - : null, | |
| 65 | - ), | |
| 66 | - GleanButton( | |
| 67 | - label: 'Next', | |
| 68 | - onPressed: data.pagination.hasNext | |
| 69 | - ? () { | |
| 70 | - _page = data.pagination.nextPage; | |
| 71 | - _load(); | |
| 72 | - } | |
| 73 | - : null, | |
| 74 | - ), | |
| 75 | - ], | |
| 76 | - ), | |
| 77 | - ); | |
| 78 | - } | |
| 79 | - final t = data.trending[i]; | |
| 80 | - return InkWell( | |
| 81 | - onTap: () => Navigator.of(context).push( | |
| 82 | - MaterialPageRoute(builder: (_) => ArticleScreen(articleId: t.articleId)), | |
| 83 | - ), | |
| 84 | - child: Container( | |
| 85 | - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), | |
| 86 | - decoration: BoxDecoration( | |
| 87 | - border: Border(bottom: BorderSide(color: c.faint, width: 1)), | |
| 88 | - ), | |
| 89 | - child: Column( | |
| 90 | - crossAxisAlignment: CrossAxisAlignment.start, | |
| 91 | - children: [ | |
| 92 | - Row( | |
| 93 | - children: [ | |
| 94 | - Text('${i + 1 + (data.pagination.page - 1) * data.pagination.pageSize}', | |
| 95 | - style: text.bodySmall), | |
| 96 | - const SizedBox(width: 10), | |
| 97 | - FaviconBadge(url: t.faviconUrl, seed: t.feedTitle, size: 14), | |
| 98 | - const SizedBox(width: 6), | |
| 99 | - Expanded( | |
| 100 | - child: Text(t.feedTitle, | |
| 101 | - maxLines: 1, | |
| 102 | - overflow: TextOverflow.ellipsis, | |
| 103 | - style: text.bodySmall), | |
| 104 | - ), | |
| 105 | - ], | |
| 106 | - ), | |
| 107 | - const SizedBox(height: 6), | |
| 108 | - Text(t.title, | |
| 109 | - style: text.bodyLarge?.copyWith(fontWeight: FontWeight.w700)), | |
| 110 | - const SizedBox(height: 8), | |
| 111 | - Row( | |
| 112 | - children: [ | |
| 113 | - GleanTag('${t.likeCount} likes'), | |
| 114 | - const SizedBox(width: 6), | |
| 115 | - if (t.annotationCount > 0) GleanTag('${t.annotationCount} notes'), | |
| 116 | - ], | |
| 117 | - ), | |
| 118 | - ], | |
| 119 | - ), | |
| 120 | - ), | |
| 121 | - ); | |
| 122 | - }, | |
| 123 | - ), | |
| 124 | - ); | |
| 125 | - }, | |
| 126 | - ); | |
| 127 | - } | |
| 128 | -} | |
| deleted file mode 100644 | |||
| @@ -1,128 +0,0 @@ | |||
| 1 | -import 'package:flutter/material.dart'; | ||
| 2 | - | ||
| 3 | -import '../api/responses.dart'; | ||
| 4 | -import '../app_state.dart'; | ||
| 5 | -import '../theme.dart'; | ||
| 6 | -import '../widgets/common.dart'; | ||
| 7 | -import 'article_screen.dart'; | ||
| 8 | - | ||
| 9 | -/// Public trending list. This is the one signed-out screen, so it doubles as | ||
| 10 | -/// the landing page for visitors who have not signed in. | ||
| 11 | -class TrendingScreen extends StatefulWidget { | ||
| 12 | - const TrendingScreen({super.key}); | ||
| 13 | - | ||
| 14 | - @override | ||
| 15 | - State<TrendingScreen> createState() => _TrendingScreenState(); | ||
| 16 | -} | ||
| 17 | - | ||
| 18 | -class _TrendingScreenState extends State<TrendingScreen> { | ||
| 19 | - Future<TrendingResponse>? _future; | ||
| 20 | - int _page = 1; | ||
| 21 | - | ||
| 22 | - @override | ||
| 23 | - void initState() { | ||
| 24 | - super.initState(); | ||
| 25 | - _load(); | ||
| 26 | - } | ||
| 27 | - | ||
| 28 | - void _load() { | ||
| 29 | - setState(() { | ||
| 30 | - _future = AppScope.read(context).client.trending(page: _page); | ||
| 31 | - }); | ||
| 32 | - } | ||
| 33 | - | ||
| 34 | - @override | ||
| 35 | - Widget build(BuildContext context) { | ||
| 36 | - final text = Theme.of(context).textTheme; | ||
| 37 | - final c = GleanColors.of(context); | ||
| 38 | - | ||
| 39 | - return AsyncView<TrendingResponse>( | ||
| 40 | - future: _future, | ||
| 41 | - onRetry: _load, | ||
| 42 | - builder: (context, data) { | ||
| 43 | - if (data.trending.isEmpty) { | ||
| 44 | - return const EmptyView(message: 'Nothing trending yet.'); | ||
| 45 | - } | ||
| 46 | - return RefreshIndicator( | ||
| 47 | - onRefresh: () async => _load(), | ||
| 48 | - child: ListView.builder( | ||
| 49 | - itemCount: data.trending.length + 1, | ||
| 50 | - itemBuilder: (context, i) { | ||
| 51 | - if (i == data.trending.length) { | ||
| 52 | - return Padding( | ||
| 53 | - padding: const EdgeInsets.all(16), | ||
| 54 | - child: Row( | ||
| 55 | - mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||
| 56 | - children: [ | ||
| 57 | - GleanButton( | ||
| 58 | - label: 'Prev', | ||
| 59 | - onPressed: data.pagination.hasPrev | ||
| 60 | - ? () { | ||
| 61 | - _page = data.pagination.prevPage; | ||
| 62 | - _load(); | ||
| 63 | - } | ||
| 64 | - : null, | ||
| 65 | - ), | ||
| 66 | - GleanButton( | ||
| 67 | - label: 'Next', | ||
| 68 | - onPressed: data.pagination.hasNext | ||
| 69 | - ? () { | ||
| 70 | - _page = data.pagination.nextPage; | ||
| 71 | - _load(); | ||
| 72 | - } | ||
| 73 | - : null, | ||
| 74 | - ), | ||
| 75 | - ], | ||
| 76 | - ), | ||
| 77 | - ); | ||
| 78 | - } | ||
| 79 | - final t = data.trending[i]; | ||
| 80 | - return InkWell( | ||
| 81 | - onTap: () => Navigator.of(context).push( | ||
| 82 | - MaterialPageRoute(builder: (_) => ArticleScreen(articleId: t.articleId)), | ||
| 83 | - ), | ||
| 84 | - child: Container( | ||
| 85 | - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), | ||
| 86 | - decoration: BoxDecoration( | ||
| 87 | - border: Border(bottom: BorderSide(color: c.faint, width: 1)), | ||
| 88 | - ), | ||
| 89 | - child: Column( | ||
| 90 | - crossAxisAlignment: CrossAxisAlignment.start, | ||
| 91 | - children: [ | ||
| 92 | - Row( | ||
| 93 | - children: [ | ||
| 94 | - Text('${i + 1 + (data.pagination.page - 1) * data.pagination.pageSize}', | ||
| 95 | - style: text.bodySmall), | ||
| 96 | - const SizedBox(width: 10), | ||
| 97 | - FaviconBadge(url: t.faviconUrl, seed: t.feedTitle, size: 14), | ||
| 98 | - const SizedBox(width: 6), | ||
| 99 | - Expanded( | ||
| 100 | - child: Text(t.feedTitle, | ||
| 101 | - maxLines: 1, | ||
| 102 | - overflow: TextOverflow.ellipsis, | ||
| 103 | - style: text.bodySmall), | ||
| 104 | - ), | ||
| 105 | - ], | ||
| 106 | - ), | ||
| 107 | - const SizedBox(height: 6), | ||
| 108 | - Text(t.title, | ||
| 109 | - style: text.bodyLarge?.copyWith(fontWeight: FontWeight.w700)), | ||
| 110 | - const SizedBox(height: 8), | ||
| 111 | - Row( | ||
| 112 | - children: [ | ||
| 113 | - GleanTag('${t.likeCount} likes'), | ||
| 114 | - const SizedBox(width: 6), | ||
| 115 | - if (t.annotationCount > 0) GleanTag('${t.annotationCount} notes'), | ||
| 116 | - ], | ||
| 117 | - ), | ||
| 118 | - ], | ||
| 119 | - ), | ||
| 120 | - ), | ||
| 121 | - ); | ||
| 122 | - }, | ||
| 123 | - ), | ||
| 124 | - ); | ||
| 125 | - }, | ||
| 126 | - ); | ||
| 127 | - } | ||
| 128 | -} | ||
modified
app/lib/src/widgets/article_tile.dart +24 -91 | @@ -4,27 +4,19 @@ import '../models/models.dart'; | ||
| 4 | 4 | import '../theme.dart'; |
| 5 | 5 | import 'common.dart'; |
| 6 | 6 | |
| 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. | |
| 11 | 9 | class ArticleTile extends StatelessWidget { |
| 12 | 10 | const ArticleTile({ |
| 13 | 11 | super.key, |
| 14 | 12 | required this.article, |
| 15 | 13 | required this.onTap, |
| 16 | 14 | this.onToggleRead, |
| 17 | - this.onToggleLike, | |
| 18 | - this.expanded = false, | |
| 19 | 15 | }); |
| 20 | 16 | |
| 21 | 17 | final Article article; |
| 22 | 18 | final VoidCallback onTap; |
| 23 | 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 | 21 | @override |
| 30 | 22 | Widget build(BuildContext context) { |
| @@ -44,15 +36,11 @@ class ArticleTile extends StatelessWidget { | ||
| 44 | 36 | children: [ |
| 45 | 37 | Row( |
| 46 | 38 | children: [ |
| 47 | - FaviconBadge(url: article.feedFaviconUrl, seed: article.feedTitle), | |
| 48 | - const SizedBox(width: 8), | |
| 49 | 39 | 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), | |
| 56 | 44 | ), |
| 57 | 45 | if (article.published != null) |
| 58 | 46 | Text(relativeTime(article.published), style: text.bodySmall), |
| @@ -62,7 +50,7 @@ class ArticleTile extends StatelessWidget { | ||
| 62 | 50 | Opacity( |
| 63 | 51 | opacity: read ? 0.55 : 1, |
| 64 | 52 | child: Text( |
| 65 | - article.title, | |
| 53 | + article.title.isEmpty ? '(untitled)' : article.title, | |
| 66 | 54 | maxLines: 3, |
| 67 | 55 | overflow: TextOverflow.ellipsis, |
| 68 | 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 | 62 | const SizedBox(height: 6), |
| 75 | 63 | 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, | |
| 78 | 67 | overflow: TextOverflow.ellipsis, |
| 79 | 68 | style: text.bodySmall, |
| 80 | 69 | ), |
| 81 | 70 | ], |
| 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, | |
| 93 | 81 | ), |
| 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 | + ), | |
| 152 | 85 | ], |
| 153 | 86 | ), |
| 154 | 87 | ), |
| @@ -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 | @override | 21 | @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 { | ||
| 126 | 126 | /// |
| 127 | 127 | /// Every screen fetches independently, so this is where retry and the |
| 128 | 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 | 133 | class AsyncView<T> extends StatelessWidget { |
| 130 | 134 | const AsyncView({ |
| 131 | 135 | super.key, |
| 132 | 136 | required this.future, |
| 133 | 137 | required this.builder, |
| 134 | 138 | required this.onRetry, |
| 135 | - this.onUnauthorized, | |
| 136 | 139 | }); |
| 137 | 140 | |
| 138 | 141 | final Future<T>? future; |
| 139 | 142 | final Widget Function(BuildContext, T) builder; |
| 140 | 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 | 145 | @override |
| 147 | 146 | Widget build(BuildContext context) { |
| 148 | 147 | return FutureBuilder<T>( |
| @@ -153,9 +152,6 @@ class AsyncView<T> extends StatelessWidget { | ||
| 153 | 152 | } |
| 154 | 153 | if (snap.hasError) { |
| 155 | 154 | final err = snap.error; |
| 156 | - if (err is ApiException && err.isUnauthorized && onUnauthorized != null) { | |
| 157 | - WidgetsBinding.instance.addPostFrameCallback((_) => onUnauthorized!()); | |
| 158 | - } | |
| 159 | 155 | return ErrorView( |
| 160 | 156 | message: err is ApiException ? err.message : 'Something went wrong.', |
| 161 | 157 | onRetry: onRetry, |
| @@ -275,3 +271,18 @@ void showToast(BuildContext context, String message) { | ||
| 275 | 271 | ..hideCurrentSnackBar() |
| 276 | 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(' ', ' ') | |
| 283 | + .replaceAll('&', '&') | |
| 284 | + .replaceAll('<', '<') | |
| 285 | + .replaceAll('>', '>') | |
| 286 | + .replaceAll('"', '"') | |
| 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 the | 127 | /// 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 | @override | 145 | @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(' ', ' ') | ||
| 283 | + .replaceAll('&', '&') | ||
| 284 | + .replaceAll('<', '<') | ||
| 285 | + .replaceAll('>', '>') | ||
| 286 | + .replaceAll('"', '"') | ||
| 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'; | ||
| 2 | 2 | |
| 3 | 3 | import 'package:flutter/material.dart'; |
| 4 | 4 | import 'package:flutter_test/flutter_test.dart'; |
| 5 | -import 'package:http/testing.dart'; | |
| 6 | 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 | 9 | import 'package:glean_app/src/app_state.dart'; |
| 11 | 10 | import 'package:glean_app/src/screens/home_shell.dart'; |
| 12 | 11 | import 'package:glean_app/src/theme.dart'; |
| 13 | 12 | |
| 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}) { | |
| 16 | 15 | 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}; | |
| 87 | 60 | } |
| 88 | - return http.Response('{"error":"not found"}', 404, | |
| 61 | + return http.Response(jsonEncode(body), 200, | |
| 89 | 62 | headers: {'content-type': 'application/json'}); |
| 90 | 63 | }); |
| 91 | 64 | } |
| 92 | 65 | |
| 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 { | |
| 96 | 68 | 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)), | |
| 101 | 72 | ); |
| 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(), | |
| 107 | 78 | ), |
| 108 | - ); | |
| 79 | + )); | |
| 109 | 80 | await tester.pumpAndSettle(); |
| 81 | + return state; | |
| 110 | 82 | } |
| 111 | 83 | |
| 112 | 84 | 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); | |
| 116 | 94 | |
| 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')); | |
| 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 | 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); | |
| 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); | |
| 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 | 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 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(); | |
| 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 | 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 | } |
| @@ -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': signedIn | 21 | + 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 every | 119 | + 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 | -} | ||