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

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

Add the Flutter client's auth flow and core screens

Session state, theme, and the first four screens: login, dashboard,
articles and the article reader, behind a tab shell that only offers Home
and Articles to a signed-in reader -- a visitor would get 401s from both,
so they get Trending and a sign-in prompt instead.

The theme is a port of web/src/app.css rather than stock Material: the web
app is deliberately brutalist (ink/paper, one accent green, 2px borders,
hard offset shadows, monospace) and the client should not look like a
different product.

Sign-in runs the existing OAuth flow in a webview and lifts the cookies out
of the platform cookie store afterwards. document.cookie is not enough --
the session cookie is HttpOnly (internal/server/session.go), so JavaScript
sees only the CSRF token, and adopting that alone would leave the app
looking signed in while every request came back 401.

Writes (read/unread, like) apply optimistically and revert if the server
disagrees, since the list should not stall on a round trip.

flutter_html is out: it analyzes cleanly but fails to compile against the
current csslib, so article bodies render through
flutter_widget_from_html_core instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandithebull committed 2026-09-20T20:18:08-07:00 Browse files
7d9686b parent: af65e66
modified app/lib/main.dart +41 -102
@@ -1,122 +1,61 @@
11 import 'package:flutter/material.dart';
22
3+import 'src/app_state.dart';
4+import 'src/screens/home_shell.dart';
5+import 'src/theme.dart';
6+
37 void main() {
4- runApp(const MyApp());
8+ runApp(const GleanApp());
59 }
610
7-class MyApp extends StatelessWidget {
8- const MyApp({super.key});
11+class GleanApp extends StatefulWidget {
12+ const GleanApp({super.key});
913
10- // This widget is the root of your application.
1114 @override
12- Widget build(BuildContext context) {
13- return MaterialApp(
14- title: 'Flutter Demo',
15- theme: ThemeData(
16- // This is the theme of your application.
17- //
18- // TRY THIS: Try running your application with "flutter run". You'll see
19- // the application has a purple toolbar. Then, without quitting the app,
20- // try changing the seedColor in the colorScheme below to Colors.green
21- // and then invoke "hot reload" (save your changes or press the "hot
22- // reload" button in a Flutter-supported IDE, or press "r" if you used
23- // the command line to start the app).
24- //
25- // Notice that the counter didn't reset back to zero; the application
26- // state is not lost during the reload. To reset the state, use hot
27- // restart instead.
28- //
29- // This works for code too, not just values: Most code changes can be
30- // tested with just a hot reload.
31- colorScheme: .fromSeed(seedColor: Colors.deepPurple),
32- ),
33- home: const MyHomePage(title: 'Flutter Demo Home Page'),
34- );
35- }
15+ State<GleanApp> createState() => _GleanAppState();
3616 }
3717
38-class MyHomePage extends StatefulWidget {
39- const MyHomePage({super.key, required this.title});
40-
41- // This widget is the home page of your application. It is stateful, meaning
42- // that it has a State object (defined below) that contains fields that affect
43- // how it looks.
44-
45- // This class is the configuration for the state. It holds the values (in this
46- // case the title) provided by the parent (in this case the App widget) and
47- // used by the build method of the State. Fields in a Widget subclass are
48- // always marked "final".
49-
50- final String title;
18+class _GleanAppState extends State<GleanApp> {
19+ final _state = AppState();
5120
5221 @override
53- State<MyHomePage> createState() => _MyHomePageState();
54-}
55-
56-class _MyHomePageState extends State<MyHomePage> {
57- int _counter = 0;
22+ void initState() {
23+ super.initState();
24+ _state.bootstrap();
25+ }
5826
59- void _incrementCounter() {
60- setState(() {
61- // This call to setState tells the Flutter framework that something has
62- // changed in this State, which causes it to rerun the build method below
63- // so that the display can reflect the updated values. If we changed
64- // _counter without calling setState(), then the build method would not be
65- // called again, and so nothing would appear to happen.
66- _counter++;
67- });
27+ @override
28+ void dispose() {
29+ _state.dispose();
30+ super.dispose();
6831 }
6932
7033 @override
7134 Widget build(BuildContext context) {
72- // This method is rerun every time setState is called, for instance as done
73- // by the _incrementCounter method above.
74- //
75- // The Flutter framework has been optimized to make rerunning build methods
76- // fast, so that you can just rebuild anything that needs updating rather
77- // than having to individually change instances of widgets.
78- return Scaffold(
79- appBar: AppBar(
80- // TRY THIS: Try changing the color here to a specific color (to
81- // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
82- // change color while the other colors stay the same.
83- backgroundColor: Theme.of(context).colorScheme.inversePrimary,
84- // Here we take the value from the MyHomePage object that was created by
85- // the App.build method, and use it to set our appbar title.
86- title: Text(widget.title),
87- ),
88- body: Center(
89- // Center is a layout widget. It takes a single child and positions it
90- // in the middle of the parent.
91- child: Column(
92- // Column is also a layout widget. It takes a list of children and
93- // arranges them vertically. By default, it sizes itself to fit its
94- // children horizontally, and tries to be as tall as its parent.
95- //
96- // Column has various properties to control how it sizes itself and
97- // how it positions its children. Here we use mainAxisAlignment to
98- // center the children vertically; the main axis here is the vertical
99- // axis because Columns are vertical (the cross axis would be
100- // horizontal).
101- //
102- // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
103- // action in the IDE, or press "p" in the console), to see the
104- // wireframe for each widget.
105- mainAxisAlignment: .center,
106- children: [
107- const Text('You have pushed the button this many times:'),
108- Text(
109- '$_counter',
110- style: Theme.of(context).textTheme.headlineMedium,
111- ),
112- ],
113- ),
114- ),
115- floatingActionButton: FloatingActionButton(
116- onPressed: _incrementCounter,
117- tooltip: 'Increment',
118- child: const Icon(Icons.add),
35+ return AppScope(
36+ state: _state,
37+ child: MaterialApp(
38+ title: 'glean',
39+ debugShowCheckedModeBanner: false,
40+ theme: gleanTheme(Brightness.light),
41+ darkTheme: gleanTheme(Brightness.dark),
42+ home: const _Root(),
11943 ),
12044 );
12145 }
12246 }
47+
48+/// Holds the splash until the persisted session has been checked, so the app
49+/// does not flash the signed-out shell at a signed-in user.
50+class _Root extends StatelessWidget {
51+ const _Root();
52+
53+ @override
54+ Widget build(BuildContext context) {
55+ final app = AppScope.of(context);
56+ if (app.loading && app.user == null) {
57+ return const Scaffold(body: Center(child: CircularProgressIndicator()));
58+ }
59+ return const HomeShell();
60+ }
61+}
@@ -1,122 +1,61 @@
1 import 'package:flutter/material.dart';1 import 'package:flutter/material.dart';
2 2
3+import 'src/app_state.dart';
4+import 'src/screens/home_shell.dart';
5+import 'src/theme.dart';
6+
3 void main() {7 void main() {
4- runApp(const MyApp());8+ runApp(const GleanApp());
5 }9 }
6 10
7-class MyApp extends StatelessWidget {11+class GleanApp extends StatefulWidget {
8- const MyApp({super.key});12+ const GleanApp({super.key});
9 13
10- // This widget is the root of your application.
11 @override14 @override
12- Widget build(BuildContext context) {15+ State<GleanApp> createState() => _GleanAppState();
13- return MaterialApp(
14- title: 'Flutter Demo',
15- theme: ThemeData(
16- // This is the theme of your application.
17- //
18- // TRY THIS: Try running your application with "flutter run". You'll see
19- // the application has a purple toolbar. Then, without quitting the app,
20- // try changing the seedColor in the colorScheme below to Colors.green
21- // and then invoke "hot reload" (save your changes or press the "hot
22- // reload" button in a Flutter-supported IDE, or press "r" if you used
23- // the command line to start the app).
24- //
25- // Notice that the counter didn't reset back to zero; the application
26- // state is not lost during the reload. To reset the state, use hot
27- // restart instead.
28- //
29- // This works for code too, not just values: Most code changes can be
30- // tested with just a hot reload.
31- colorScheme: .fromSeed(seedColor: Colors.deepPurple),
32- ),
33- home: const MyHomePage(title: 'Flutter Demo Home Page'),
34- );
35- }
36 }16 }
37 17
38-class MyHomePage extends StatefulWidget {18+class _GleanAppState extends State<GleanApp> {
39- const MyHomePage({super.key, required this.title});19+ final _state = AppState();
40-
41- // This widget is the home page of your application. It is stateful, meaning
42- // that it has a State object (defined below) that contains fields that affect
43- // how it looks.
44-
45- // This class is the configuration for the state. It holds the values (in this
46- // case the title) provided by the parent (in this case the App widget) and
47- // used by the build method of the State. Fields in a Widget subclass are
48- // always marked "final".
49-
50- final String title;
51 20
52 @override21 @override
53- State<MyHomePage> createState() => _MyHomePageState();22+ void initState() {
54-}23+ super.initState();
55-24+ _state.bootstrap();
56-class _MyHomePageState extends State<MyHomePage> {25+ }
57- int _counter = 0;
58 26
59- void _incrementCounter() {27+ @override
60- setState(() {28+ void dispose() {
61- // This call to setState tells the Flutter framework that something has29+ _state.dispose();
62- // changed in this State, which causes it to rerun the build method below30+ super.dispose();
63- // so that the display can reflect the updated values. If we changed
64- // _counter without calling setState(), then the build method would not be
65- // called again, and so nothing would appear to happen.
66- _counter++;
67- });
68 }31 }
69 32
70 @override33 @override
71 Widget build(BuildContext context) {34 Widget build(BuildContext context) {
72- // This method is rerun every time setState is called, for instance as done35+ return AppScope(
73- // by the _incrementCounter method above.36+ state: _state,
74- //37+ child: MaterialApp(
75- // The Flutter framework has been optimized to make rerunning build methods38+ title: 'glean',
76- // fast, so that you can just rebuild anything that needs updating rather39+ debugShowCheckedModeBanner: false,
77- // than having to individually change instances of widgets.40+ theme: gleanTheme(Brightness.light),
78- return Scaffold(41+ darkTheme: gleanTheme(Brightness.dark),
79- appBar: AppBar(42+ home: const _Root(),
80- // TRY THIS: Try changing the color here to a specific color (to
81- // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
82- // change color while the other colors stay the same.
83- backgroundColor: Theme.of(context).colorScheme.inversePrimary,
84- // Here we take the value from the MyHomePage object that was created by
85- // the App.build method, and use it to set our appbar title.
86- title: Text(widget.title),
87- ),
88- body: Center(
89- // Center is a layout widget. It takes a single child and positions it
90- // in the middle of the parent.
91- child: Column(
92- // Column is also a layout widget. It takes a list of children and
93- // arranges them vertically. By default, it sizes itself to fit its
94- // children horizontally, and tries to be as tall as its parent.
95- //
96- // Column has various properties to control how it sizes itself and
97- // how it positions its children. Here we use mainAxisAlignment to
98- // center the children vertically; the main axis here is the vertical
99- // axis because Columns are vertical (the cross axis would be
100- // horizontal).
101- //
102- // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
103- // action in the IDE, or press "p" in the console), to see the
104- // wireframe for each widget.
105- mainAxisAlignment: .center,
106- children: [
107- const Text('You have pushed the button this many times:'),
108- Text(
109- '$_counter',
110- style: Theme.of(context).textTheme.headlineMedium,
111- ),
112- ],
113- ),
114- ),
115- floatingActionButton: FloatingActionButton(
116- onPressed: _incrementCounter,
117- tooltip: 'Increment',
118- child: const Icon(Icons.add),
119 ),43 ),
120 );44 );
121 }45 }
122 }46 }
47+
48+/// Holds the splash until the persisted session has been checked, so the app
49+/// does not flash the signed-out shell at a signed-in user.
50+class _Root extends StatelessWidget {
51+ const _Root();
52+
53+ @override
54+ Widget build(BuildContext context) {
55+ final app = AppScope.of(context);
56+ if (app.loading && app.user == null) {
57+ return const Scaffold(body: Center(child: CircularProgressIndicator()));
58+ }
59+ return const HomeShell();
60+ }
61+}
added app/lib/src/app_state.dart +124 -0
new file mode 100644
@@ -0,0 +1,124 @@
1+import 'package:flutter/widgets.dart';
2+
3+import 'api/client.dart';
4+import 'api/responses.dart';
5+import 'api/session.dart';
6+import 'models/models.dart';
7+
8+/// Default server. Override at build time:
9+/// flutter run --dart-define=GLEAN_BASE_URL=http://10.0.2.2:3000
10+const kDefaultBaseUrl = String.fromEnvironment(
11+ 'GLEAN_BASE_URL',
12+ defaultValue: 'https://codegod100--glean-serve.modal.run',
13+);
14+
15+/// Whole-app session state: who is signed in, and the client everything else
16+/// talks through.
17+///
18+/// Deliberately a plain ChangeNotifier rather than a state-management package
19+/// -- the app has exactly one piece of global state (the session) and every
20+/// screen otherwise owns its own fetch.
21+class AppState extends ChangeNotifier {
22+ /// [session] exists so tests can inject a GleanSession backed by a mock
23+ /// http.Client; production callers only pass a base URL.
24+ AppState({String baseUrl = kDefaultBaseUrl, GleanSession? session})
25+ : session = session ?? GleanSession(baseUrl: baseUrl) {
26+ client = GleanClient(this.session);
27+ }
28+
29+ final GleanSession session;
30+ late final GleanClient client;
31+
32+ String get baseUrl => session.baseUrl;
33+
34+ User? _user;
35+ bool _loading = true;
36+ bool _hasLlm = false;
37+ bool _oauthConfigured = false;
38+ String? _error;
39+
40+ User? get user => _user;
41+ bool get signedIn => _user != null;
42+ bool get loading => _loading;
43+ bool get hasLlm => _hasLlm;
44+
45+ /// False when the server has no GLEAN_OAUTH_CLIENT_ID, in which case sign-in
46+ /// cannot complete and the login screen should say so rather than failing
47+ /// halfway through a webview.
48+ bool get oauthConfigured => _oauthConfigured;
49+ String? get error => _error;
50+
51+ /// Restore any persisted cookies, then ask the server who we are.
52+ Future<void> bootstrap() async {
53+ await session.load();
54+ await refreshUser();
55+ }
56+
57+ Future<void> refreshUser() async {
58+ _loading = true;
59+ notifyListeners();
60+ try {
61+ final me = await client.me();
62+ _apply(me);
63+ _error = null;
64+ } on ApiException catch (e) {
65+ _user = null;
66+ _error = e.message;
67+ } on Exception {
68+ _user = null;
69+ _error = 'Could not reach the server.';
70+ } finally {
71+ _loading = false;
72+ notifyListeners();
73+ }
74+ }
75+
76+ void _apply(MeResponse me) {
77+ _user = me.user;
78+ _hasLlm = me.hasLlm;
79+ _oauthConfigured = me.oauthConfigured;
80+ }
81+
82+ /// Adopt cookies captured by the OAuth webview and re-check the session.
83+ Future<void> completeSignIn(Map<String, String> cookies) async {
84+ await session.adopt(cookies);
85+ await refreshUser();
86+ }
87+
88+ Future<void> signOut() async {
89+ try {
90+ await client.logout();
91+ } on Exception {
92+ // A failed logout call still means the local session should go.
93+ }
94+ await session.clear();
95+ _user = null;
96+ notifyListeners();
97+ }
98+
99+ @override
100+ void dispose() {
101+ session.close();
102+ super.dispose();
103+ }
104+}
105+
106+/// Makes [AppState] available to the widget tree and rebuilds dependents when
107+/// it changes.
108+class AppScope extends InheritedNotifier<AppState> {
109+ const AppScope({super.key, required AppState state, required super.child})
110+ : super(notifier: state);
111+
112+ static AppState of(BuildContext context) {
113+ final scope = context.dependOnInheritedWidgetOfExactType<AppScope>();
114+ assert(scope != null, 'No AppScope above this widget.');
115+ return scope!.notifier!;
116+ }
117+
118+ /// For callbacks that need the state but should not subscribe to it.
119+ static AppState read(BuildContext context) {
120+ final scope = context.getInheritedWidgetOfExactType<AppScope>();
121+ assert(scope != null, 'No AppScope above this widget.');
122+ return scope!.notifier!;
123+ }
124+}
new file mode 100644
@@ -0,0 +1,124 @@
1+import 'package:flutter/widgets.dart';
2+
3+import 'api/client.dart';
4+import 'api/responses.dart';
5+import 'api/session.dart';
6+import 'models/models.dart';
7+
8+/// Default server. Override at build time:
9+/// flutter run --dart-define=GLEAN_BASE_URL=http://10.0.2.2:3000
10+const kDefaultBaseUrl = String.fromEnvironment(
11+ 'GLEAN_BASE_URL',
12+ defaultValue: 'https://codegod100--glean-serve.modal.run',
13+);
14+
15+/// Whole-app session state: who is signed in, and the client everything else
16+/// talks through.
17+///
18+/// Deliberately a plain ChangeNotifier rather than a state-management package
19+/// -- the app has exactly one piece of global state (the session) and every
20+/// screen otherwise owns its own fetch.
21+class AppState extends ChangeNotifier {
22+ /// [session] exists so tests can inject a GleanSession backed by a mock
23+ /// http.Client; production callers only pass a base URL.
24+ AppState({String baseUrl = kDefaultBaseUrl, GleanSession? session})
25+ : session = session ?? GleanSession(baseUrl: baseUrl) {
26+ client = GleanClient(this.session);
27+ }
28+
29+ final GleanSession session;
30+ late final GleanClient client;
31+
32+ String get baseUrl => session.baseUrl;
33+
34+ User? _user;
35+ bool _loading = true;
36+ bool _hasLlm = false;
37+ bool _oauthConfigured = false;
38+ String? _error;
39+
40+ User? get user => _user;
41+ bool get signedIn => _user != null;
42+ bool get loading => _loading;
43+ bool get hasLlm => _hasLlm;
44+
45+ /// False when the server has no GLEAN_OAUTH_CLIENT_ID, in which case sign-in
46+ /// cannot complete and the login screen should say so rather than failing
47+ /// halfway through a webview.
48+ bool get oauthConfigured => _oauthConfigured;
49+ String? get error => _error;
50+
51+ /// Restore any persisted cookies, then ask the server who we are.
52+ Future<void> bootstrap() async {
53+ await session.load();
54+ await refreshUser();
55+ }
56+
57+ Future<void> refreshUser() async {
58+ _loading = true;
59+ notifyListeners();
60+ try {
61+ final me = await client.me();
62+ _apply(me);
63+ _error = null;
64+ } on ApiException catch (e) {
65+ _user = null;
66+ _error = e.message;
67+ } on Exception {
68+ _user = null;
69+ _error = 'Could not reach the server.';
70+ } finally {
71+ _loading = false;
72+ notifyListeners();
73+ }
74+ }
75+
76+ void _apply(MeResponse me) {
77+ _user = me.user;
78+ _hasLlm = me.hasLlm;
79+ _oauthConfigured = me.oauthConfigured;
80+ }
81+
82+ /// Adopt cookies captured by the OAuth webview and re-check the session.
83+ Future<void> completeSignIn(Map<String, String> cookies) async {
84+ await session.adopt(cookies);
85+ await refreshUser();
86+ }
87+
88+ Future<void> signOut() async {
89+ try {
90+ await client.logout();
91+ } on Exception {
92+ // A failed logout call still means the local session should go.
93+ }
94+ await session.clear();
95+ _user = null;
96+ notifyListeners();
97+ }
98+
99+ @override
100+ void dispose() {
101+ session.close();
102+ super.dispose();
103+ }
104+}
105+
106+/// Makes [AppState] available to the widget tree and rebuilds dependents when
107+/// it changes.
108+class AppScope extends InheritedNotifier<AppState> {
109+ const AppScope({super.key, required AppState state, required super.child})
110+ : super(notifier: state);
111+
112+ static AppState of(BuildContext context) {
113+ final scope = context.dependOnInheritedWidgetOfExactType<AppScope>();
114+ assert(scope != null, 'No AppScope above this widget.');
115+ return scope!.notifier!;
116+ }
117+
118+ /// For callbacks that need the state but should not subscribe to it.
119+ static AppState read(BuildContext context) {
120+ final scope = context.getInheritedWidgetOfExactType<AppScope>();
121+ assert(scope != null, 'No AppScope above this widget.');
122+ return scope!.notifier!;
123+ }
124+}
added app/lib/src/screens/article_screen.dart +234 -0
new file mode 100644
@@ -0,0 +1,234 @@
1+import 'dart:async';
2+
3+import 'package:flutter/material.dart';
4+import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart';
5+import 'package:url_launcher/url_launcher.dart';
6+
7+import '../api/client.dart';
8+import '../api/responses.dart';
9+import '../app_state.dart';
10+import '../models/models.dart';
11+import '../theme.dart';
12+import '../widgets/common.dart';
13+
14+/// Reader view for one article, plus its annotations.
15+///
16+/// Feeds usually carry truncated HTML, so this offers the server's scraper
17+/// (/api/articles/{id}/fetch-content) rather than sending the reader to the
18+/// browser for the full text.
19+class ArticleScreen extends StatefulWidget {
20+ const ArticleScreen({super.key, required this.articleId});
21+
22+ final int articleId;
23+
24+ @override
25+ State<ArticleScreen> createState() => _ArticleScreenState();
26+}
27+
28+class _ArticleScreenState extends State<ArticleScreen> {
29+ Future<ArticleDetailResponse>? _future;
30+ Article? _article;
31+ bool _fetching = false;
32+
33+ @override
34+ void initState() {
35+ super.initState();
36+ _load();
37+ }
38+
39+ void _load() {
40+ setState(() {
41+ _article = null;
42+ _future = _fetch();
43+ });
44+ }
45+
46+ Future<ArticleDetailResponse> _fetch() async {
47+ final client = AppScope.read(context).client;
48+ final detail = await client.article(widget.articleId);
49+ _article = detail.article;
50+ // Opening an article is what marks it read in the web app too. It is
51+ // best-effort: failing to record it must not break the reader.
52+ if (!detail.article.isRead) {
53+ unawaited(client.markRead(widget.articleId).catchError((_) => false));
54+ }
55+ return detail;
56+ }
57+
58+ Future<void> _toggleLike() async {
59+ final a = _article;
60+ if (a == null) return;
61+ final client = AppScope.read(context).client;
62+ setState(() => _article = a.copyWith(
63+ hasLiked: !a.hasLiked,
64+ likeCount: a.likeCount + (a.hasLiked ? -1 : 1),
65+ ));
66+ try {
67+ final r = await client.likeArticle(a.id);
68+ if (mounted) {
69+ setState(() => _article = a.copyWith(hasLiked: r.liked, likeCount: r.likeCount));
70+ }
71+ } on ApiException catch (e) {
72+ if (!mounted) return;
73+ setState(() => _article = a);
74+ showToast(context, e.message);
75+ }
76+ }
77+
78+ Future<void> _fetchFullContent() async {
79+ setState(() => _fetching = true);
80+ try {
81+ final full = await AppScope.read(context).client.fetchContent(widget.articleId);
82+ if (!mounted) return;
83+ setState(() => _article = _article?.copyWith(fullContent: full));
84+ } on ApiException catch (e) {
85+ if (mounted) showToast(context, e.message);
86+ } finally {
87+ if (mounted) setState(() => _fetching = false);
88+ }
89+ }
90+
91+ Future<void> _openOriginal(String url) async {
92+ if (url.isEmpty) return;
93+ final uri = Uri.tryParse(url);
94+ if (uri == null) return;
95+ if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) {
96+ if (mounted) showToast(context, 'Could not open the link.');
97+ }
98+ }
99+
100+ @override
101+ Widget build(BuildContext context) {
102+ return Scaffold(
103+ appBar: AppBar(
104+ title: const Text('Read'),
105+ actions: [
106+ if (_article != null)
107+ IconButton(
108+ tooltip: _article!.hasLiked ? 'Unlike' : 'Like',
109+ icon: Icon(
110+ _article!.hasLiked ? Icons.favorite : Icons.favorite_border,
111+ color: _article!.hasLiked ? GleanColors.of(context).danger : null,
112+ ),
113+ onPressed: _toggleLike,
114+ ),
115+ IconButton(
116+ tooltip: 'Open original',
117+ icon: const Icon(Icons.open_in_new),
118+ onPressed: () => _openOriginal(_article?.url ?? ''),
119+ ),
120+ ],
121+ ),
122+ body: AsyncView<ArticleDetailResponse>(
123+ future: _future,
124+ onRetry: _load,
125+ builder: (context, data) => _body(context, data),
126+ ),
127+ );
128+ }
129+
130+ Widget _body(BuildContext context, ArticleDetailResponse data) {
131+ final a = _article ?? data.article;
132+ final text = Theme.of(context).textTheme;
133+ final c = GleanColors.of(context);
134+
135+ return ListView(
136+ padding: const EdgeInsets.all(16),
137+ children: [
138+ Row(
139+ children: [
140+ FaviconBadge(url: a.feedFaviconUrl, seed: a.feedTitle),
141+ const SizedBox(width: 8),
142+ Expanded(child: Text(a.feedTitle, style: text.bodySmall)),
143+ Text(relativeTime(a.published), style: text.bodySmall),
144+ ],
145+ ),
146+ const SizedBox(height: 12),
147+ Text(a.title, style: text.headlineSmall),
148+ if (a.author.isNotEmpty) ...[
149+ const SizedBox(height: 6),
150+ Text(a.author, style: text.bodySmall),
151+ ],
152+ const SizedBox(height: 16),
153+ Divider(color: c.faint, height: 1),
154+ const SizedBox(height: 16),
155+ if (a.readable.isEmpty)
156+ Text('No content in the feed.', style: text.bodySmall)
157+ else
158+ HtmlWidget(
159+ a.readable,
160+ textStyle: text.bodyLarge,
161+ onTapUrl: (url) async {
162+ await _openOriginal(url);
163+ return true;
164+ },
165+ customStylesBuilder: (element) => switch (element.localName) {
166+ 'a' => {'color': _hex(c.accent), 'text-decoration': 'underline'},
167+ 'blockquote' => {'border-left': '3px solid ${_hex(c.faint)}', 'padding-left': '12px'},
168+ 'pre' || 'code' => {'font-size': '13px'},
169+ _ => null,
170+ },
171+ ),
172+ const SizedBox(height: 20),
173+ if (a.fullContent.isEmpty)
174+ GleanButton(
175+ label: 'Fetch full article',
176+ busy: _fetching,
177+ onPressed: _fetchFullContent,
178+ ),
179+ if (data.annotations.isNotEmpty) ...[
180+ const SizedBox(height: 28),
181+ Text('Annotations', style: text.titleMedium),
182+ const SizedBox(height: 12),
183+ for (final an in data.annotations) _AnnotationCard(annotation: an),
184+ ],
185+ const SizedBox(height: 40),
186+ ],
187+ );
188+ }
189+}
190+
191+class _AnnotationCard extends StatelessWidget {
192+ const _AnnotationCard({required this.annotation});
193+
194+ final Annotation annotation;
195+
196+ @override
197+ Widget build(BuildContext context) {
198+ final text = Theme.of(context).textTheme;
199+ return Padding(
200+ padding: const EdgeInsets.only(bottom: 12),
201+ child: GleanBox(
202+ filled: true,
203+ child: Column(
204+ crossAxisAlignment: CrossAxisAlignment.start,
205+ children: [
206+ Text('@${annotation.authorHandle}', style: text.bodySmall),
207+ if (annotation.quote.isNotEmpty) ...[
208+ const SizedBox(height: 8),
209+ Text('"${annotation.quote}"',
210+ style: text.bodyMedium?.copyWith(fontStyle: FontStyle.italic)),
211+ ],
212+ if (annotation.note.isNotEmpty) ...[
213+ const SizedBox(height: 8),
214+ Text(annotation.note, style: text.bodyMedium),
215+ ],
216+ if (annotation.tags.isNotEmpty) ...[
217+ const SizedBox(height: 8),
218+ Wrap(
219+ spacing: 6,
220+ runSpacing: 6,
221+ children: [for (final t in annotation.tags) GleanTag(t)],
222+ ),
223+ ],
224+ ],
225+ ),
226+ ),
227+ );
228+ }
229+}
230+
231+/// flutter_widget_from_html takes CSS strings, so theme colors have to be
232+/// handed over as hex rather than as Color objects.
233+String _hex(Color c) =>
234+ '#${((c.a * 255).round() << 24 | (c.r * 255).round() << 16 | (c.g * 255).round() << 8 | (c.b * 255).round()).toRadixString(16).padLeft(8, '0').substring(2)}';
new file mode 100644
@@ -0,0 +1,234 @@
1+import 'dart:async';
2+
3+import 'package:flutter/material.dart';
4+import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart';
5+import 'package:url_launcher/url_launcher.dart';
6+
7+import '../api/client.dart';
8+import '../api/responses.dart';
9+import '../app_state.dart';
10+import '../models/models.dart';
11+import '../theme.dart';
12+import '../widgets/common.dart';
13+
14+/// Reader view for one article, plus its annotations.
15+///
16+/// Feeds usually carry truncated HTML, so this offers the server's scraper
17+/// (/api/articles/{id}/fetch-content) rather than sending the reader to the
18+/// browser for the full text.
19+class ArticleScreen extends StatefulWidget {
20+ const ArticleScreen({super.key, required this.articleId});
21+
22+ final int articleId;
23+
24+ @override
25+ State<ArticleScreen> createState() => _ArticleScreenState();
26+}
27+
28+class _ArticleScreenState extends State<ArticleScreen> {
29+ Future<ArticleDetailResponse>? _future;
30+ Article? _article;
31+ bool _fetching = false;
32+
33+ @override
34+ void initState() {
35+ super.initState();
36+ _load();
37+ }
38+
39+ void _load() {
40+ setState(() {
41+ _article = null;
42+ _future = _fetch();
43+ });
44+ }
45+
46+ Future<ArticleDetailResponse> _fetch() async {
47+ final client = AppScope.read(context).client;
48+ final detail = await client.article(widget.articleId);
49+ _article = detail.article;
50+ // Opening an article is what marks it read in the web app too. It is
51+ // best-effort: failing to record it must not break the reader.
52+ if (!detail.article.isRead) {
53+ unawaited(client.markRead(widget.articleId).catchError((_) => false));
54+ }
55+ return detail;
56+ }
57+
58+ Future<void> _toggleLike() async {
59+ final a = _article;
60+ if (a == null) return;
61+ final client = AppScope.read(context).client;
62+ setState(() => _article = a.copyWith(
63+ hasLiked: !a.hasLiked,
64+ likeCount: a.likeCount + (a.hasLiked ? -1 : 1),
65+ ));
66+ try {
67+ final r = await client.likeArticle(a.id);
68+ if (mounted) {
69+ setState(() => _article = a.copyWith(hasLiked: r.liked, likeCount: r.likeCount));
70+ }
71+ } on ApiException catch (e) {
72+ if (!mounted) return;
73+ setState(() => _article = a);
74+ showToast(context, e.message);
75+ }
76+ }
77+
78+ Future<void> _fetchFullContent() async {
79+ setState(() => _fetching = true);
80+ try {
81+ final full = await AppScope.read(context).client.fetchContent(widget.articleId);
82+ if (!mounted) return;
83+ setState(() => _article = _article?.copyWith(fullContent: full));
84+ } on ApiException catch (e) {
85+ if (mounted) showToast(context, e.message);
86+ } finally {
87+ if (mounted) setState(() => _fetching = false);
88+ }
89+ }
90+
91+ Future<void> _openOriginal(String url) async {
92+ if (url.isEmpty) return;
93+ final uri = Uri.tryParse(url);
94+ if (uri == null) return;
95+ if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) {
96+ if (mounted) showToast(context, 'Could not open the link.');
97+ }
98+ }
99+
100+ @override
101+ Widget build(BuildContext context) {
102+ return Scaffold(
103+ appBar: AppBar(
104+ title: const Text('Read'),
105+ actions: [
106+ if (_article != null)
107+ IconButton(
108+ tooltip: _article!.hasLiked ? 'Unlike' : 'Like',
109+ icon: Icon(
110+ _article!.hasLiked ? Icons.favorite : Icons.favorite_border,
111+ color: _article!.hasLiked ? GleanColors.of(context).danger : null,
112+ ),
113+ onPressed: _toggleLike,
114+ ),
115+ IconButton(
116+ tooltip: 'Open original',
117+ icon: const Icon(Icons.open_in_new),
118+ onPressed: () => _openOriginal(_article?.url ?? ''),
119+ ),
120+ ],
121+ ),
122+ body: AsyncView<ArticleDetailResponse>(
123+ future: _future,
124+ onRetry: _load,
125+ builder: (context, data) => _body(context, data),
126+ ),
127+ );
128+ }
129+
130+ Widget _body(BuildContext context, ArticleDetailResponse data) {
131+ final a = _article ?? data.article;
132+ final text = Theme.of(context).textTheme;
133+ final c = GleanColors.of(context);
134+
135+ return ListView(
136+ padding: const EdgeInsets.all(16),
137+ children: [
138+ Row(
139+ children: [
140+ FaviconBadge(url: a.feedFaviconUrl, seed: a.feedTitle),
141+ const SizedBox(width: 8),
142+ Expanded(child: Text(a.feedTitle, style: text.bodySmall)),
143+ Text(relativeTime(a.published), style: text.bodySmall),
144+ ],
145+ ),
146+ const SizedBox(height: 12),
147+ Text(a.title, style: text.headlineSmall),
148+ if (a.author.isNotEmpty) ...[
149+ const SizedBox(height: 6),
150+ Text(a.author, style: text.bodySmall),
151+ ],
152+ const SizedBox(height: 16),
153+ Divider(color: c.faint, height: 1),
154+ const SizedBox(height: 16),
155+ if (a.readable.isEmpty)
156+ Text('No content in the feed.', style: text.bodySmall)
157+ else
158+ HtmlWidget(
159+ a.readable,
160+ textStyle: text.bodyLarge,
161+ onTapUrl: (url) async {
162+ await _openOriginal(url);
163+ return true;
164+ },
165+ customStylesBuilder: (element) => switch (element.localName) {
166+ 'a' => {'color': _hex(c.accent), 'text-decoration': 'underline'},
167+ 'blockquote' => {'border-left': '3px solid ${_hex(c.faint)}', 'padding-left': '12px'},
168+ 'pre' || 'code' => {'font-size': '13px'},
169+ _ => null,
170+ },
171+ ),
172+ const SizedBox(height: 20),
173+ if (a.fullContent.isEmpty)
174+ GleanButton(
175+ label: 'Fetch full article',
176+ busy: _fetching,
177+ onPressed: _fetchFullContent,
178+ ),
179+ if (data.annotations.isNotEmpty) ...[
180+ const SizedBox(height: 28),
181+ Text('Annotations', style: text.titleMedium),
182+ const SizedBox(height: 12),
183+ for (final an in data.annotations) _AnnotationCard(annotation: an),
184+ ],
185+ const SizedBox(height: 40),
186+ ],
187+ );
188+ }
189+}
190+
191+class _AnnotationCard extends StatelessWidget {
192+ const _AnnotationCard({required this.annotation});
193+
194+ final Annotation annotation;
195+
196+ @override
197+ Widget build(BuildContext context) {
198+ final text = Theme.of(context).textTheme;
199+ return Padding(
200+ padding: const EdgeInsets.only(bottom: 12),
201+ child: GleanBox(
202+ filled: true,
203+ child: Column(
204+ crossAxisAlignment: CrossAxisAlignment.start,
205+ children: [
206+ Text('@${annotation.authorHandle}', style: text.bodySmall),
207+ if (annotation.quote.isNotEmpty) ...[
208+ const SizedBox(height: 8),
209+ Text('"${annotation.quote}"',
210+ style: text.bodyMedium?.copyWith(fontStyle: FontStyle.italic)),
211+ ],
212+ if (annotation.note.isNotEmpty) ...[
213+ const SizedBox(height: 8),
214+ Text(annotation.note, style: text.bodyMedium),
215+ ],
216+ if (annotation.tags.isNotEmpty) ...[
217+ const SizedBox(height: 8),
218+ Wrap(
219+ spacing: 6,
220+ runSpacing: 6,
221+ children: [for (final t in annotation.tags) GleanTag(t)],
222+ ),
223+ ],
224+ ],
225+ ),
226+ ),
227+ );
228+ }
229+}
230+
231+/// flutter_widget_from_html takes CSS strings, so theme colors have to be
232+/// handed over as hex rather than as Color objects.
233+String _hex(Color c) =>
234+ '#${((c.a * 255).round() << 24 | (c.r * 255).round() << 16 | (c.g * 255).round() << 8 | (c.b * 255).round()).toRadixString(16).padLeft(8, '0').substring(2)}';
added app/lib/src/screens/articles_screen.dart +302 -0
new file mode 100644
@@ -0,0 +1,302 @@
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+/// The main reading list: /api/articles with the same filters the web app
13+/// exposes (status, search, category, sort, per-feed).
14+class ArticlesScreen extends StatefulWidget {
15+ const ArticlesScreen({super.key, this.feedUrl, this.title});
16+
17+ /// When set, the list is scoped to one feed.
18+ final String? feedUrl;
19+ final String? title;
20+
21+ @override
22+ State<ArticlesScreen> createState() => _ArticlesScreenState();
23+}
24+
25+class _ArticlesScreenState extends State<ArticlesScreen> {
26+ Future<ArticlesResponse>? _future;
27+ final _searchController = TextEditingController();
28+
29+ int _page = 1;
30+ String _status = 'unread';
31+ String? _category;
32+ String _search = '';
33+ bool _sortOldest = false;
34+
35+ /// Local overrides for rows the user has just acted on, so a like or a
36+ /// mark-read is reflected immediately without refetching the page.
37+ final Map<int, Article> _patched = {};
38+
39+ @override
40+ void initState() {
41+ super.initState();
42+ _load();
43+ }
44+
45+ @override
46+ void dispose() {
47+ _searchController.dispose();
48+ super.dispose();
49+ }
50+
51+ void _load() {
52+ setState(() {
53+ _patched.clear();
54+ _future = AppScope.read(context).client.articles(
55+ page: _page,
56+ feedUrl: widget.feedUrl,
57+ status: _status,
58+ search: _search.isEmpty ? null : _search,
59+ category: _category,
60+ sortOldest: _sortOldest,
61+ );
62+ });
63+ }
64+
65+ void _goToPage(int page) {
66+ _page = page;
67+ _load();
68+ }
69+
70+ Future<void> _toggleRead(Article a) async {
71+ final client = AppScope.read(context).client;
72+ // Optimistic: the row flips now, and reverts if the server disagrees.
73+ setState(() => _patched[a.id] = a.copyWith(isRead: !a.isRead));
74+ try {
75+ final isRead = a.isRead ? await client.markUnread(a.id) : await client.markRead(a.id);
76+ if (mounted) setState(() => _patched[a.id] = a.copyWith(isRead: isRead));
77+ } on ApiException catch (e) {
78+ if (!mounted) return;
79+ setState(() => _patched[a.id] = a);
80+ showToast(context, e.message);
81+ }
82+ }
83+
84+ Future<void> _toggleLike(Article a) async {
85+ final client = AppScope.read(context).client;
86+ setState(() => _patched[a.id] = a.copyWith(
87+ hasLiked: !a.hasLiked,
88+ likeCount: a.likeCount + (a.hasLiked ? -1 : 1),
89+ ));
90+ try {
91+ final r = await client.likeArticle(a.id);
92+ if (mounted) {
93+ setState(() => _patched[a.id] = a.copyWith(hasLiked: r.liked, likeCount: r.likeCount));
94+ }
95+ } on ApiException catch (e) {
96+ if (!mounted) return;
97+ setState(() => _patched[a.id] = a);
98+ showToast(context, e.message);
99+ }
100+ }
101+
102+ Future<void> _markAllRead() async {
103+ final messenger = ScaffoldMessenger.of(context);
104+ try {
105+ await AppScope.read(context).client.markAllRead(
106+ feedUrl: widget.feedUrl,
107+ category: _category,
108+ );
109+ _load();
110+ } on ApiException catch (e) {
111+ messenger.showSnackBar(SnackBar(content: Text(e.message)));
112+ }
113+ }
114+
115+ @override
116+ Widget build(BuildContext context) {
117+ return Scaffold(
118+ appBar: AppBar(
119+ title: Text(widget.title ?? 'Articles'),
120+ actions: [
121+ IconButton(
122+ tooltip: 'Mark all read',
123+ icon: const Icon(Icons.done_all),
124+ onPressed: _markAllRead,
125+ ),
126+ IconButton(
127+ tooltip: _sortOldest ? 'Newest first' : 'Oldest first',
128+ icon: Icon(_sortOldest ? Icons.arrow_upward : Icons.arrow_downward),
129+ onPressed: () {
130+ _sortOldest = !_sortOldest;
131+ _goToPage(1);
132+ },
133+ ),
134+ ],
135+ ),
136+ body: Column(
137+ children: [
138+ _SearchBar(
139+ controller: _searchController,
140+ onSubmit: (q) {
141+ _search = q;
142+ _goToPage(1);
143+ },
144+ ),
145+ _StatusFilter(
146+ status: _status,
147+ onChanged: (s) {
148+ _status = s;
149+ _goToPage(1);
150+ },
151+ ),
152+ Expanded(
153+ child: AsyncView<ArticlesResponse>(
154+ future: _future,
155+ onRetry: _load,
156+ builder: (context, data) => _list(context, data),
157+ ),
158+ ),
159+ ],
160+ ),
161+ );
162+ }
163+
164+ Widget _list(BuildContext context, ArticlesResponse data) {
165+ if (data.articles.isEmpty) {
166+ return const EmptyView(message: 'Nothing here.\nTry a different filter.');
167+ }
168+ return RefreshIndicator(
169+ onRefresh: () async => _load(),
170+ child: ListView.builder(
171+ itemCount: data.articles.length + 1,
172+ itemBuilder: (context, i) {
173+ if (i == data.articles.length) {
174+ return _Pager(pagination: data.pagination, onPage: _goToPage);
175+ }
176+ final raw = data.articles[i];
177+ final a = _patched[raw.id] ?? raw;
178+ return ArticleTile(
179+ article: a,
180+ expanded: data.expandedView,
181+ onToggleRead: () => _toggleRead(a),
182+ onToggleLike: () => _toggleLike(a),
183+ onTap: () async {
184+ await Navigator.of(context).push(
185+ MaterialPageRoute(builder: (_) => ArticleScreen(articleId: a.id)),
186+ );
187+ // Reading marks it read server-side; reflect that on return.
188+ if (mounted) setState(() => _patched[a.id] = a.copyWith(isRead: true));
189+ },
190+ );
191+ },
192+ ),
193+ );
194+ }
195+}
196+
197+class _SearchBar extends StatelessWidget {
198+ const _SearchBar({required this.controller, required this.onSubmit});
199+
200+ final TextEditingController controller;
201+ final ValueChanged<String> onSubmit;
202+
203+ @override
204+ Widget build(BuildContext context) {
205+ return Padding(
206+ padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
207+ child: TextField(
208+ controller: controller,
209+ textInputAction: TextInputAction.search,
210+ decoration: InputDecoration(
211+ hintText: 'Search articles',
212+ isDense: true,
213+ prefixIcon: const Icon(Icons.search, size: 18),
214+ suffixIcon: controller.text.isEmpty
215+ ? null
216+ : IconButton(
217+ icon: const Icon(Icons.clear, size: 18),
218+ onPressed: () {
219+ controller.clear();
220+ onSubmit('');
221+ },
222+ ),
223+ ),
224+ onSubmitted: onSubmit,
225+ ),
226+ );
227+ }
228+}
229+
230+class _StatusFilter extends StatelessWidget {
231+ const _StatusFilter({required this.status, required this.onChanged});
232+
233+ final String status;
234+ final ValueChanged<String> onChanged;
235+
236+ // Mirrors the status values handleArticles accepts.
237+ static const _options = {'unread': 'Unread', 'all': 'All', 'read': 'Read'};
238+
239+ @override
240+ Widget build(BuildContext context) {
241+ final c = GleanColors.of(context);
242+ return SizedBox(
243+ height: 40,
244+ child: ListView(
245+ scrollDirection: Axis.horizontal,
246+ padding: const EdgeInsets.symmetric(horizontal: 12),
247+ children: [
248+ for (final e in _options.entries)
249+ Padding(
250+ padding: const EdgeInsets.only(right: 8),
251+ child: GestureDetector(
252+ onTap: () => onChanged(e.key),
253+ child: Container(
254+ alignment: Alignment.center,
255+ padding: const EdgeInsets.symmetric(horizontal: 14),
256+ decoration: BoxDecoration(
257+ color: status == e.key ? c.accent : Colors.transparent,
258+ border: Border.all(color: c.border, width: 2),
259+ ),
260+ child: Text(
261+ e.value,
262+ style: Theme.of(context).textTheme.labelLarge?.copyWith(
263+ color: status == e.key ? c.accentInk : c.fg,
264+ ),
265+ ),
266+ ),
267+ ),
268+ ),
269+ ],
270+ ),
271+ );
272+ }
273+}
274+
275+class _Pager extends StatelessWidget {
276+ const _Pager({required this.pagination, required this.onPage});
277+
278+ final Pagination pagination;
279+ final ValueChanged<int> onPage;
280+
281+ @override
282+ Widget build(BuildContext context) {
283+ if (!pagination.hasPrev && !pagination.hasNext) return const SizedBox(height: 24);
284+ return Padding(
285+ padding: const EdgeInsets.all(16),
286+ child: Row(
287+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
288+ children: [
289+ GleanButton(
290+ label: 'Prev',
291+ onPressed: pagination.hasPrev ? () => onPage(pagination.prevPage) : null,
292+ ),
293+ Text('page ${pagination.page}', style: Theme.of(context).textTheme.bodySmall),
294+ GleanButton(
295+ label: 'Next',
296+ onPressed: pagination.hasNext ? () => onPage(pagination.nextPage) : null,
297+ ),
298+ ],
299+ ),
300+ );
301+ }
302+}
new file mode 100644
@@ -0,0 +1,302 @@
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+/// The main reading list: /api/articles with the same filters the web app
13+/// exposes (status, search, category, sort, per-feed).
14+class ArticlesScreen extends StatefulWidget {
15+ const ArticlesScreen({super.key, this.feedUrl, this.title});
16+
17+ /// When set, the list is scoped to one feed.
18+ final String? feedUrl;
19+ final String? title;
20+
21+ @override
22+ State<ArticlesScreen> createState() => _ArticlesScreenState();
23+}
24+
25+class _ArticlesScreenState extends State<ArticlesScreen> {
26+ Future<ArticlesResponse>? _future;
27+ final _searchController = TextEditingController();
28+
29+ int _page = 1;
30+ String _status = 'unread';
31+ String? _category;
32+ String _search = '';
33+ bool _sortOldest = false;
34+
35+ /// Local overrides for rows the user has just acted on, so a like or a
36+ /// mark-read is reflected immediately without refetching the page.
37+ final Map<int, Article> _patched = {};
38+
39+ @override
40+ void initState() {
41+ super.initState();
42+ _load();
43+ }
44+
45+ @override
46+ void dispose() {
47+ _searchController.dispose();
48+ super.dispose();
49+ }
50+
51+ void _load() {
52+ setState(() {
53+ _patched.clear();
54+ _future = AppScope.read(context).client.articles(
55+ page: _page,
56+ feedUrl: widget.feedUrl,
57+ status: _status,
58+ search: _search.isEmpty ? null : _search,
59+ category: _category,
60+ sortOldest: _sortOldest,
61+ );
62+ });
63+ }
64+
65+ void _goToPage(int page) {
66+ _page = page;
67+ _load();
68+ }
69+
70+ Future<void> _toggleRead(Article a) async {
71+ final client = AppScope.read(context).client;
72+ // Optimistic: the row flips now, and reverts if the server disagrees.
73+ setState(() => _patched[a.id] = a.copyWith(isRead: !a.isRead));
74+ try {
75+ final isRead = a.isRead ? await client.markUnread(a.id) : await client.markRead(a.id);
76+ if (mounted) setState(() => _patched[a.id] = a.copyWith(isRead: isRead));
77+ } on ApiException catch (e) {
78+ if (!mounted) return;
79+ setState(() => _patched[a.id] = a);
80+ showToast(context, e.message);
81+ }
82+ }
83+
84+ Future<void> _toggleLike(Article a) async {
85+ final client = AppScope.read(context).client;
86+ setState(() => _patched[a.id] = a.copyWith(
87+ hasLiked: !a.hasLiked,
88+ likeCount: a.likeCount + (a.hasLiked ? -1 : 1),
89+ ));
90+ try {
91+ final r = await client.likeArticle(a.id);
92+ if (mounted) {
93+ setState(() => _patched[a.id] = a.copyWith(hasLiked: r.liked, likeCount: r.likeCount));
94+ }
95+ } on ApiException catch (e) {
96+ if (!mounted) return;
97+ setState(() => _patched[a.id] = a);
98+ showToast(context, e.message);
99+ }
100+ }
101+
102+ Future<void> _markAllRead() async {
103+ final messenger = ScaffoldMessenger.of(context);
104+ try {
105+ await AppScope.read(context).client.markAllRead(
106+ feedUrl: widget.feedUrl,
107+ category: _category,
108+ );
109+ _load();
110+ } on ApiException catch (e) {
111+ messenger.showSnackBar(SnackBar(content: Text(e.message)));
112+ }
113+ }
114+
115+ @override
116+ Widget build(BuildContext context) {
117+ return Scaffold(
118+ appBar: AppBar(
119+ title: Text(widget.title ?? 'Articles'),
120+ actions: [
121+ IconButton(
122+ tooltip: 'Mark all read',
123+ icon: const Icon(Icons.done_all),
124+ onPressed: _markAllRead,
125+ ),
126+ IconButton(
127+ tooltip: _sortOldest ? 'Newest first' : 'Oldest first',
128+ icon: Icon(_sortOldest ? Icons.arrow_upward : Icons.arrow_downward),
129+ onPressed: () {
130+ _sortOldest = !_sortOldest;
131+ _goToPage(1);
132+ },
133+ ),
134+ ],
135+ ),
136+ body: Column(
137+ children: [
138+ _SearchBar(
139+ controller: _searchController,
140+ onSubmit: (q) {
141+ _search = q;
142+ _goToPage(1);
143+ },
144+ ),
145+ _StatusFilter(
146+ status: _status,
147+ onChanged: (s) {
148+ _status = s;
149+ _goToPage(1);
150+ },
151+ ),
152+ Expanded(
153+ child: AsyncView<ArticlesResponse>(
154+ future: _future,
155+ onRetry: _load,
156+ builder: (context, data) => _list(context, data),
157+ ),
158+ ),
159+ ],
160+ ),
161+ );
162+ }
163+
164+ Widget _list(BuildContext context, ArticlesResponse data) {
165+ if (data.articles.isEmpty) {
166+ return const EmptyView(message: 'Nothing here.\nTry a different filter.');
167+ }
168+ return RefreshIndicator(
169+ onRefresh: () async => _load(),
170+ child: ListView.builder(
171+ itemCount: data.articles.length + 1,
172+ itemBuilder: (context, i) {
173+ if (i == data.articles.length) {
174+ return _Pager(pagination: data.pagination, onPage: _goToPage);
175+ }
176+ final raw = data.articles[i];
177+ final a = _patched[raw.id] ?? raw;
178+ return ArticleTile(
179+ article: a,
180+ expanded: data.expandedView,
181+ onToggleRead: () => _toggleRead(a),
182+ onToggleLike: () => _toggleLike(a),
183+ onTap: () async {
184+ await Navigator.of(context).push(
185+ MaterialPageRoute(builder: (_) => ArticleScreen(articleId: a.id)),
186+ );
187+ // Reading marks it read server-side; reflect that on return.
188+ if (mounted) setState(() => _patched[a.id] = a.copyWith(isRead: true));
189+ },
190+ );
191+ },
192+ ),
193+ );
194+ }
195+}
196+
197+class _SearchBar extends StatelessWidget {
198+ const _SearchBar({required this.controller, required this.onSubmit});
199+
200+ final TextEditingController controller;
201+ final ValueChanged<String> onSubmit;
202+
203+ @override
204+ Widget build(BuildContext context) {
205+ return Padding(
206+ padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
207+ child: TextField(
208+ controller: controller,
209+ textInputAction: TextInputAction.search,
210+ decoration: InputDecoration(
211+ hintText: 'Search articles',
212+ isDense: true,
213+ prefixIcon: const Icon(Icons.search, size: 18),
214+ suffixIcon: controller.text.isEmpty
215+ ? null
216+ : IconButton(
217+ icon: const Icon(Icons.clear, size: 18),
218+ onPressed: () {
219+ controller.clear();
220+ onSubmit('');
221+ },
222+ ),
223+ ),
224+ onSubmitted: onSubmit,
225+ ),
226+ );
227+ }
228+}
229+
230+class _StatusFilter extends StatelessWidget {
231+ const _StatusFilter({required this.status, required this.onChanged});
232+
233+ final String status;
234+ final ValueChanged<String> onChanged;
235+
236+ // Mirrors the status values handleArticles accepts.
237+ static const _options = {'unread': 'Unread', 'all': 'All', 'read': 'Read'};
238+
239+ @override
240+ Widget build(BuildContext context) {
241+ final c = GleanColors.of(context);
242+ return SizedBox(
243+ height: 40,
244+ child: ListView(
245+ scrollDirection: Axis.horizontal,
246+ padding: const EdgeInsets.symmetric(horizontal: 12),
247+ children: [
248+ for (final e in _options.entries)
249+ Padding(
250+ padding: const EdgeInsets.only(right: 8),
251+ child: GestureDetector(
252+ onTap: () => onChanged(e.key),
253+ child: Container(
254+ alignment: Alignment.center,
255+ padding: const EdgeInsets.symmetric(horizontal: 14),
256+ decoration: BoxDecoration(
257+ color: status == e.key ? c.accent : Colors.transparent,
258+ border: Border.all(color: c.border, width: 2),
259+ ),
260+ child: Text(
261+ e.value,
262+ style: Theme.of(context).textTheme.labelLarge?.copyWith(
263+ color: status == e.key ? c.accentInk : c.fg,
264+ ),
265+ ),
266+ ),
267+ ),
268+ ),
269+ ],
270+ ),
271+ );
272+ }
273+}
274+
275+class _Pager extends StatelessWidget {
276+ const _Pager({required this.pagination, required this.onPage});
277+
278+ final Pagination pagination;
279+ final ValueChanged<int> onPage;
280+
281+ @override
282+ Widget build(BuildContext context) {
283+ if (!pagination.hasPrev && !pagination.hasNext) return const SizedBox(height: 24);
284+ return Padding(
285+ padding: const EdgeInsets.all(16),
286+ child: Row(
287+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
288+ children: [
289+ GleanButton(
290+ label: 'Prev',
291+ onPressed: pagination.hasPrev ? () => onPage(pagination.prevPage) : null,
292+ ),
293+ Text('page ${pagination.page}', style: Theme.of(context).textTheme.bodySmall),
294+ GleanButton(
295+ label: 'Next',
296+ onPressed: pagination.hasNext ? () => onPage(pagination.nextPage) : null,
297+ ),
298+ ],
299+ ),
300+ );
301+ }
302+}
added app/lib/src/screens/dashboard_screen.dart +192 -0
new file mode 100644
@@ -0,0 +1,192 @@
1+import 'package:flutter/material.dart';
2+
3+import '../api/responses.dart';
4+import '../app_state.dart';
5+import '../models/models.dart';
6+import '../widgets/article_tile.dart';
7+import '../widgets/common.dart';
8+import 'article_screen.dart';
9+
10+/// Signed-in landing screen: unread count, latest articles, and the two
11+/// trending rails the server computes (personal and global).
12+class DashboardScreen extends StatefulWidget {
13+ const DashboardScreen({super.key});
14+
15+ @override
16+ State<DashboardScreen> createState() => _DashboardScreenState();
17+}
18+
19+class _DashboardScreenState extends State<DashboardScreen> {
20+ Future<DashboardResponse>? _future;
21+
22+ @override
23+ void initState() {
24+ super.initState();
25+ _load();
26+ }
27+
28+ void _load() {
29+ setState(() {
30+ _future = AppScope.read(context).client.dashboard();
31+ });
32+ }
33+
34+ @override
35+ Widget build(BuildContext context) {
36+ return AsyncView<DashboardResponse>(
37+ future: _future,
38+ onRetry: _load,
39+ builder: (context, data) => RefreshIndicator(
40+ onRefresh: () async => _load(),
41+ child: ListView(
42+ padding: const EdgeInsets.only(bottom: 32),
43+ children: [
44+ _Counts(data: data),
45+ if (data.articles.isNotEmpty) ...[
46+ const _SectionHeading('Latest'),
47+ for (final a in data.articles)
48+ ArticleTile(
49+ article: a,
50+ onTap: () => Navigator.of(context).push(
51+ MaterialPageRoute(builder: (_) => ArticleScreen(articleId: a.id)),
52+ ),
53+ ),
54+ ] else
55+ const Padding(
56+ padding: EdgeInsets.all(24),
57+ child: EmptyView(message: 'No unread articles.\nSubscribe to a feed to get started.'),
58+ ),
59+ if (data.personalTrending.isNotEmpty) ...[
60+ const _SectionHeading('Trending in your feeds'),
61+ _TrendingRail(items: data.personalTrending),
62+ ],
63+ if (data.globalTrending.isNotEmpty) ...[
64+ const _SectionHeading('Trending everywhere'),
65+ _TrendingRail(items: data.globalTrending),
66+ ],
67+ ],
68+ ),
69+ ),
70+ );
71+ }
72+}
73+
74+class _Counts extends StatelessWidget {
75+ const _Counts({required this.data});
76+
77+ final DashboardResponse data;
78+
79+ @override
80+ Widget build(BuildContext context) {
81+ return Padding(
82+ padding: const EdgeInsets.all(16),
83+ child: Row(
84+ children: [
85+ Expanded(child: _Stat(value: '${data.unreadCount}', label: 'unread')),
86+ const SizedBox(width: 12),
87+ Expanded(child: _Stat(value: '${data.subscriptionCount}', label: 'feeds')),
88+ ],
89+ ),
90+ );
91+ }
92+}
93+
94+class _Stat extends StatelessWidget {
95+ const _Stat({required this.value, required this.label});
96+
97+ final String value;
98+ final String label;
99+
100+ @override
101+ Widget build(BuildContext context) {
102+ final text = Theme.of(context).textTheme;
103+ return GleanBox(
104+ filled: true,
105+ child: Column(
106+ crossAxisAlignment: CrossAxisAlignment.start,
107+ children: [
108+ Text(value, style: text.displaySmall),
109+ Text(label, style: text.bodySmall),
110+ ],
111+ ),
112+ );
113+ }
114+}
115+
116+class _SectionHeading extends StatelessWidget {
117+ const _SectionHeading(this.label);
118+
119+ final String label;
120+
121+ @override
122+ Widget build(BuildContext context) {
123+ return Padding(
124+ padding: const EdgeInsets.fromLTRB(16, 20, 16, 10),
125+ child: Text(label, style: Theme.of(context).textTheme.titleMedium),
126+ );
127+ }
128+}
129+
130+/// Horizontal rail of trending articles.
131+class _TrendingRail extends StatelessWidget {
132+ const _TrendingRail({required this.items});
133+
134+ final List<TrendingItem> items;
135+
136+ @override
137+ Widget build(BuildContext context) {
138+ final text = Theme.of(context).textTheme;
139+ return SizedBox(
140+ height: 168,
141+ child: ListView.separated(
142+ scrollDirection: Axis.horizontal,
143+ padding: const EdgeInsets.symmetric(horizontal: 16),
144+ itemCount: items.length,
145+ separatorBuilder: (_, _) => const SizedBox(width: 12),
146+ itemBuilder: (context, i) {
147+ final t = items[i];
148+ return SizedBox(
149+ width: 240,
150+ child: GleanBox(
151+ onTap: () => Navigator.of(context).push(
152+ MaterialPageRoute(builder: (_) => ArticleScreen(articleId: t.articleId)),
153+ ),
154+ child: Column(
155+ crossAxisAlignment: CrossAxisAlignment.start,
156+ children: [
157+ Row(
158+ children: [
159+ FaviconBadge(url: t.faviconUrl, seed: t.feedTitle, size: 14),
160+ const SizedBox(width: 6),
161+ Expanded(
162+ child: Text(t.feedTitle,
163+ maxLines: 1, overflow: TextOverflow.ellipsis, style: text.bodySmall),
164+ ),
165+ ],
166+ ),
167+ const SizedBox(height: 8),
168+ Expanded(
169+ child: Text(
170+ t.title,
171+ maxLines: 4,
172+ overflow: TextOverflow.ellipsis,
173+ style: text.bodyMedium?.copyWith(fontWeight: FontWeight.w700),
174+ ),
175+ ),
176+ const SizedBox(height: 6),
177+ Row(
178+ children: [
179+ GleanTag('${t.likeCount} likes'),
180+ const SizedBox(width: 6),
181+ if (t.annotationCount > 0) GleanTag('${t.annotationCount} notes'),
182+ ],
183+ ),
184+ ],
185+ ),
186+ ),
187+ );
188+ },
189+ ),
190+ );
191+ }
192+}
new file mode 100644
@@ -0,0 +1,192 @@
1+import 'package:flutter/material.dart';
2+
3+import '../api/responses.dart';
4+import '../app_state.dart';
5+import '../models/models.dart';
6+import '../widgets/article_tile.dart';
7+import '../widgets/common.dart';
8+import 'article_screen.dart';
9+
10+/// Signed-in landing screen: unread count, latest articles, and the two
11+/// trending rails the server computes (personal and global).
12+class DashboardScreen extends StatefulWidget {
13+ const DashboardScreen({super.key});
14+
15+ @override
16+ State<DashboardScreen> createState() => _DashboardScreenState();
17+}
18+
19+class _DashboardScreenState extends State<DashboardScreen> {
20+ Future<DashboardResponse>? _future;
21+
22+ @override
23+ void initState() {
24+ super.initState();
25+ _load();
26+ }
27+
28+ void _load() {
29+ setState(() {
30+ _future = AppScope.read(context).client.dashboard();
31+ });
32+ }
33+
34+ @override
35+ Widget build(BuildContext context) {
36+ return AsyncView<DashboardResponse>(
37+ future: _future,
38+ onRetry: _load,
39+ builder: (context, data) => RefreshIndicator(
40+ onRefresh: () async => _load(),
41+ child: ListView(
42+ padding: const EdgeInsets.only(bottom: 32),
43+ children: [
44+ _Counts(data: data),
45+ if (data.articles.isNotEmpty) ...[
46+ const _SectionHeading('Latest'),
47+ for (final a in data.articles)
48+ ArticleTile(
49+ article: a,
50+ onTap: () => Navigator.of(context).push(
51+ MaterialPageRoute(builder: (_) => ArticleScreen(articleId: a.id)),
52+ ),
53+ ),
54+ ] else
55+ const Padding(
56+ padding: EdgeInsets.all(24),
57+ child: EmptyView(message: 'No unread articles.\nSubscribe to a feed to get started.'),
58+ ),
59+ if (data.personalTrending.isNotEmpty) ...[
60+ const _SectionHeading('Trending in your feeds'),
61+ _TrendingRail(items: data.personalTrending),
62+ ],
63+ if (data.globalTrending.isNotEmpty) ...[
64+ const _SectionHeading('Trending everywhere'),
65+ _TrendingRail(items: data.globalTrending),
66+ ],
67+ ],
68+ ),
69+ ),
70+ );
71+ }
72+}
73+
74+class _Counts extends StatelessWidget {
75+ const _Counts({required this.data});
76+
77+ final DashboardResponse data;
78+
79+ @override
80+ Widget build(BuildContext context) {
81+ return Padding(
82+ padding: const EdgeInsets.all(16),
83+ child: Row(
84+ children: [
85+ Expanded(child: _Stat(value: '${data.unreadCount}', label: 'unread')),
86+ const SizedBox(width: 12),
87+ Expanded(child: _Stat(value: '${data.subscriptionCount}', label: 'feeds')),
88+ ],
89+ ),
90+ );
91+ }
92+}
93+
94+class _Stat extends StatelessWidget {
95+ const _Stat({required this.value, required this.label});
96+
97+ final String value;
98+ final String label;
99+
100+ @override
101+ Widget build(BuildContext context) {
102+ final text = Theme.of(context).textTheme;
103+ return GleanBox(
104+ filled: true,
105+ child: Column(
106+ crossAxisAlignment: CrossAxisAlignment.start,
107+ children: [
108+ Text(value, style: text.displaySmall),
109+ Text(label, style: text.bodySmall),
110+ ],
111+ ),
112+ );
113+ }
114+}
115+
116+class _SectionHeading extends StatelessWidget {
117+ const _SectionHeading(this.label);
118+
119+ final String label;
120+
121+ @override
122+ Widget build(BuildContext context) {
123+ return Padding(
124+ padding: const EdgeInsets.fromLTRB(16, 20, 16, 10),
125+ child: Text(label, style: Theme.of(context).textTheme.titleMedium),
126+ );
127+ }
128+}
129+
130+/// Horizontal rail of trending articles.
131+class _TrendingRail extends StatelessWidget {
132+ const _TrendingRail({required this.items});
133+
134+ final List<TrendingItem> items;
135+
136+ @override
137+ Widget build(BuildContext context) {
138+ final text = Theme.of(context).textTheme;
139+ return SizedBox(
140+ height: 168,
141+ child: ListView.separated(
142+ scrollDirection: Axis.horizontal,
143+ padding: const EdgeInsets.symmetric(horizontal: 16),
144+ itemCount: items.length,
145+ separatorBuilder: (_, _) => const SizedBox(width: 12),
146+ itemBuilder: (context, i) {
147+ final t = items[i];
148+ return SizedBox(
149+ width: 240,
150+ child: GleanBox(
151+ onTap: () => Navigator.of(context).push(
152+ MaterialPageRoute(builder: (_) => ArticleScreen(articleId: t.articleId)),
153+ ),
154+ child: Column(
155+ crossAxisAlignment: CrossAxisAlignment.start,
156+ children: [
157+ Row(
158+ children: [
159+ FaviconBadge(url: t.faviconUrl, seed: t.feedTitle, size: 14),
160+ const SizedBox(width: 6),
161+ Expanded(
162+ child: Text(t.feedTitle,
163+ maxLines: 1, overflow: TextOverflow.ellipsis, style: text.bodySmall),
164+ ),
165+ ],
166+ ),
167+ const SizedBox(height: 8),
168+ Expanded(
169+ child: Text(
170+ t.title,
171+ maxLines: 4,
172+ overflow: TextOverflow.ellipsis,
173+ style: text.bodyMedium?.copyWith(fontWeight: FontWeight.w700),
174+ ),
175+ ),
176+ const SizedBox(height: 6),
177+ Row(
178+ children: [
179+ GleanTag('${t.likeCount} likes'),
180+ const SizedBox(width: 6),
181+ if (t.annotationCount > 0) GleanTag('${t.annotationCount} notes'),
182+ ],
183+ ),
184+ ],
185+ ),
186+ ),
187+ );
188+ },
189+ ),
190+ );
191+ }
192+}
added app/lib/src/screens/home_shell.dart +91 -0
new file mode 100644
@@ -0,0 +1,91 @@
1+import 'package:flutter/material.dart';
2+
3+import '../app_state.dart';
4+import '../theme.dart';
5+import 'articles_screen.dart';
6+import 'dashboard_screen.dart';
7+import 'login_screen.dart';
8+import 'trending_screen.dart';
9+
10+/// Bottom-tab shell. Trending is public; the rest require a session, so a
11+/// signed-out visitor sees Trending with a prompt to sign in rather than a
12+/// wall of failed requests.
13+class HomeShell extends StatefulWidget {
14+ const HomeShell({super.key});
15+
16+ @override
17+ State<HomeShell> createState() => _HomeShellState();
18+}
19+
20+class _HomeShellState extends State<HomeShell> {
21+ int _index = 0;
22+
23+ @override
24+ Widget build(BuildContext context) {
25+ final app = AppScope.of(context);
26+ final c = GleanColors.of(context);
27+
28+ // Tab set depends on the session: no point offering Home to a visitor who
29+ // would only get a 401.
30+ final tabs = <_Tab>[
31+ if (app.signedIn)
32+ 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()),
35+ const _Tab(icon: Icons.trending_up, label: 'Trending', child: TrendingScreen()),
36+ ];
37+ final index = _index.clamp(0, tabs.length - 1);
38+
39+ return Scaffold(
40+ appBar: AppBar(
41+ title: Text(tabs[index].label == 'Home' ? 'glean' : tabs[index].label),
42+ actions: [
43+ if (app.signedIn)
44+ IconButton(
45+ tooltip: 'Sign out',
46+ icon: const Icon(Icons.logout),
47+ onPressed: () => AppScope.read(context).signOut(),
48+ )
49+ else
50+ TextButton(
51+ onPressed: () => Navigator.of(context).push(
52+ MaterialPageRoute(builder: (_) => const LoginScreen()),
53+ ),
54+ child: const Text('Sign in'),
55+ ),
56+ ],
57+ ),
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+ ),
64+ bottomNavigationBar: tabs.length < 2
65+ ? null
66+ : Container(
67+ decoration: BoxDecoration(
68+ border: Border(top: BorderSide(color: c.border, width: 2)),
69+ ),
70+ child: NavigationBar(
71+ selectedIndex: index,
72+ backgroundColor: c.bg,
73+ indicatorColor: c.accent,
74+ onDestinationSelected: (i) => setState(() => _index = i),
75+ destinations: [
76+ for (final t in tabs)
77+ NavigationDestination(icon: Icon(t.icon), label: t.label),
78+ ],
79+ ),
80+ ),
81+ );
82+ }
83+}
84+
85+class _Tab {
86+ const _Tab({required this.icon, required this.label, required this.child});
87+
88+ final IconData icon;
89+ final String label;
90+ final Widget child;
91+}
new file mode 100644
@@ -0,0 +1,91 @@
1+import 'package:flutter/material.dart';
2+
3+import '../app_state.dart';
4+import '../theme.dart';
5+import 'articles_screen.dart';
6+import 'dashboard_screen.dart';
7+import 'login_screen.dart';
8+import 'trending_screen.dart';
9+
10+/// Bottom-tab shell. Trending is public; the rest require a session, so a
11+/// signed-out visitor sees Trending with a prompt to sign in rather than a
12+/// wall of failed requests.
13+class HomeShell extends StatefulWidget {
14+ const HomeShell({super.key});
15+
16+ @override
17+ State<HomeShell> createState() => _HomeShellState();
18+}
19+
20+class _HomeShellState extends State<HomeShell> {
21+ int _index = 0;
22+
23+ @override
24+ Widget build(BuildContext context) {
25+ final app = AppScope.of(context);
26+ final c = GleanColors.of(context);
27+
28+ // Tab set depends on the session: no point offering Home to a visitor who
29+ // would only get a 401.
30+ final tabs = <_Tab>[
31+ if (app.signedIn)
32+ 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()),
35+ const _Tab(icon: Icons.trending_up, label: 'Trending', child: TrendingScreen()),
36+ ];
37+ final index = _index.clamp(0, tabs.length - 1);
38+
39+ return Scaffold(
40+ appBar: AppBar(
41+ title: Text(tabs[index].label == 'Home' ? 'glean' : tabs[index].label),
42+ actions: [
43+ if (app.signedIn)
44+ IconButton(
45+ tooltip: 'Sign out',
46+ icon: const Icon(Icons.logout),
47+ onPressed: () => AppScope.read(context).signOut(),
48+ )
49+ else
50+ TextButton(
51+ onPressed: () => Navigator.of(context).push(
52+ MaterialPageRoute(builder: (_) => const LoginScreen()),
53+ ),
54+ child: const Text('Sign in'),
55+ ),
56+ ],
57+ ),
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+ ),
64+ bottomNavigationBar: tabs.length < 2
65+ ? null
66+ : Container(
67+ decoration: BoxDecoration(
68+ border: Border(top: BorderSide(color: c.border, width: 2)),
69+ ),
70+ child: NavigationBar(
71+ selectedIndex: index,
72+ backgroundColor: c.bg,
73+ indicatorColor: c.accent,
74+ onDestinationSelected: (i) => setState(() => _index = i),
75+ destinations: [
76+ for (final t in tabs)
77+ NavigationDestination(icon: Icon(t.icon), label: t.label),
78+ ],
79+ ),
80+ ),
81+ );
82+ }
83+}
84+
85+class _Tab {
86+ const _Tab({required this.icon, required this.label, required this.child});
87+
88+ final IconData icon;
89+ final String label;
90+ final Widget child;
91+}
added app/lib/src/screens/login_screen.dart +215 -0
new file mode 100644
@@ -0,0 +1,215 @@
1+import 'package:flutter/material.dart';
2+import 'package:webview_cookie_manager/webview_cookie_manager.dart';
3+import 'package:webview_flutter/webview_flutter.dart';
4+
5+import '../api/client.dart';
6+import '../app_state.dart';
7+import '../theme.dart';
8+import '../widgets/common.dart';
9+
10+/// Sign-in via ATProto OAuth.
11+///
12+/// The Go server drives the whole flow in a browser: POST /api/auth/start
13+/// returns the PDS authorization URL, the user authorizes there, and the PDS
14+/// redirects back to /api/auth/callback, which sets the session cookie and
15+/// redirects on. A native app has no browser to inherit that cookie from, so
16+/// the flow runs in a webview and the cookie is lifted out of it once the
17+/// callback has landed. That keeps the server contract untouched -- the
18+/// alternative, a native deep-link redirect URI, would mean changing the
19+/// published OAuth client metadata.
20+class LoginScreen extends StatefulWidget {
21+ const LoginScreen({super.key});
22+
23+ @override
24+ State<LoginScreen> createState() => _LoginScreenState();
25+}
26+
27+class _LoginScreenState extends State<LoginScreen> {
28+ final _controller = TextEditingController();
29+ bool _busy = false;
30+ String? _error;
31+
32+ @override
33+ void dispose() {
34+ _controller.dispose();
35+ super.dispose();
36+ }
37+
38+ Future<void> _start() async {
39+ final handle = _controller.text.trim().replaceFirst(RegExp(r'^@'), '');
40+ if (handle.isEmpty) {
41+ setState(() => _error = 'Enter your handle.');
42+ return;
43+ }
44+ setState(() {
45+ _busy = true;
46+ _error = null;
47+ });
48+
49+ final app = AppScope.read(context);
50+ try {
51+ final target = await app.client.startAuth(handle);
52+ if (!mounted) return;
53+
54+ // The server answers with /dashboard when it fell back to creating a
55+ // local account without OAuth; there is nothing to authorize.
56+ if (!target.startsWith('http')) {
57+ await app.refreshUser();
58+ return;
59+ }
60+
61+ final cookies = await Navigator.of(context).push<Map<String, String>>(
62+ MaterialPageRoute(
63+ builder: (_) => _OAuthWebView(authUrl: target, baseUrl: app.baseUrl),
64+ ),
65+ );
66+ if (!mounted) return;
67+ if (cookies == null || cookies.isEmpty) {
68+ setState(() => _error = 'Sign-in was cancelled.');
69+ return;
70+ }
71+ await app.completeSignIn(cookies);
72+ } on ApiException catch (e) {
73+ if (mounted) setState(() => _error = e.message);
74+ } on Exception {
75+ if (mounted) setState(() => _error = 'Could not reach the server.');
76+ } finally {
77+ if (mounted) setState(() => _busy = false);
78+ }
79+ }
80+
81+ @override
82+ Widget build(BuildContext context) {
83+ final app = AppScope.of(context);
84+ final c = GleanColors.of(context);
85+ final text = Theme.of(context).textTheme;
86+
87+ return Scaffold(
88+ body: SafeArea(
89+ child: Center(
90+ child: SingleChildScrollView(
91+ padding: const EdgeInsets.all(24),
92+ child: ConstrainedBox(
93+ constraints: const BoxConstraints(maxWidth: 420),
94+ child: Column(
95+ crossAxisAlignment: CrossAxisAlignment.stretch,
96+ mainAxisSize: MainAxisSize.min,
97+ children: [
98+ Text('glean', style: text.displaySmall),
99+ const SizedBox(height: 4),
100+ Text('a reader for the open social web', style: text.bodySmall),
101+ const SizedBox(height: 28),
102+ if (!app.oauthConfigured)
103+ Padding(
104+ padding: const EdgeInsets.only(bottom: 16),
105+ child: GleanBox(
106+ filled: true,
107+ child: Text(
108+ 'This server has no OAuth client configured '
109+ '(GLEAN_OAUTH_CLIENT_ID), so sign-in cannot complete. '
110+ 'Browse trending in the meantime.',
111+ style: text.bodySmall,
112+ ),
113+ ),
114+ ),
115+ TextField(
116+ controller: _controller,
117+ autocorrect: false,
118+ enableSuggestions: false,
119+ keyboardType: TextInputType.url,
120+ decoration: const InputDecoration(hintText: 'you.bsky.social'),
121+ onSubmitted: (_) => _start(),
122+ ),
123+ if (_error != null) ...[
124+ const SizedBox(height: 10),
125+ Text(_error!, style: text.bodySmall?.copyWith(color: c.danger)),
126+ ],
127+ const SizedBox(height: 16),
128+ GleanButton(
129+ label: 'Sign in',
130+ accent: true,
131+ busy: _busy,
132+ onPressed: app.oauthConfigured ? _start : null,
133+ ),
134+ const SizedBox(height: 12),
135+ Center(
136+ child: TextButton(
137+ onPressed: () => Navigator.of(context).maybePop(),
138+ child: Text('Keep browsing', style: text.bodySmall),
139+ ),
140+ ),
141+ ],
142+ ),
143+ ),
144+ ),
145+ ),
146+ ),
147+ );
148+ }
149+}
150+
151+/// Hosts the PDS authorization page and pops the session cookies once the
152+/// server's callback has completed.
153+class _OAuthWebView extends StatefulWidget {
154+ const _OAuthWebView({required this.authUrl, required this.baseUrl});
155+
156+ final String authUrl;
157+ final String baseUrl;
158+
159+ @override
160+ State<_OAuthWebView> createState() => _OAuthWebViewState();
161+}
162+
163+class _OAuthWebViewState extends State<_OAuthWebView> {
164+ late final WebViewController _controller;
165+ bool _finishing = false;
166+
167+ @override
168+ void initState() {
169+ super.initState();
170+ _controller = WebViewController()
171+ ..setJavaScriptMode(JavaScriptMode.unrestricted)
172+ ..setNavigationDelegate(
173+ NavigationDelegate(onPageFinished: _onPageFinished),
174+ )
175+ ..loadRequest(Uri.parse(widget.authUrl));
176+ }
177+
178+ Future<void> _onPageFinished(String url) async {
179+ if (_finishing) return;
180+ final uri = Uri.tryParse(url);
181+ final base = Uri.tryParse(widget.baseUrl);
182+ if (uri == null || base == null || uri.host != base.host) return;
183+
184+ // The callback redirects onward once the cookie is set, so treat any
185+ // same-origin page that is not the login screen as a completed flow.
186+ if (uri.path.startsWith('/auth/login')) {
187+ if (uri.queryParameters['error'] != null && mounted) {
188+ Navigator.of(context).pop(<String, String>{});
189+ }
190+ return;
191+ }
192+
193+ _finishing = true;
194+ final cookies = await _readCookies();
195+ if (!mounted) return;
196+ Navigator.of(context).pop(cookies);
197+ }
198+
199+ /// Read the cookies out of the platform cookie store rather than via
200+ /// document.cookie: the session cookie is HttpOnly (internal/server/
201+ /// session.go), so JavaScript can only ever see the CSRF one, and adopting
202+ /// that alone would leave the app looking signed in but unauthenticated.
203+ Future<Map<String, String>> _readCookies() async {
204+ final jar = await WebviewCookieManager().getCookies(widget.baseUrl);
205+ return {for (final c in jar) c.name: c.value};
206+ }
207+
208+ @override
209+ Widget build(BuildContext context) {
210+ return Scaffold(
211+ appBar: AppBar(title: const Text('Authorize')),
212+ body: WebViewWidget(controller: _controller),
213+ );
214+ }
215+}
new file mode 100644
@@ -0,0 +1,215 @@
1+import 'package:flutter/material.dart';
2+import 'package:webview_cookie_manager/webview_cookie_manager.dart';
3+import 'package:webview_flutter/webview_flutter.dart';
4+
5+import '../api/client.dart';
6+import '../app_state.dart';
7+import '../theme.dart';
8+import '../widgets/common.dart';
9+
10+/// Sign-in via ATProto OAuth.
11+///
12+/// The Go server drives the whole flow in a browser: POST /api/auth/start
13+/// returns the PDS authorization URL, the user authorizes there, and the PDS
14+/// redirects back to /api/auth/callback, which sets the session cookie and
15+/// redirects on. A native app has no browser to inherit that cookie from, so
16+/// the flow runs in a webview and the cookie is lifted out of it once the
17+/// callback has landed. That keeps the server contract untouched -- the
18+/// alternative, a native deep-link redirect URI, would mean changing the
19+/// published OAuth client metadata.
20+class LoginScreen extends StatefulWidget {
21+ const LoginScreen({super.key});
22+
23+ @override
24+ State<LoginScreen> createState() => _LoginScreenState();
25+}
26+
27+class _LoginScreenState extends State<LoginScreen> {
28+ final _controller = TextEditingController();
29+ bool _busy = false;
30+ String? _error;
31+
32+ @override
33+ void dispose() {
34+ _controller.dispose();
35+ super.dispose();
36+ }
37+
38+ Future<void> _start() async {
39+ final handle = _controller.text.trim().replaceFirst(RegExp(r'^@'), '');
40+ if (handle.isEmpty) {
41+ setState(() => _error = 'Enter your handle.');
42+ return;
43+ }
44+ setState(() {
45+ _busy = true;
46+ _error = null;
47+ });
48+
49+ final app = AppScope.read(context);
50+ try {
51+ final target = await app.client.startAuth(handle);
52+ if (!mounted) return;
53+
54+ // The server answers with /dashboard when it fell back to creating a
55+ // local account without OAuth; there is nothing to authorize.
56+ if (!target.startsWith('http')) {
57+ await app.refreshUser();
58+ return;
59+ }
60+
61+ final cookies = await Navigator.of(context).push<Map<String, String>>(
62+ MaterialPageRoute(
63+ builder: (_) => _OAuthWebView(authUrl: target, baseUrl: app.baseUrl),
64+ ),
65+ );
66+ if (!mounted) return;
67+ if (cookies == null || cookies.isEmpty) {
68+ setState(() => _error = 'Sign-in was cancelled.');
69+ return;
70+ }
71+ await app.completeSignIn(cookies);
72+ } on ApiException catch (e) {
73+ if (mounted) setState(() => _error = e.message);
74+ } on Exception {
75+ if (mounted) setState(() => _error = 'Could not reach the server.');
76+ } finally {
77+ if (mounted) setState(() => _busy = false);
78+ }
79+ }
80+
81+ @override
82+ Widget build(BuildContext context) {
83+ final app = AppScope.of(context);
84+ final c = GleanColors.of(context);
85+ final text = Theme.of(context).textTheme;
86+
87+ return Scaffold(
88+ body: SafeArea(
89+ child: Center(
90+ child: SingleChildScrollView(
91+ padding: const EdgeInsets.all(24),
92+ child: ConstrainedBox(
93+ constraints: const BoxConstraints(maxWidth: 420),
94+ child: Column(
95+ crossAxisAlignment: CrossAxisAlignment.stretch,
96+ mainAxisSize: MainAxisSize.min,
97+ children: [
98+ Text('glean', style: text.displaySmall),
99+ const SizedBox(height: 4),
100+ Text('a reader for the open social web', style: text.bodySmall),
101+ const SizedBox(height: 28),
102+ if (!app.oauthConfigured)
103+ Padding(
104+ padding: const EdgeInsets.only(bottom: 16),
105+ child: GleanBox(
106+ filled: true,
107+ child: Text(
108+ 'This server has no OAuth client configured '
109+ '(GLEAN_OAUTH_CLIENT_ID), so sign-in cannot complete. '
110+ 'Browse trending in the meantime.',
111+ style: text.bodySmall,
112+ ),
113+ ),
114+ ),
115+ TextField(
116+ controller: _controller,
117+ autocorrect: false,
118+ enableSuggestions: false,
119+ keyboardType: TextInputType.url,
120+ decoration: const InputDecoration(hintText: 'you.bsky.social'),
121+ onSubmitted: (_) => _start(),
122+ ),
123+ if (_error != null) ...[
124+ const SizedBox(height: 10),
125+ Text(_error!, style: text.bodySmall?.copyWith(color: c.danger)),
126+ ],
127+ const SizedBox(height: 16),
128+ GleanButton(
129+ label: 'Sign in',
130+ accent: true,
131+ busy: _busy,
132+ onPressed: app.oauthConfigured ? _start : null,
133+ ),
134+ const SizedBox(height: 12),
135+ Center(
136+ child: TextButton(
137+ onPressed: () => Navigator.of(context).maybePop(),
138+ child: Text('Keep browsing', style: text.bodySmall),
139+ ),
140+ ),
141+ ],
142+ ),
143+ ),
144+ ),
145+ ),
146+ ),
147+ );
148+ }
149+}
150+
151+/// Hosts the PDS authorization page and pops the session cookies once the
152+/// server's callback has completed.
153+class _OAuthWebView extends StatefulWidget {
154+ const _OAuthWebView({required this.authUrl, required this.baseUrl});
155+
156+ final String authUrl;
157+ final String baseUrl;
158+
159+ @override
160+ State<_OAuthWebView> createState() => _OAuthWebViewState();
161+}
162+
163+class _OAuthWebViewState extends State<_OAuthWebView> {
164+ late final WebViewController _controller;
165+ bool _finishing = false;
166+
167+ @override
168+ void initState() {
169+ super.initState();
170+ _controller = WebViewController()
171+ ..setJavaScriptMode(JavaScriptMode.unrestricted)
172+ ..setNavigationDelegate(
173+ NavigationDelegate(onPageFinished: _onPageFinished),
174+ )
175+ ..loadRequest(Uri.parse(widget.authUrl));
176+ }
177+
178+ Future<void> _onPageFinished(String url) async {
179+ if (_finishing) return;
180+ final uri = Uri.tryParse(url);
181+ final base = Uri.tryParse(widget.baseUrl);
182+ if (uri == null || base == null || uri.host != base.host) return;
183+
184+ // The callback redirects onward once the cookie is set, so treat any
185+ // same-origin page that is not the login screen as a completed flow.
186+ if (uri.path.startsWith('/auth/login')) {
187+ if (uri.queryParameters['error'] != null && mounted) {
188+ Navigator.of(context).pop(<String, String>{});
189+ }
190+ return;
191+ }
192+
193+ _finishing = true;
194+ final cookies = await _readCookies();
195+ if (!mounted) return;
196+ Navigator.of(context).pop(cookies);
197+ }
198+
199+ /// Read the cookies out of the platform cookie store rather than via
200+ /// document.cookie: the session cookie is HttpOnly (internal/server/
201+ /// session.go), so JavaScript can only ever see the CSRF one, and adopting
202+ /// that alone would leave the app looking signed in but unauthenticated.
203+ Future<Map<String, String>> _readCookies() async {
204+ final jar = await WebviewCookieManager().getCookies(widget.baseUrl);
205+ return {for (final c in jar) c.name: c.value};
206+ }
207+
208+ @override
209+ Widget build(BuildContext context) {
210+ return Scaffold(
211+ appBar: AppBar(title: const Text('Authorize')),
212+ body: WebViewWidget(controller: _controller),
213+ );
214+ }
215+}
added app/lib/src/theme.dart +138 -0
new file mode 100644
@@ -0,0 +1,138 @@
1+import 'package:flutter/material.dart';
2+
3+/// Flutter port of web/src/app.css.
4+///
5+/// Glean's web design system is deliberately brutalist: a monochrome ink/paper
6+/// base with one accent green, sharp corners, 2px borders and hard offset
7+/// shadows, monospace throughout. Keeping that identity matters more than
8+/// looking like stock Material, so this leans on a shared token set rather
9+/// than ColorScheme.fromSeed.
10+class GleanColors extends ThemeExtension<GleanColors> {
11+ const GleanColors({
12+ required this.bg,
13+ required this.fg,
14+ required this.surface,
15+ required this.border,
16+ required this.muted,
17+ required this.faint,
18+ required this.accent,
19+ required this.accentInk,
20+ required this.danger,
21+ });
22+
23+ final Color bg;
24+ final Color fg;
25+ final Color surface;
26+ final Color border;
27+ final Color muted;
28+ final Color faint;
29+ final Color accent;
30+ final Color accentInk;
31+ final Color danger;
32+
33+ static const light = GleanColors(
34+ bg: Color(0xFFFAFAF7),
35+ fg: Color(0xFF0A0A0A),
36+ surface: Color(0xFFF0EFE9),
37+ border: Color(0xFF0A0A0A),
38+ muted: Color(0xFF6B6B6B),
39+ faint: Color(0xFFC8C8C2),
40+ accent: Color(0xFF00754A),
41+ accentInk: Color(0xFFECFFF4),
42+ danger: Color(0xFFC82014),
43+ );
44+
45+ static const dark = GleanColors(
46+ bg: Color(0xFF0A0A0A),
47+ fg: Color(0xFFF5F5EF),
48+ surface: Color(0xFF161616),
49+ border: Color(0xFFF5F5EF),
50+ muted: Color(0xFF9A9A9A),
51+ faint: Color(0xFF3A3A3A),
52+ accent: Color(0xFF00754A),
53+ accentInk: Color(0xFF062018),
54+ danger: Color(0xFFFF5A4D),
55+ );
56+
57+ static GleanColors of(BuildContext context) =>
58+ Theme.of(context).extension<GleanColors>()!;
59+
60+ @override
61+ GleanColors copyWith() => this;
62+
63+ @override
64+ GleanColors lerp(ThemeExtension<GleanColors>? other, double t) =>
65+ t < 0.5 ? this : (other as GleanColors? ?? this);
66+}
67+
68+/// The web app asks for JetBrains Mono and falls back through IBM Plex Mono to
69+/// the platform monospace. Doing the same here keeps the look without a
70+/// runtime font download; bundling the TTF would pin it exactly.
71+const kMonoFallback = <String>['JetBrains Mono', 'IBM Plex Mono', 'monospace'];
72+
73+ThemeData gleanTheme(Brightness brightness) {
74+ final c = brightness == Brightness.dark ? GleanColors.dark : GleanColors.light;
75+
76+ TextStyle mono(double size, {FontWeight weight = FontWeight.w400, Color? color}) =>
77+ TextStyle(
78+ fontFamilyFallback: kMonoFallback,
79+ fontSize: size,
80+ fontWeight: weight,
81+ color: color ?? c.fg,
82+ height: 1.45,
83+ );
84+
85+ return ThemeData(
86+ brightness: brightness,
87+ scaffoldBackgroundColor: c.bg,
88+ canvasColor: c.bg,
89+ dividerColor: c.border,
90+ splashFactory: NoSplash.splashFactory,
91+ colorScheme: ColorScheme.fromSeed(
92+ seedColor: c.accent,
93+ brightness: brightness,
94+ ).copyWith(surface: c.bg, primary: c.accent, error: c.danger),
95+ extensions: [c],
96+ textTheme: TextTheme(
97+ displaySmall: mono(28, weight: FontWeight.w700),
98+ headlineSmall: mono(20, weight: FontWeight.w700),
99+ titleMedium: mono(16, weight: FontWeight.w700),
100+ bodyLarge: mono(15),
101+ bodyMedium: mono(14),
102+ bodySmall: mono(12, color: c.muted),
103+ labelLarge: mono(13, weight: FontWeight.w700),
104+ ),
105+ appBarTheme: AppBarTheme(
106+ backgroundColor: c.bg,
107+ foregroundColor: c.fg,
108+ elevation: 0,
109+ centerTitle: false,
110+ shape: Border(bottom: BorderSide(color: c.border, width: 2)),
111+ titleTextStyle: mono(18, weight: FontWeight.w700),
112+ ),
113+ iconTheme: IconThemeData(color: c.fg, size: 20),
114+ snackBarTheme: SnackBarThemeData(
115+ backgroundColor: c.fg,
116+ contentTextStyle: mono(13, color: c.bg),
117+ behavior: SnackBarBehavior.floating,
118+ shape: const RoundedRectangleBorder(),
119+ ),
120+ inputDecorationTheme: InputDecorationTheme(
121+ filled: true,
122+ fillColor: c.surface,
123+ border: OutlineInputBorder(
124+ borderRadius: BorderRadius.zero,
125+ borderSide: BorderSide(color: c.border, width: 2),
126+ ),
127+ enabledBorder: OutlineInputBorder(
128+ borderRadius: BorderRadius.zero,
129+ borderSide: BorderSide(color: c.border, width: 2),
130+ ),
131+ focusedBorder: OutlineInputBorder(
132+ borderRadius: BorderRadius.zero,
133+ borderSide: BorderSide(color: c.accent, width: 2),
134+ ),
135+ hintStyle: mono(14, color: c.muted),
136+ ),
137+ );
138+}
new file mode 100644
@@ -0,0 +1,138 @@
1+import 'package:flutter/material.dart';
2+
3+/// Flutter port of web/src/app.css.
4+///
5+/// Glean's web design system is deliberately brutalist: a monochrome ink/paper
6+/// base with one accent green, sharp corners, 2px borders and hard offset
7+/// shadows, monospace throughout. Keeping that identity matters more than
8+/// looking like stock Material, so this leans on a shared token set rather
9+/// than ColorScheme.fromSeed.
10+class GleanColors extends ThemeExtension<GleanColors> {
11+ const GleanColors({
12+ required this.bg,
13+ required this.fg,
14+ required this.surface,
15+ required this.border,
16+ required this.muted,
17+ required this.faint,
18+ required this.accent,
19+ required this.accentInk,
20+ required this.danger,
21+ });
22+
23+ final Color bg;
24+ final Color fg;
25+ final Color surface;
26+ final Color border;
27+ final Color muted;
28+ final Color faint;
29+ final Color accent;
30+ final Color accentInk;
31+ final Color danger;
32+
33+ static const light = GleanColors(
34+ bg: Color(0xFFFAFAF7),
35+ fg: Color(0xFF0A0A0A),
36+ surface: Color(0xFFF0EFE9),
37+ border: Color(0xFF0A0A0A),
38+ muted: Color(0xFF6B6B6B),
39+ faint: Color(0xFFC8C8C2),
40+ accent: Color(0xFF00754A),
41+ accentInk: Color(0xFFECFFF4),
42+ danger: Color(0xFFC82014),
43+ );
44+
45+ static const dark = GleanColors(
46+ bg: Color(0xFF0A0A0A),
47+ fg: Color(0xFFF5F5EF),
48+ surface: Color(0xFF161616),
49+ border: Color(0xFFF5F5EF),
50+ muted: Color(0xFF9A9A9A),
51+ faint: Color(0xFF3A3A3A),
52+ accent: Color(0xFF00754A),
53+ accentInk: Color(0xFF062018),
54+ danger: Color(0xFFFF5A4D),
55+ );
56+
57+ static GleanColors of(BuildContext context) =>
58+ Theme.of(context).extension<GleanColors>()!;
59+
60+ @override
61+ GleanColors copyWith() => this;
62+
63+ @override
64+ GleanColors lerp(ThemeExtension<GleanColors>? other, double t) =>
65+ t < 0.5 ? this : (other as GleanColors? ?? this);
66+}
67+
68+/// The web app asks for JetBrains Mono and falls back through IBM Plex Mono to
69+/// the platform monospace. Doing the same here keeps the look without a
70+/// runtime font download; bundling the TTF would pin it exactly.
71+const kMonoFallback = <String>['JetBrains Mono', 'IBM Plex Mono', 'monospace'];
72+
73+ThemeData gleanTheme(Brightness brightness) {
74+ final c = brightness == Brightness.dark ? GleanColors.dark : GleanColors.light;
75+
76+ TextStyle mono(double size, {FontWeight weight = FontWeight.w400, Color? color}) =>
77+ TextStyle(
78+ fontFamilyFallback: kMonoFallback,
79+ fontSize: size,
80+ fontWeight: weight,
81+ color: color ?? c.fg,
82+ height: 1.45,
83+ );
84+
85+ return ThemeData(
86+ brightness: brightness,
87+ scaffoldBackgroundColor: c.bg,
88+ canvasColor: c.bg,
89+ dividerColor: c.border,
90+ splashFactory: NoSplash.splashFactory,
91+ colorScheme: ColorScheme.fromSeed(
92+ seedColor: c.accent,
93+ brightness: brightness,
94+ ).copyWith(surface: c.bg, primary: c.accent, error: c.danger),
95+ extensions: [c],
96+ textTheme: TextTheme(
97+ displaySmall: mono(28, weight: FontWeight.w700),
98+ headlineSmall: mono(20, weight: FontWeight.w700),
99+ titleMedium: mono(16, weight: FontWeight.w700),
100+ bodyLarge: mono(15),
101+ bodyMedium: mono(14),
102+ bodySmall: mono(12, color: c.muted),
103+ labelLarge: mono(13, weight: FontWeight.w700),
104+ ),
105+ appBarTheme: AppBarTheme(
106+ backgroundColor: c.bg,
107+ foregroundColor: c.fg,
108+ elevation: 0,
109+ centerTitle: false,
110+ shape: Border(bottom: BorderSide(color: c.border, width: 2)),
111+ titleTextStyle: mono(18, weight: FontWeight.w700),
112+ ),
113+ iconTheme: IconThemeData(color: c.fg, size: 20),
114+ snackBarTheme: SnackBarThemeData(
115+ backgroundColor: c.fg,
116+ contentTextStyle: mono(13, color: c.bg),
117+ behavior: SnackBarBehavior.floating,
118+ shape: const RoundedRectangleBorder(),
119+ ),
120+ inputDecorationTheme: InputDecorationTheme(
121+ filled: true,
122+ fillColor: c.surface,
123+ border: OutlineInputBorder(
124+ borderRadius: BorderRadius.zero,
125+ borderSide: BorderSide(color: c.border, width: 2),
126+ ),
127+ enabledBorder: OutlineInputBorder(
128+ borderRadius: BorderRadius.zero,
129+ borderSide: BorderSide(color: c.border, width: 2),
130+ ),
131+ focusedBorder: OutlineInputBorder(
132+ borderRadius: BorderRadius.zero,
133+ borderSide: BorderSide(color: c.accent, width: 2),
134+ ),
135+ hintStyle: mono(14, color: c.muted),
136+ ),
137+ );
138+}
added app/lib/src/widgets/article_tile.dart +157 -0
new file mode 100644
@@ -0,0 +1,157 @@
1+import 'package:flutter/material.dart';
2+
3+import '../models/models.dart';
4+import '../theme.dart';
5+import 'common.dart';
6+
7+/// One row in any article list.
8+///
9+/// Read state is carried by weight and opacity rather than a separate badge,
10+/// matching the web list where unread items simply read louder.
11+class ArticleTile extends StatelessWidget {
12+ const ArticleTile({
13+ super.key,
14+ required this.article,
15+ required this.onTap,
16+ this.onToggleRead,
17+ this.onToggleLike,
18+ this.expanded = false,
19+ });
20+
21+ final Article article;
22+ final VoidCallback onTap;
23+ final VoidCallback? onToggleRead;
24+ final VoidCallback? onToggleLike;
25+
26+ /// Mirrors the server's expanded_view preference: show the summary too.
27+ final bool expanded;
28+
29+ @override
30+ Widget build(BuildContext context) {
31+ final c = GleanColors.of(context);
32+ final text = Theme.of(context).textTheme;
33+ final read = article.isRead;
34+
35+ return InkWell(
36+ onTap: onTap,
37+ child: Container(
38+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
39+ decoration: BoxDecoration(
40+ border: Border(bottom: BorderSide(color: c.faint, width: 1)),
41+ ),
42+ child: Column(
43+ crossAxisAlignment: CrossAxisAlignment.start,
44+ children: [
45+ Row(
46+ children: [
47+ FaviconBadge(url: article.feedFaviconUrl, seed: article.feedTitle),
48+ const SizedBox(width: 8),
49+ Expanded(
50+ child: Text(
51+ article.feedTitle,
52+ maxLines: 1,
53+ overflow: TextOverflow.ellipsis,
54+ style: text.bodySmall,
55+ ),
56+ ),
57+ if (article.published != null)
58+ Text(relativeTime(article.published), style: text.bodySmall),
59+ ],
60+ ),
61+ const SizedBox(height: 6),
62+ Opacity(
63+ opacity: read ? 0.55 : 1,
64+ child: Text(
65+ article.title,
66+ maxLines: 3,
67+ overflow: TextOverflow.ellipsis,
68+ style: text.bodyLarge?.copyWith(
69+ fontWeight: read ? FontWeight.w400 : FontWeight.w700,
70+ ),
71+ ),
72+ ),
73+ if (expanded && article.summary.isNotEmpty) ...[
74+ const SizedBox(height: 6),
75+ Text(
76+ article.summary,
77+ maxLines: 3,
78+ overflow: TextOverflow.ellipsis,
79+ style: text.bodySmall,
80+ ),
81+ ],
82+ const SizedBox(height: 8),
83+ Row(
84+ children: [
85+ if (article.author.isNotEmpty) ...[
86+ Flexible(
87+ child: Text(
88+ article.author,
89+ maxLines: 1,
90+ overflow: TextOverflow.ellipsis,
91+ style: text.bodySmall,
92+ ),
93+ ),
94+ const SizedBox(width: 12),
95+ ],
96+ const Spacer(),
97+ if (onToggleLike != null)
98+ _IconCount(
99+ icon: article.hasLiked ? Icons.favorite : Icons.favorite_border,
100+ count: article.likeCount,
101+ active: article.hasLiked,
102+ onTap: onToggleLike!,
103+ ),
104+ if (onToggleRead != null)
105+ IconButton(
106+ visualDensity: VisualDensity.compact,
107+ tooltip: read ? 'Mark unread' : 'Mark read',
108+ icon: Icon(
109+ read ? Icons.mark_email_unread_outlined : Icons.check,
110+ size: 18,
111+ color: c.muted,
112+ ),
113+ onPressed: onToggleRead,
114+ ),
115+ ],
116+ ),
117+ ],
118+ ),
119+ ),
120+ );
121+ }
122+}
123+
124+class _IconCount extends StatelessWidget {
125+ const _IconCount({
126+ required this.icon,
127+ required this.count,
128+ required this.active,
129+ required this.onTap,
130+ });
131+
132+ final IconData icon;
133+ final int count;
134+ final bool active;
135+ final VoidCallback onTap;
136+
137+ @override
138+ Widget build(BuildContext context) {
139+ final c = GleanColors.of(context);
140+ return InkWell(
141+ onTap: onTap,
142+ child: Padding(
143+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
144+ child: Row(
145+ mainAxisSize: MainAxisSize.min,
146+ children: [
147+ Icon(icon, size: 16, color: active ? c.danger : c.muted),
148+ if (count > 0) ...[
149+ const SizedBox(width: 4),
150+ Text('$count', style: Theme.of(context).textTheme.bodySmall),
151+ ],
152+ ],
153+ ),
154+ ),
155+ );
156+ }
157+}
new file mode 100644
@@ -0,0 +1,157 @@
1+import 'package:flutter/material.dart';
2+
3+import '../models/models.dart';
4+import '../theme.dart';
5+import 'common.dart';
6+
7+/// One row in any article list.
8+///
9+/// Read state is carried by weight and opacity rather than a separate badge,
10+/// matching the web list where unread items simply read louder.
11+class ArticleTile extends StatelessWidget {
12+ const ArticleTile({
13+ super.key,
14+ required this.article,
15+ required this.onTap,
16+ this.onToggleRead,
17+ this.onToggleLike,
18+ this.expanded = false,
19+ });
20+
21+ final Article article;
22+ final VoidCallback onTap;
23+ final VoidCallback? onToggleRead;
24+ final VoidCallback? onToggleLike;
25+
26+ /// Mirrors the server's expanded_view preference: show the summary too.
27+ final bool expanded;
28+
29+ @override
30+ Widget build(BuildContext context) {
31+ final c = GleanColors.of(context);
32+ final text = Theme.of(context).textTheme;
33+ final read = article.isRead;
34+
35+ return InkWell(
36+ onTap: onTap,
37+ child: Container(
38+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
39+ decoration: BoxDecoration(
40+ border: Border(bottom: BorderSide(color: c.faint, width: 1)),
41+ ),
42+ child: Column(
43+ crossAxisAlignment: CrossAxisAlignment.start,
44+ children: [
45+ Row(
46+ children: [
47+ FaviconBadge(url: article.feedFaviconUrl, seed: article.feedTitle),
48+ const SizedBox(width: 8),
49+ Expanded(
50+ child: Text(
51+ article.feedTitle,
52+ maxLines: 1,
53+ overflow: TextOverflow.ellipsis,
54+ style: text.bodySmall,
55+ ),
56+ ),
57+ if (article.published != null)
58+ Text(relativeTime(article.published), style: text.bodySmall),
59+ ],
60+ ),
61+ const SizedBox(height: 6),
62+ Opacity(
63+ opacity: read ? 0.55 : 1,
64+ child: Text(
65+ article.title,
66+ maxLines: 3,
67+ overflow: TextOverflow.ellipsis,
68+ style: text.bodyLarge?.copyWith(
69+ fontWeight: read ? FontWeight.w400 : FontWeight.w700,
70+ ),
71+ ),
72+ ),
73+ if (expanded && article.summary.isNotEmpty) ...[
74+ const SizedBox(height: 6),
75+ Text(
76+ article.summary,
77+ maxLines: 3,
78+ overflow: TextOverflow.ellipsis,
79+ style: text.bodySmall,
80+ ),
81+ ],
82+ const SizedBox(height: 8),
83+ Row(
84+ children: [
85+ if (article.author.isNotEmpty) ...[
86+ Flexible(
87+ child: Text(
88+ article.author,
89+ maxLines: 1,
90+ overflow: TextOverflow.ellipsis,
91+ style: text.bodySmall,
92+ ),
93+ ),
94+ const SizedBox(width: 12),
95+ ],
96+ const Spacer(),
97+ if (onToggleLike != null)
98+ _IconCount(
99+ icon: article.hasLiked ? Icons.favorite : Icons.favorite_border,
100+ count: article.likeCount,
101+ active: article.hasLiked,
102+ onTap: onToggleLike!,
103+ ),
104+ if (onToggleRead != null)
105+ IconButton(
106+ visualDensity: VisualDensity.compact,
107+ tooltip: read ? 'Mark unread' : 'Mark read',
108+ icon: Icon(
109+ read ? Icons.mark_email_unread_outlined : Icons.check,
110+ size: 18,
111+ color: c.muted,
112+ ),
113+ onPressed: onToggleRead,
114+ ),
115+ ],
116+ ),
117+ ],
118+ ),
119+ ),
120+ );
121+ }
122+}
123+
124+class _IconCount extends StatelessWidget {
125+ const _IconCount({
126+ required this.icon,
127+ required this.count,
128+ required this.active,
129+ required this.onTap,
130+ });
131+
132+ final IconData icon;
133+ final int count;
134+ final bool active;
135+ final VoidCallback onTap;
136+
137+ @override
138+ Widget build(BuildContext context) {
139+ final c = GleanColors.of(context);
140+ return InkWell(
141+ onTap: onTap,
142+ child: Padding(
143+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
144+ child: Row(
145+ mainAxisSize: MainAxisSize.min,
146+ children: [
147+ Icon(icon, size: 16, color: active ? c.danger : c.muted),
148+ if (count > 0) ...[
149+ const SizedBox(width: 4),
150+ Text('$count', style: Theme.of(context).textTheme.bodySmall),
151+ ],
152+ ],
153+ ),
154+ ),
155+ );
156+ }
157+}
added app/lib/src/widgets/common.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 '../theme.dart';
5+
6+/// A hard-edged box with a 2px border and the offset shadow the web app uses
7+/// for cards and buttons.
8+class GleanBox extends StatelessWidget {
9+ const GleanBox({
10+ super.key,
11+ required this.child,
12+ this.padding = const EdgeInsets.all(12),
13+ this.onTap,
14+ this.filled = false,
15+ this.shadow = true,
16+ });
17+
18+ final Widget child;
19+ final EdgeInsetsGeometry padding;
20+ final VoidCallback? onTap;
21+ final bool filled;
22+ final bool shadow;
23+
24+ @override
25+ Widget build(BuildContext context) {
26+ final c = GleanColors.of(context);
27+ final box = Container(
28+ padding: padding,
29+ decoration: BoxDecoration(
30+ color: filled ? c.surface : c.bg,
31+ border: Border.all(color: c.border, width: 2),
32+ boxShadow: shadow
33+ ? [BoxShadow(color: c.border, offset: const Offset(3, 3))]
34+ : null,
35+ ),
36+ child: child,
37+ );
38+ if (onTap == null) return box;
39+ return InkWell(onTap: onTap, child: box);
40+ }
41+}
42+
43+/// Monospace pill used for counts, categories and other metadata.
44+class GleanTag extends StatelessWidget {
45+ const GleanTag(this.label, {super.key, this.emphasis = false});
46+
47+ final String label;
48+ final bool emphasis;
49+
50+ @override
51+ Widget build(BuildContext context) {
52+ final c = GleanColors.of(context);
53+ return Container(
54+ padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
55+ decoration: BoxDecoration(
56+ color: emphasis ? c.accent : Colors.transparent,
57+ border: Border.all(color: emphasis ? c.accent : c.border, width: 1.5),
58+ ),
59+ child: Text(
60+ label,
61+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
62+ color: emphasis ? c.accentInk : c.muted,
63+ fontWeight: FontWeight.w700,
64+ ),
65+ ),
66+ );
67+ }
68+}
69+
70+/// Square-cornered primary button.
71+class GleanButton extends StatelessWidget {
72+ const GleanButton({
73+ super.key,
74+ required this.label,
75+ this.onPressed,
76+ this.accent = false,
77+ this.busy = false,
78+ });
79+
80+ final String label;
81+ final VoidCallback? onPressed;
82+ final bool accent;
83+ final bool busy;
84+
85+ @override
86+ Widget build(BuildContext context) {
87+ final c = GleanColors.of(context);
88+ final enabled = onPressed != null && !busy;
89+ return Opacity(
90+ opacity: enabled ? 1 : 0.5,
91+ child: GleanBox(
92+ onTap: enabled ? onPressed : null,
93+ filled: !accent,
94+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
95+ child: Container(
96+ color: accent ? c.accent : null,
97+ child: Row(
98+ mainAxisSize: MainAxisSize.min,
99+ children: [
100+ if (busy) ...[
101+ SizedBox(
102+ width: 12,
103+ height: 12,
104+ child: CircularProgressIndicator(
105+ strokeWidth: 2,
106+ color: accent ? c.accentInk : c.fg,
107+ ),
108+ ),
109+ const SizedBox(width: 8),
110+ ],
111+ Text(
112+ label,
113+ style: Theme.of(context).textTheme.labelLarge?.copyWith(
114+ color: accent ? c.accentInk : c.fg,
115+ ),
116+ ),
117+ ],
118+ ),
119+ ),
120+ ),
121+ );
122+ }
123+}
124+
125+/// Renders a future with consistent loading, error and empty states.
126+///
127+/// Every screen fetches independently, so this is where retry and the
128+/// "session expired" path live rather than being re-written per screen.
129+class AsyncView<T> extends StatelessWidget {
130+ const AsyncView({
131+ super.key,
132+ required this.future,
133+ required this.builder,
134+ required this.onRetry,
135+ this.onUnauthorized,
136+ });
137+
138+ final Future<T>? future;
139+ final Widget Function(BuildContext, T) builder;
140+ final VoidCallback onRetry;
141+
142+ /// Called when the server rejects the session, so the shell can send the
143+ /// user back to sign-in instead of showing a bare error.
144+ final VoidCallback? onUnauthorized;
145+
146+ @override
147+ Widget build(BuildContext context) {
148+ return FutureBuilder<T>(
149+ future: future,
150+ builder: (context, snap) {
151+ if (snap.connectionState == ConnectionState.waiting) {
152+ return const Center(child: CircularProgressIndicator());
153+ }
154+ if (snap.hasError) {
155+ final err = snap.error;
156+ if (err is ApiException && err.isUnauthorized && onUnauthorized != null) {
157+ WidgetsBinding.instance.addPostFrameCallback((_) => onUnauthorized!());
158+ }
159+ return ErrorView(
160+ message: err is ApiException ? err.message : 'Something went wrong.',
161+ onRetry: onRetry,
162+ );
163+ }
164+ return builder(context, snap.data as T);
165+ },
166+ );
167+ }
168+}
169+
170+class ErrorView extends StatelessWidget {
171+ const ErrorView({super.key, required this.message, required this.onRetry});
172+
173+ final String message;
174+ final VoidCallback onRetry;
175+
176+ @override
177+ Widget build(BuildContext context) {
178+ final c = GleanColors.of(context);
179+ return Center(
180+ child: Padding(
181+ padding: const EdgeInsets.all(24),
182+ child: Column(
183+ mainAxisSize: MainAxisSize.min,
184+ children: [
185+ Text('!', style: TextStyle(
186+ fontFamilyFallback: kMonoFallback,
187+ fontSize: 40,
188+ fontWeight: FontWeight.w700,
189+ color: c.danger,
190+ )),
191+ const SizedBox(height: 8),
192+ Text(message, textAlign: TextAlign.center),
193+ const SizedBox(height: 16),
194+ GleanButton(label: 'Retry', onPressed: onRetry),
195+ ],
196+ ),
197+ ),
198+ );
199+ }
200+}
201+
202+class EmptyView extends StatelessWidget {
203+ const EmptyView({super.key, required this.message});
204+
205+ final String message;
206+
207+ @override
208+ Widget build(BuildContext context) {
209+ return Center(
210+ child: Padding(
211+ padding: const EdgeInsets.all(32),
212+ child: Text(
213+ message,
214+ textAlign: TextAlign.center,
215+ style: Theme.of(context).textTheme.bodySmall,
216+ ),
217+ ),
218+ );
219+ }
220+}
221+
222+/// Feed favicon with a letter fallback, so rows keep their rhythm when a site
223+/// has no icon or the request fails.
224+class FaviconBadge extends StatelessWidget {
225+ const FaviconBadge({super.key, required this.url, required this.seed, this.size = 18});
226+
227+ final String url;
228+ final String seed;
229+ final double size;
230+
231+ @override
232+ Widget build(BuildContext context) {
233+ final c = GleanColors.of(context);
234+ final letter = seed.isEmpty ? '?' : seed.characters.first.toUpperCase();
235+ final fallback = Container(
236+ width: size,
237+ height: size,
238+ alignment: Alignment.center,
239+ color: c.faint,
240+ child: Text(
241+ letter,
242+ style: TextStyle(
243+ fontFamilyFallback: kMonoFallback,
244+ fontSize: size * 0.6,
245+ fontWeight: FontWeight.w700,
246+ color: c.bg,
247+ ),
248+ ),
249+ );
250+ if (url.isEmpty) return fallback;
251+ return Image.network(
252+ url,
253+ width: size,
254+ height: size,
255+ errorBuilder: (_, _, _) => fallback,
256+ loadingBuilder: (_, child, progress) => progress == null ? child : fallback,
257+ );
258+ }
259+}
260+
261+/// Compact relative time ("3h", "2d") matching the web app's dense metadata rows.
262+String relativeTime(DateTime? t) {
263+ if (t == null) return '';
264+ final d = DateTime.now().difference(t);
265+ if (d.inMinutes < 1) return 'now';
266+ if (d.inMinutes < 60) return '${d.inMinutes}m';
267+ if (d.inHours < 24) return '${d.inHours}h';
268+ if (d.inDays < 30) return '${d.inDays}d';
269+ if (d.inDays < 365) return '${(d.inDays / 30).floor()}mo';
270+ return '${(d.inDays / 365).floor()}y';
271+}
272+
273+void showToast(BuildContext context, String message) {
274+ ScaffoldMessenger.of(context)
275+ ..hideCurrentSnackBar()
276+ ..showSnackBar(SnackBar(content: Text(message)));
277+}
new file mode 100644
@@ -0,0 +1,277 @@
1+import 'package:flutter/material.dart';
2+
3+import '../api/client.dart';
4+import '../theme.dart';
5+
6+/// A hard-edged box with a 2px border and the offset shadow the web app uses
7+/// for cards and buttons.
8+class GleanBox extends StatelessWidget {
9+ const GleanBox({
10+ super.key,
11+ required this.child,
12+ this.padding = const EdgeInsets.all(12),
13+ this.onTap,
14+ this.filled = false,
15+ this.shadow = true,
16+ });
17+
18+ final Widget child;
19+ final EdgeInsetsGeometry padding;
20+ final VoidCallback? onTap;
21+ final bool filled;
22+ final bool shadow;
23+
24+ @override
25+ Widget build(BuildContext context) {
26+ final c = GleanColors.of(context);
27+ final box = Container(
28+ padding: padding,
29+ decoration: BoxDecoration(
30+ color: filled ? c.surface : c.bg,
31+ border: Border.all(color: c.border, width: 2),
32+ boxShadow: shadow
33+ ? [BoxShadow(color: c.border, offset: const Offset(3, 3))]
34+ : null,
35+ ),
36+ child: child,
37+ );
38+ if (onTap == null) return box;
39+ return InkWell(onTap: onTap, child: box);
40+ }
41+}
42+
43+/// Monospace pill used for counts, categories and other metadata.
44+class GleanTag extends StatelessWidget {
45+ const GleanTag(this.label, {super.key, this.emphasis = false});
46+
47+ final String label;
48+ final bool emphasis;
49+
50+ @override
51+ Widget build(BuildContext context) {
52+ final c = GleanColors.of(context);
53+ return Container(
54+ padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
55+ decoration: BoxDecoration(
56+ color: emphasis ? c.accent : Colors.transparent,
57+ border: Border.all(color: emphasis ? c.accent : c.border, width: 1.5),
58+ ),
59+ child: Text(
60+ label,
61+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
62+ color: emphasis ? c.accentInk : c.muted,
63+ fontWeight: FontWeight.w700,
64+ ),
65+ ),
66+ );
67+ }
68+}
69+
70+/// Square-cornered primary button.
71+class GleanButton extends StatelessWidget {
72+ const GleanButton({
73+ super.key,
74+ required this.label,
75+ this.onPressed,
76+ this.accent = false,
77+ this.busy = false,
78+ });
79+
80+ final String label;
81+ final VoidCallback? onPressed;
82+ final bool accent;
83+ final bool busy;
84+
85+ @override
86+ Widget build(BuildContext context) {
87+ final c = GleanColors.of(context);
88+ final enabled = onPressed != null && !busy;
89+ return Opacity(
90+ opacity: enabled ? 1 : 0.5,
91+ child: GleanBox(
92+ onTap: enabled ? onPressed : null,
93+ filled: !accent,
94+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
95+ child: Container(
96+ color: accent ? c.accent : null,
97+ child: Row(
98+ mainAxisSize: MainAxisSize.min,
99+ children: [
100+ if (busy) ...[
101+ SizedBox(
102+ width: 12,
103+ height: 12,
104+ child: CircularProgressIndicator(
105+ strokeWidth: 2,
106+ color: accent ? c.accentInk : c.fg,
107+ ),
108+ ),
109+ const SizedBox(width: 8),
110+ ],
111+ Text(
112+ label,
113+ style: Theme.of(context).textTheme.labelLarge?.copyWith(
114+ color: accent ? c.accentInk : c.fg,
115+ ),
116+ ),
117+ ],
118+ ),
119+ ),
120+ ),
121+ );
122+ }
123+}
124+
125+/// Renders a future with consistent loading, error and empty states.
126+///
127+/// Every screen fetches independently, so this is where retry and the
128+/// "session expired" path live rather than being re-written per screen.
129+class AsyncView<T> extends StatelessWidget {
130+ const AsyncView({
131+ super.key,
132+ required this.future,
133+ required this.builder,
134+ required this.onRetry,
135+ this.onUnauthorized,
136+ });
137+
138+ final Future<T>? future;
139+ final Widget Function(BuildContext, T) builder;
140+ final VoidCallback onRetry;
141+
142+ /// Called when the server rejects the session, so the shell can send the
143+ /// user back to sign-in instead of showing a bare error.
144+ final VoidCallback? onUnauthorized;
145+
146+ @override
147+ Widget build(BuildContext context) {
148+ return FutureBuilder<T>(
149+ future: future,
150+ builder: (context, snap) {
151+ if (snap.connectionState == ConnectionState.waiting) {
152+ return const Center(child: CircularProgressIndicator());
153+ }
154+ if (snap.hasError) {
155+ final err = snap.error;
156+ if (err is ApiException && err.isUnauthorized && onUnauthorized != null) {
157+ WidgetsBinding.instance.addPostFrameCallback((_) => onUnauthorized!());
158+ }
159+ return ErrorView(
160+ message: err is ApiException ? err.message : 'Something went wrong.',
161+ onRetry: onRetry,
162+ );
163+ }
164+ return builder(context, snap.data as T);
165+ },
166+ );
167+ }
168+}
169+
170+class ErrorView extends StatelessWidget {
171+ const ErrorView({super.key, required this.message, required this.onRetry});
172+
173+ final String message;
174+ final VoidCallback onRetry;
175+
176+ @override
177+ Widget build(BuildContext context) {
178+ final c = GleanColors.of(context);
179+ return Center(
180+ child: Padding(
181+ padding: const EdgeInsets.all(24),
182+ child: Column(
183+ mainAxisSize: MainAxisSize.min,
184+ children: [
185+ Text('!', style: TextStyle(
186+ fontFamilyFallback: kMonoFallback,
187+ fontSize: 40,
188+ fontWeight: FontWeight.w700,
189+ color: c.danger,
190+ )),
191+ const SizedBox(height: 8),
192+ Text(message, textAlign: TextAlign.center),
193+ const SizedBox(height: 16),
194+ GleanButton(label: 'Retry', onPressed: onRetry),
195+ ],
196+ ),
197+ ),
198+ );
199+ }
200+}
201+
202+class EmptyView extends StatelessWidget {
203+ const EmptyView({super.key, required this.message});
204+
205+ final String message;
206+
207+ @override
208+ Widget build(BuildContext context) {
209+ return Center(
210+ child: Padding(
211+ padding: const EdgeInsets.all(32),
212+ child: Text(
213+ message,
214+ textAlign: TextAlign.center,
215+ style: Theme.of(context).textTheme.bodySmall,
216+ ),
217+ ),
218+ );
219+ }
220+}
221+
222+/// Feed favicon with a letter fallback, so rows keep their rhythm when a site
223+/// has no icon or the request fails.
224+class FaviconBadge extends StatelessWidget {
225+ const FaviconBadge({super.key, required this.url, required this.seed, this.size = 18});
226+
227+ final String url;
228+ final String seed;
229+ final double size;
230+
231+ @override
232+ Widget build(BuildContext context) {
233+ final c = GleanColors.of(context);
234+ final letter = seed.isEmpty ? '?' : seed.characters.first.toUpperCase();
235+ final fallback = Container(
236+ width: size,
237+ height: size,
238+ alignment: Alignment.center,
239+ color: c.faint,
240+ child: Text(
241+ letter,
242+ style: TextStyle(
243+ fontFamilyFallback: kMonoFallback,
244+ fontSize: size * 0.6,
245+ fontWeight: FontWeight.w700,
246+ color: c.bg,
247+ ),
248+ ),
249+ );
250+ if (url.isEmpty) return fallback;
251+ return Image.network(
252+ url,
253+ width: size,
254+ height: size,
255+ errorBuilder: (_, _, _) => fallback,
256+ loadingBuilder: (_, child, progress) => progress == null ? child : fallback,
257+ );
258+ }
259+}
260+
261+/// Compact relative time ("3h", "2d") matching the web app's dense metadata rows.
262+String relativeTime(DateTime? t) {
263+ if (t == null) return '';
264+ final d = DateTime.now().difference(t);
265+ if (d.inMinutes < 1) return 'now';
266+ if (d.inMinutes < 60) return '${d.inMinutes}m';
267+ if (d.inHours < 24) return '${d.inHours}h';
268+ if (d.inDays < 30) return '${d.inDays}d';
269+ if (d.inDays < 365) return '${(d.inDays / 30).floor()}mo';
270+ return '${(d.inDays / 365).floor()}y';
271+}
272+
273+void showToast(BuildContext context, String message) {
274+ ScaffoldMessenger.of(context)
275+ ..hideCurrentSnackBar()
276+ ..showSnackBar(SnackBar(content: Text(message)));
277+}
modified app/pubspec.lock +20 -12
@@ -86,14 +86,6 @@ packages:
8686 description: flutter
8787 source: sdk
8888 version: "0.0.0"
89- flutter_html:
90- dependency: "direct main"
91- description:
92- name: flutter_html
93- sha256: "38a2fd702ffdf3243fb7441ab58aa1bc7e6922d95a50db76534de8260638558d"
94- url: "https://pub.dev"
95- source: hosted
96- version: "3.0.0"
9789 flutter_lints:
9890 dependency: "direct dev"
9991 description:
@@ -112,6 +104,14 @@ packages:
112104 description: flutter
113105 source: sdk
114106 version: "0.0.0"
107+ flutter_widget_from_html_core:
108+ dependency: "direct main"
109+ description:
110+ name: flutter_widget_from_html_core
111+ sha256: "530f2e3cc57be1a00f8bfae1b0000064e8a6df0cf1fb4d48a80f85afde9b39b7"
112+ url: "https://pub.dev"
113+ source: hosted
114+ version: "0.17.4"
115115 html:
116116 dependency: transitive
117117 description:
@@ -176,14 +176,14 @@ packages:
176176 url: "https://pub.dev"
177177 source: hosted
178178 version: "6.1.0"
179- list_counter:
179+ logging:
180180 dependency: transitive
181181 description:
182- name: list_counter
183- sha256: c447ae3dfcd1c55f0152867090e67e219d42fe6d4f2807db4bbe8b8d69912237
182+ name: logging
183+ sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
184184 url: "https://pub.dev"
185185 source: hosted
186- version: "1.0.2"
186+ version: "1.3.0"
187187 matcher:
188188 dependency: transitive
189189 description:
@@ -461,6 +461,14 @@ packages:
461461 url: "https://pub.dev"
462462 source: hosted
463463 version: "1.1.1"
464+ webview_cookie_manager:
465+ dependency: "direct main"
466+ description:
467+ name: webview_cookie_manager
468+ sha256: "425a9feac5cd2cb62a71da3dda5ac2eaf9ece5481ee8d79f3868dc5ba8223ad3"
469+ url: "https://pub.dev"
470+ source: hosted
471+ version: "2.0.6"
464472 webview_flutter:
465473 dependency: "direct main"
466474 description:
@@ -86,14 +86,6 @@ packages:
86 description: flutter86 description: flutter
87 source: sdk87 source: sdk
88 version: "0.0.0"88 version: "0.0.0"
89- flutter_html:
90- dependency: "direct main"
91- description:
92- name: flutter_html
93- sha256: "38a2fd702ffdf3243fb7441ab58aa1bc7e6922d95a50db76534de8260638558d"
94- url: "https://pub.dev"
95- source: hosted
96- version: "3.0.0"
97 flutter_lints:89 flutter_lints:
98 dependency: "direct dev"90 dependency: "direct dev"
99 description:91 description:
@@ -112,6 +104,14 @@ packages:
112 description: flutter104 description: flutter
113 source: sdk105 source: sdk
114 version: "0.0.0"106 version: "0.0.0"
107+ flutter_widget_from_html_core:
108+ dependency: "direct main"
109+ description:
110+ name: flutter_widget_from_html_core
111+ sha256: "530f2e3cc57be1a00f8bfae1b0000064e8a6df0cf1fb4d48a80f85afde9b39b7"
112+ url: "https://pub.dev"
113+ source: hosted
114+ version: "0.17.4"
115 html:115 html:
116 dependency: transitive116 dependency: transitive
117 description:117 description:
@@ -176,14 +176,14 @@ packages:
176 url: "https://pub.dev"176 url: "https://pub.dev"
177 source: hosted177 source: hosted
178 version: "6.1.0"178 version: "6.1.0"
179- list_counter:179+ logging:
180 dependency: transitive180 dependency: transitive
181 description:181 description:
182- name: list_counter182+ name: logging
183- sha256: c447ae3dfcd1c55f0152867090e67e219d42fe6d4f2807db4bbe8b8d69912237183+ sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
184 url: "https://pub.dev"184 url: "https://pub.dev"
185 source: hosted185 source: hosted
186- version: "1.0.2"186+ version: "1.3.0"
187 matcher:187 matcher:
188 dependency: transitive188 dependency: transitive
189 description:189 description:
@@ -461,6 +461,14 @@ packages:
461 url: "https://pub.dev"461 url: "https://pub.dev"
462 source: hosted462 source: hosted
463 version: "1.1.1"463 version: "1.1.1"
464+ webview_cookie_manager:
465+ dependency: "direct main"
466+ description:
467+ name: webview_cookie_manager
468+ sha256: "425a9feac5cd2cb62a71da3dda5ac2eaf9ece5481ee8d79f3868dc5ba8223ad3"
469+ url: "https://pub.dev"
470+ source: hosted
471+ version: "2.0.6"
464 webview_flutter:472 webview_flutter:
465 dependency: "direct main"473 dependency: "direct main"
466 description:474 description:
modified app/pubspec.yaml +2 -1
@@ -39,7 +39,8 @@ dependencies:
3939 webview_flutter: ^4.14.1
4040 url_launcher: ^6.3.2
4141 intl: ^0.20.3
42- flutter_html: ^3.0.0
42+ webview_cookie_manager: ^2.0.6
43+ flutter_widget_from_html_core: ^0.17.4
4344
4445 dev_dependencies:
4546 flutter_test:
@@ -39,7 +39,8 @@ dependencies:
39 webview_flutter: ^4.14.139 webview_flutter: ^4.14.1
40 url_launcher: ^6.3.240 url_launcher: ^6.3.2
41 intl: ^0.20.341 intl: ^0.20.3
42- flutter_html: ^3.0.042+ webview_cookie_manager: ^2.0.6
43+ flutter_widget_from_html_core: ^0.17.4
43 44
44 dev_dependencies:45 dev_dependencies:
45 flutter_test:46 flutter_test:
modified app/test/widget_test.dart +120 -19
@@ -1,30 +1,131 @@
1-// This is a basic Flutter widget test.
2-//
3-// To perform an interaction with a widget in your test, use the WidgetTester
4-// utility in the flutter_test package. For example, you can send tap and scroll
5-// gestures. You can also use WidgetTester to find child widgets in the widget
6-// tree, read text, and verify that the values of widget properties are correct.
1+import 'dart:convert';
72
83 import 'package:flutter/material.dart';
94 import 'package:flutter_test/flutter_test.dart';
5+import 'package:http/testing.dart';
6+import 'package:http/http.dart' as http;
7+import 'package:shared_preferences/shared_preferences.dart';
108
11-import 'package:glean_app/main.dart';
9+import 'package:glean_app/src/api/session.dart';
10+import 'package:glean_app/src/app_state.dart';
11+import 'package:glean_app/src/screens/home_shell.dart';
12+import 'package:glean_app/src/theme.dart';
13+
14+/// Fake server covering just the routes the shell touches on startup.
15+http.Client _fakeServer({required bool signedIn}) {
16+ return MockClient((req) async {
17+ final path = req.url.path;
18+ if (path == '/api/me') {
19+ return http.Response(
20+ jsonEncode({
21+ 'user': signedIn
22+ ? {
23+ 'did': 'did:plc:abc',
24+ 'handle': 'reader.bsky.social',
25+ 'display_name': 'Reader',
26+ 'avatar_url': '',
27+ }
28+ : null,
29+ 'csrf_token': 'tok',
30+ 'has_llm': false,
31+ 'client_id': 'https://example.test/client-metadata',
32+ }),
33+ 200,
34+ headers: {'content-type': 'application/json'},
35+ );
36+ }
37+ if (path == '/api/trending/') {
38+ return http.Response(
39+ jsonEncode({
40+ 'user': null,
41+ 'trending': [
42+ {
43+ 'article_id': 1,
44+ 'title': 'A trending article',
45+ 'url': 'https://example.test/a',
46+ 'author': '',
47+ 'summary': '',
48+ 'feed_url': 'https://example.test/feed',
49+ 'feed_title': 'Example',
50+ 'favicon_url': '',
51+ 'like_count': 3,
52+ 'annotation_count': 0,
53+ 'has_liked': false,
54+ }
55+ ],
56+ 'scope': 'all',
57+ 'pagination': {
58+ 'page': 1,
59+ 'page_size': 25,
60+ 'has_prev': false,
61+ 'has_next': false,
62+ 'prev_page': 0,
63+ 'next_page': 0,
64+ },
65+ }),
66+ 200,
67+ headers: {'content-type': 'application/json'},
68+ );
69+ }
70+ if (path == '/api/dashboard/') {
71+ return http.Response(
72+ jsonEncode({
73+ 'user': {'did': 'did:plc:abc', 'handle': 'reader.bsky.social', 'display_name': '', 'avatar_url': ''},
74+ 'subscription_count': 2,
75+ 'unread_count': 7,
76+ 'articles': [],
77+ 'personal_trending': [],
78+ 'global_trending': [],
79+ 'digest_enabled': false,
80+ 'has_llm': false,
81+ 'now': 0,
82+ }),
83+ 200,
84+ headers: {'content-type': 'application/json'},
85+ );
86+ }
87+ return http.Response('{"error":"not found"}', 404,
88+ headers: {'content-type': 'application/json'});
89+ });
90+}
91+
92+Future<void> _pump(WidgetTester tester, {required bool signedIn}) async {
93+ SharedPreferences.setMockInitialValues({});
94+ final state = AppState(
95+ session: GleanSession(
96+ baseUrl: 'https://example.test',
97+ client: _fakeServer(signedIn: signedIn),
98+ ),
99+ );
100+ await state.bootstrap();
101+ await tester.pumpWidget(
102+ AppScope(
103+ state: state,
104+ child: MaterialApp(theme: gleanTheme(Brightness.light), home: const HomeShell()),
105+ ),
106+ );
107+ await tester.pumpAndSettle();
108+}
12109
13110 void main() {
14- testWidgets('Counter increments smoke test', (WidgetTester tester) async {
15- // Build our app and trigger a frame.
16- await tester.pumpWidget(const MyApp());
111+ testWidgets('a signed-out visitor gets Trending and a sign-in affordance',
112+ (tester) async {
113+ await _pump(tester, signedIn: false);
17114
18- // Verify that our counter starts at 0.
19- expect(find.text('0'), findsOneWidget);
20- expect(find.text('1'), findsNothing);
115+ expect(find.text('Sign in'), findsOneWidget);
116+ expect(find.text('A trending article'), findsOneWidget);
117+ // Home and Articles would only 401 for a visitor, so they are not offered.
118+ expect(find.text('Home'), findsNothing);
119+ expect(find.text('Articles'), findsNothing);
120+ });
21121
22- // Tap the '+' icon and trigger a frame.
23- await tester.tap(find.byIcon(Icons.add));
24- await tester.pump();
122+ testWidgets('a signed-in reader gets the full tab set and dashboard counts',
123+ (tester) async {
124+ await _pump(tester, signedIn: true);
25125
26- // Verify that our counter has incremented.
27- expect(find.text('0'), findsNothing);
28- expect(find.text('1'), findsOneWidget);
126+ expect(find.text('Sign in'), findsNothing);
127+ expect(find.text('Articles'), findsWidgets);
128+ expect(find.text('7'), findsOneWidget);
129+ expect(find.text('unread'), findsOneWidget);
29130 });
30131 }
@@ -1,30 +1,131 @@
1-// This is a basic Flutter widget test.1+import 'dart:convert';
2-//
3-// To perform an interaction with a widget in your test, use the WidgetTester
4-// utility in the flutter_test package. For example, you can send tap and scroll
5-// gestures. You can also use WidgetTester to find child widgets in the widget
6-// tree, read text, and verify that the values of widget properties are correct.
7 2
8 import 'package:flutter/material.dart';3 import 'package:flutter/material.dart';
9 import 'package:flutter_test/flutter_test.dart';4 import 'package:flutter_test/flutter_test.dart';
5+import 'package:http/testing.dart';
6+import 'package:http/http.dart' as http;
7+import 'package:shared_preferences/shared_preferences.dart';
10 8
11-import 'package:glean_app/main.dart';9+import 'package:glean_app/src/api/session.dart';
10+import 'package:glean_app/src/app_state.dart';
11+import 'package:glean_app/src/screens/home_shell.dart';
12+import 'package:glean_app/src/theme.dart';
13+
14+/// Fake server covering just the routes the shell touches on startup.
15+http.Client _fakeServer({required bool signedIn}) {
16+ return MockClient((req) async {
17+ final path = req.url.path;
18+ if (path == '/api/me') {
19+ return http.Response(
20+ jsonEncode({
21+ 'user': signedIn
22+ ? {
23+ 'did': 'did:plc:abc',
24+ 'handle': 'reader.bsky.social',
25+ 'display_name': 'Reader',
26+ 'avatar_url': '',
27+ }
28+ : null,
29+ 'csrf_token': 'tok',
30+ 'has_llm': false,
31+ 'client_id': 'https://example.test/client-metadata',
32+ }),
33+ 200,
34+ headers: {'content-type': 'application/json'},
35+ );
36+ }
37+ if (path == '/api/trending/') {
38+ return http.Response(
39+ jsonEncode({
40+ 'user': null,
41+ 'trending': [
42+ {
43+ 'article_id': 1,
44+ 'title': 'A trending article',
45+ 'url': 'https://example.test/a',
46+ 'author': '',
47+ 'summary': '',
48+ 'feed_url': 'https://example.test/feed',
49+ 'feed_title': 'Example',
50+ 'favicon_url': '',
51+ 'like_count': 3,
52+ 'annotation_count': 0,
53+ 'has_liked': false,
54+ }
55+ ],
56+ 'scope': 'all',
57+ 'pagination': {
58+ 'page': 1,
59+ 'page_size': 25,
60+ 'has_prev': false,
61+ 'has_next': false,
62+ 'prev_page': 0,
63+ 'next_page': 0,
64+ },
65+ }),
66+ 200,
67+ headers: {'content-type': 'application/json'},
68+ );
69+ }
70+ if (path == '/api/dashboard/') {
71+ return http.Response(
72+ jsonEncode({
73+ 'user': {'did': 'did:plc:abc', 'handle': 'reader.bsky.social', 'display_name': '', 'avatar_url': ''},
74+ 'subscription_count': 2,
75+ 'unread_count': 7,
76+ 'articles': [],
77+ 'personal_trending': [],
78+ 'global_trending': [],
79+ 'digest_enabled': false,
80+ 'has_llm': false,
81+ 'now': 0,
82+ }),
83+ 200,
84+ headers: {'content-type': 'application/json'},
85+ );
86+ }
87+ return http.Response('{"error":"not found"}', 404,
88+ headers: {'content-type': 'application/json'});
89+ });
90+}
91+
92+Future<void> _pump(WidgetTester tester, {required bool signedIn}) async {
93+ SharedPreferences.setMockInitialValues({});
94+ final state = AppState(
95+ session: GleanSession(
96+ baseUrl: 'https://example.test',
97+ client: _fakeServer(signedIn: signedIn),
98+ ),
99+ );
100+ await state.bootstrap();
101+ await tester.pumpWidget(
102+ AppScope(
103+ state: state,
104+ child: MaterialApp(theme: gleanTheme(Brightness.light), home: const HomeShell()),
105+ ),
106+ );
107+ await tester.pumpAndSettle();
108+}
12 109
13 void main() {110 void main() {
14- testWidgets('Counter increments smoke test', (WidgetTester tester) async {111+ testWidgets('a signed-out visitor gets Trending and a sign-in affordance',
15- // Build our app and trigger a frame.112+ (tester) async {
16- await tester.pumpWidget(const MyApp());113+ await _pump(tester, signedIn: false);
17 114
18- // Verify that our counter starts at 0.115+ expect(find.text('Sign in'), findsOneWidget);
19- expect(find.text('0'), findsOneWidget);116+ expect(find.text('A trending article'), findsOneWidget);
20- expect(find.text('1'), findsNothing);117+ // Home and Articles would only 401 for a visitor, so they are not offered.
118+ expect(find.text('Home'), findsNothing);
119+ expect(find.text('Articles'), findsNothing);
120+ });
21 121
22- // Tap the '+' icon and trigger a frame.122+ testWidgets('a signed-in reader gets the full tab set and dashboard counts',
23- await tester.tap(find.byIcon(Icons.add));123+ (tester) async {
24- await tester.pump();124+ await _pump(tester, signedIn: true);
25 125
26- // Verify that our counter has incremented.126+ expect(find.text('Sign in'), findsNothing);
27- expect(find.text('0'), findsNothing);127+ expect(find.text('Articles'), findsWidgets);
28- expect(find.text('1'), findsOneWidget);128+ expect(find.text('7'), findsOneWidget);
129+ expect(find.text('unread'), findsOneWidget);
29 });130 });
30 }131 }