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