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>
7d9686b parent: af65e66 modified
app/lib/main.dart +41 -102 | @@ -1,122 +1,61 @@ | ||
| 1 | 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 | 7 | void main() { |
| 4 | - runApp(const MyApp()); | |
| 8 | + runApp(const GleanApp()); | |
| 5 | 9 | } |
| 6 | 10 | |
| 7 | -class MyApp extends StatelessWidget { | |
| 8 | - const MyApp({super.key}); | |
| 11 | +class GleanApp extends StatefulWidget { | |
| 12 | + const GleanApp({super.key}); | |
| 9 | 13 | |
| 10 | - // This widget is the root of your application. | |
| 11 | 14 | @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(); | |
| 36 | 16 | } |
| 37 | 17 | |
| 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(); | |
| 51 | 20 | |
| 52 | 21 | @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 | + } | |
| 58 | 26 | |
| 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(); | |
| 68 | 31 | } |
| 69 | 32 | |
| 70 | 33 | @override |
| 71 | 34 | 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(), | |
| 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 | +} | |
| @@ -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 | @override | 14 | @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 | @override | 21 | @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 has | 29 | + _state.dispose(); |
| 62 | - // changed in this State, which causes it to rerun the build method below | 30 | + 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 | @override | 33 | @override |
| 71 | Widget build(BuildContext context) { | 34 | Widget build(BuildContext context) { |
| 72 | - // This method is rerun every time setState is called, for instance as done | 35 | + 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 methods | 38 | + title: 'glean', |
| 76 | - // fast, so that you can just rebuild anything that needs updating rather | 39 | + 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/screens/trending_screen.dart +128 -0 | new file mode 100644 | ||
| @@ -0,0 +1,128 @@ | ||
| 1 | +import 'package:flutter/material.dart'; | |
| 2 | + | |
| 3 | +import '../api/responses.dart'; | |
| 4 | +import '../app_state.dart'; | |
| 5 | +import '../theme.dart'; | |
| 6 | +import '../widgets/common.dart'; | |
| 7 | +import 'article_screen.dart'; | |
| 8 | + | |
| 9 | +/// Public trending list. This is the one signed-out screen, so it doubles as | |
| 10 | +/// the landing page for visitors who have not signed in. | |
| 11 | +class TrendingScreen extends StatefulWidget { | |
| 12 | + const TrendingScreen({super.key}); | |
| 13 | + | |
| 14 | + @override | |
| 15 | + State<TrendingScreen> createState() => _TrendingScreenState(); | |
| 16 | +} | |
| 17 | + | |
| 18 | +class _TrendingScreenState extends State<TrendingScreen> { | |
| 19 | + Future<TrendingResponse>? _future; | |
| 20 | + int _page = 1; | |
| 21 | + | |
| 22 | + @override | |
| 23 | + void initState() { | |
| 24 | + super.initState(); | |
| 25 | + _load(); | |
| 26 | + } | |
| 27 | + | |
| 28 | + void _load() { | |
| 29 | + setState(() { | |
| 30 | + _future = AppScope.read(context).client.trending(page: _page); | |
| 31 | + }); | |
| 32 | + } | |
| 33 | + | |
| 34 | + @override | |
| 35 | + Widget build(BuildContext context) { | |
| 36 | + final text = Theme.of(context).textTheme; | |
| 37 | + final c = GleanColors.of(context); | |
| 38 | + | |
| 39 | + return AsyncView<TrendingResponse>( | |
| 40 | + future: _future, | |
| 41 | + onRetry: _load, | |
| 42 | + builder: (context, data) { | |
| 43 | + if (data.trending.isEmpty) { | |
| 44 | + return const EmptyView(message: 'Nothing trending yet.'); | |
| 45 | + } | |
| 46 | + return RefreshIndicator( | |
| 47 | + onRefresh: () async => _load(), | |
| 48 | + child: ListView.builder( | |
| 49 | + itemCount: data.trending.length + 1, | |
| 50 | + itemBuilder: (context, i) { | |
| 51 | + if (i == data.trending.length) { | |
| 52 | + return Padding( | |
| 53 | + padding: const EdgeInsets.all(16), | |
| 54 | + child: Row( | |
| 55 | + mainAxisAlignment: MainAxisAlignment.spaceBetween, | |
| 56 | + children: [ | |
| 57 | + GleanButton( | |
| 58 | + label: 'Prev', | |
| 59 | + onPressed: data.pagination.hasPrev | |
| 60 | + ? () { | |
| 61 | + _page = data.pagination.prevPage; | |
| 62 | + _load(); | |
| 63 | + } | |
| 64 | + : null, | |
| 65 | + ), | |
| 66 | + GleanButton( | |
| 67 | + label: 'Next', | |
| 68 | + onPressed: data.pagination.hasNext | |
| 69 | + ? () { | |
| 70 | + _page = data.pagination.nextPage; | |
| 71 | + _load(); | |
| 72 | + } | |
| 73 | + : null, | |
| 74 | + ), | |
| 75 | + ], | |
| 76 | + ), | |
| 77 | + ); | |
| 78 | + } | |
| 79 | + final t = data.trending[i]; | |
| 80 | + return InkWell( | |
| 81 | + onTap: () => Navigator.of(context).push( | |
| 82 | + MaterialPageRoute(builder: (_) => ArticleScreen(articleId: t.articleId)), | |
| 83 | + ), | |
| 84 | + child: Container( | |
| 85 | + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), | |
| 86 | + decoration: BoxDecoration( | |
| 87 | + border: Border(bottom: BorderSide(color: c.faint, width: 1)), | |
| 88 | + ), | |
| 89 | + child: Column( | |
| 90 | + crossAxisAlignment: CrossAxisAlignment.start, | |
| 91 | + children: [ | |
| 92 | + Row( | |
| 93 | + children: [ | |
| 94 | + Text('${i + 1 + (data.pagination.page - 1) * data.pagination.pageSize}', | |
| 95 | + style: text.bodySmall), | |
| 96 | + const SizedBox(width: 10), | |
| 97 | + FaviconBadge(url: t.faviconUrl, seed: t.feedTitle, size: 14), | |
| 98 | + const SizedBox(width: 6), | |
| 99 | + Expanded( | |
| 100 | + child: Text(t.feedTitle, | |
| 101 | + maxLines: 1, | |
| 102 | + overflow: TextOverflow.ellipsis, | |
| 103 | + style: text.bodySmall), | |
| 104 | + ), | |
| 105 | + ], | |
| 106 | + ), | |
| 107 | + const SizedBox(height: 6), | |
| 108 | + Text(t.title, | |
| 109 | + style: text.bodyLarge?.copyWith(fontWeight: FontWeight.w700)), | |
| 110 | + const SizedBox(height: 8), | |
| 111 | + Row( | |
| 112 | + children: [ | |
| 113 | + GleanTag('${t.likeCount} likes'), | |
| 114 | + const SizedBox(width: 6), | |
| 115 | + if (t.annotationCount > 0) GleanTag('${t.annotationCount} notes'), | |
| 116 | + ], | |
| 117 | + ), | |
| 118 | + ], | |
| 119 | + ), | |
| 120 | + ), | |
| 121 | + ); | |
| 122 | + }, | |
| 123 | + ), | |
| 124 | + ); | |
| 125 | + }, | |
| 126 | + ); | |
| 127 | + } | |
| 128 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,128 @@ | |||
| 1 | +import 'package:flutter/material.dart'; | ||
| 2 | + | ||
| 3 | +import '../api/responses.dart'; | ||
| 4 | +import '../app_state.dart'; | ||
| 5 | +import '../theme.dart'; | ||
| 6 | +import '../widgets/common.dart'; | ||
| 7 | +import 'article_screen.dart'; | ||
| 8 | + | ||
| 9 | +/// Public trending list. This is the one signed-out screen, so it doubles as | ||
| 10 | +/// the landing page for visitors who have not signed in. | ||
| 11 | +class TrendingScreen extends StatefulWidget { | ||
| 12 | + const TrendingScreen({super.key}); | ||
| 13 | + | ||
| 14 | + @override | ||
| 15 | + State<TrendingScreen> createState() => _TrendingScreenState(); | ||
| 16 | +} | ||
| 17 | + | ||
| 18 | +class _TrendingScreenState extends State<TrendingScreen> { | ||
| 19 | + Future<TrendingResponse>? _future; | ||
| 20 | + int _page = 1; | ||
| 21 | + | ||
| 22 | + @override | ||
| 23 | + void initState() { | ||
| 24 | + super.initState(); | ||
| 25 | + _load(); | ||
| 26 | + } | ||
| 27 | + | ||
| 28 | + void _load() { | ||
| 29 | + setState(() { | ||
| 30 | + _future = AppScope.read(context).client.trending(page: _page); | ||
| 31 | + }); | ||
| 32 | + } | ||
| 33 | + | ||
| 34 | + @override | ||
| 35 | + Widget build(BuildContext context) { | ||
| 36 | + final text = Theme.of(context).textTheme; | ||
| 37 | + final c = GleanColors.of(context); | ||
| 38 | + | ||
| 39 | + return AsyncView<TrendingResponse>( | ||
| 40 | + future: _future, | ||
| 41 | + onRetry: _load, | ||
| 42 | + builder: (context, data) { | ||
| 43 | + if (data.trending.isEmpty) { | ||
| 44 | + return const EmptyView(message: 'Nothing trending yet.'); | ||
| 45 | + } | ||
| 46 | + return RefreshIndicator( | ||
| 47 | + onRefresh: () async => _load(), | ||
| 48 | + child: ListView.builder( | ||
| 49 | + itemCount: data.trending.length + 1, | ||
| 50 | + itemBuilder: (context, i) { | ||
| 51 | + if (i == data.trending.length) { | ||
| 52 | + return Padding( | ||
| 53 | + padding: const EdgeInsets.all(16), | ||
| 54 | + child: Row( | ||
| 55 | + mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||
| 56 | + children: [ | ||
| 57 | + GleanButton( | ||
| 58 | + label: 'Prev', | ||
| 59 | + onPressed: data.pagination.hasPrev | ||
| 60 | + ? () { | ||
| 61 | + _page = data.pagination.prevPage; | ||
| 62 | + _load(); | ||
| 63 | + } | ||
| 64 | + : null, | ||
| 65 | + ), | ||
| 66 | + GleanButton( | ||
| 67 | + label: 'Next', | ||
| 68 | + onPressed: data.pagination.hasNext | ||
| 69 | + ? () { | ||
| 70 | + _page = data.pagination.nextPage; | ||
| 71 | + _load(); | ||
| 72 | + } | ||
| 73 | + : null, | ||
| 74 | + ), | ||
| 75 | + ], | ||
| 76 | + ), | ||
| 77 | + ); | ||
| 78 | + } | ||
| 79 | + final t = data.trending[i]; | ||
| 80 | + return InkWell( | ||
| 81 | + onTap: () => Navigator.of(context).push( | ||
| 82 | + MaterialPageRoute(builder: (_) => ArticleScreen(articleId: t.articleId)), | ||
| 83 | + ), | ||
| 84 | + child: Container( | ||
| 85 | + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), | ||
| 86 | + decoration: BoxDecoration( | ||
| 87 | + border: Border(bottom: BorderSide(color: c.faint, width: 1)), | ||
| 88 | + ), | ||
| 89 | + child: Column( | ||
| 90 | + crossAxisAlignment: CrossAxisAlignment.start, | ||
| 91 | + children: [ | ||
| 92 | + Row( | ||
| 93 | + children: [ | ||
| 94 | + Text('${i + 1 + (data.pagination.page - 1) * data.pagination.pageSize}', | ||
| 95 | + style: text.bodySmall), | ||
| 96 | + const SizedBox(width: 10), | ||
| 97 | + FaviconBadge(url: t.faviconUrl, seed: t.feedTitle, size: 14), | ||
| 98 | + const SizedBox(width: 6), | ||
| 99 | + Expanded( | ||
| 100 | + child: Text(t.feedTitle, | ||
| 101 | + maxLines: 1, | ||
| 102 | + overflow: TextOverflow.ellipsis, | ||
| 103 | + style: text.bodySmall), | ||
| 104 | + ), | ||
| 105 | + ], | ||
| 106 | + ), | ||
| 107 | + const SizedBox(height: 6), | ||
| 108 | + Text(t.title, | ||
| 109 | + style: text.bodyLarge?.copyWith(fontWeight: FontWeight.w700)), | ||
| 110 | + const SizedBox(height: 8), | ||
| 111 | + Row( | ||
| 112 | + children: [ | ||
| 113 | + GleanTag('${t.likeCount} likes'), | ||
| 114 | + const SizedBox(width: 6), | ||
| 115 | + if (t.annotationCount > 0) GleanTag('${t.annotationCount} notes'), | ||
| 116 | + ], | ||
| 117 | + ), | ||
| 118 | + ], | ||
| 119 | + ), | ||
| 120 | + ), | ||
| 121 | + ); | ||
| 122 | + }, | ||
| 123 | + ), | ||
| 124 | + ); | ||
| 125 | + }, | ||
| 126 | + ); | ||
| 127 | + } | ||
| 128 | +} | ||
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: | ||
| 86 | 86 | description: flutter |
| 87 | 87 | source: sdk |
| 88 | 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 | 89 | flutter_lints: |
| 98 | 90 | dependency: "direct dev" |
| 99 | 91 | description: |
| @@ -112,6 +104,14 @@ packages: | ||
| 112 | 104 | description: flutter |
| 113 | 105 | source: sdk |
| 114 | 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 | 115 | html: |
| 116 | 116 | dependency: transitive |
| 117 | 117 | description: |
| @@ -176,14 +176,14 @@ packages: | ||
| 176 | 176 | url: "https://pub.dev" |
| 177 | 177 | source: hosted |
| 178 | 178 | version: "6.1.0" |
| 179 | - list_counter: | |
| 179 | + logging: | |
| 180 | 180 | dependency: transitive |
| 181 | 181 | description: |
| 182 | - name: list_counter | |
| 183 | - sha256: c447ae3dfcd1c55f0152867090e67e219d42fe6d4f2807db4bbe8b8d69912237 | |
| 182 | + name: logging | |
| 183 | + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 | |
| 184 | 184 | url: "https://pub.dev" |
| 185 | 185 | source: hosted |
| 186 | - version: "1.0.2" | |
| 186 | + version: "1.3.0" | |
| 187 | 187 | matcher: |
| 188 | 188 | dependency: transitive |
| 189 | 189 | description: |
| @@ -461,6 +461,14 @@ packages: | ||
| 461 | 461 | url: "https://pub.dev" |
| 462 | 462 | source: hosted |
| 463 | 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 | 472 | webview_flutter: |
| 465 | 473 | dependency: "direct main" |
| 466 | 474 | description: |
| @@ -86,14 +86,6 @@ packages: | |||
| 86 | description: flutter | 86 | description: flutter |
| 87 | source: sdk | 87 | 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: flutter | 104 | description: flutter |
| 113 | source: sdk | 105 | 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: transitive | 116 | dependency: transitive |
| 117 | description: | 117 | description: |
| @@ -176,14 +176,14 @@ packages: | |||
| 176 | url: "https://pub.dev" | 176 | url: "https://pub.dev" |
| 177 | source: hosted | 177 | source: hosted |
| 178 | version: "6.1.0" | 178 | version: "6.1.0" |
| 179 | - list_counter: | 179 | + logging: |
| 180 | dependency: transitive | 180 | dependency: transitive |
| 181 | description: | 181 | description: |
| 182 | - name: list_counter | 182 | + name: logging |
| 183 | - sha256: c447ae3dfcd1c55f0152867090e67e219d42fe6d4f2807db4bbe8b8d69912237 | 183 | + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 |
| 184 | url: "https://pub.dev" | 184 | url: "https://pub.dev" |
| 185 | source: hosted | 185 | source: hosted |
| 186 | - version: "1.0.2" | 186 | + version: "1.3.0" |
| 187 | matcher: | 187 | matcher: |
| 188 | dependency: transitive | 188 | dependency: transitive |
| 189 | description: | 189 | description: |
| @@ -461,6 +461,14 @@ packages: | |||
| 461 | url: "https://pub.dev" | 461 | url: "https://pub.dev" |
| 462 | source: hosted | 462 | 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: | ||
| 39 | 39 | webview_flutter: ^4.14.1 |
| 40 | 40 | url_launcher: ^6.3.2 |
| 41 | 41 | 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 | |
| 43 | 44 | |
| 44 | 45 | dev_dependencies: |
| 45 | 46 | flutter_test: |
| @@ -39,7 +39,8 @@ dependencies: | |||
| 39 | webview_flutter: ^4.14.1 | 39 | webview_flutter: ^4.14.1 |
| 40 | url_launcher: ^6.3.2 | 40 | url_launcher: ^6.3.2 |
| 41 | intl: ^0.20.3 | 41 | 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 | ||
| 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'; | |
| 7 | 2 | |
| 8 | 3 | import 'package:flutter/material.dart'; |
| 9 | 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 | 110 | 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); | |
| 17 | 114 | |
| 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 | + }); | |
| 21 | 121 | |
| 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); | |
| 25 | 125 | |
| 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); | |
| 29 | 130 | }); |
| 30 | 131 | } |
| @@ -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 | } |