Vatxzz commited on
Commit
3cb8bb8
Β·
1 Parent(s): 323a114

Fixed feed and back gesture issue

Browse files
.gitignore CHANGED
@@ -43,4 +43,6 @@ graph/
43
  # Python / Agent
44
 
45
  context.md
46
- i-want-you-to-spicy-dusk.md
 
 
 
43
  # Python / Agent
44
 
45
  context.md
46
+ i-want-you-to-spicy-dusk.md
47
+ CACHY_OVERVIEW.md
48
+ presenter
app/lib/ui/core/home_shell.dart CHANGED
@@ -14,7 +14,7 @@ import 'package:provider/provider.dart';
14
  import '../features/actions/views/actions_screen.dart';
15
  import '../features/capture/views/capture_sheet.dart';
16
  import '../features/collections/views/collections_screen.dart';
17
- import '../features/graph/views/graph_screen.dart';
18
  import '../features/library/view_models/library_view_model.dart';
19
  import '../features/library/views/library_screen.dart';
20
  import '../features/profile/views/profile_screen.dart';
@@ -41,7 +41,7 @@ const _navItems = [
41
  _NavDef(icon: PhosphorIconsRegular.house, activeIcon: PhosphorIconsFill.house, label: 'HOME'),
42
  _NavDef(icon: PhosphorIconsRegular.folder, activeIcon: PhosphorIconsFill.folder, label: 'FOLDERS'),
43
  _NavDef(icon: PhosphorIconsRegular.listChecks, activeIcon: PhosphorIconsFill.listChecks, label: 'TO-DO'),
44
- _NavDef(icon: PhosphorIconsRegular.graph, activeIcon: PhosphorIconsFill.graph, label: 'GRAPH'),
45
  _NavDef(icon: PhosphorIconsRegular.user, activeIcon: PhosphorIconsFill.user, label: 'YOU'),
46
  ];
47
 
@@ -61,7 +61,7 @@ class _HomeShellState extends State<HomeShell> {
61
  LibraryScreen(), // 0 β€” HOME
62
  CollectionsScreen(), // 1 β€” FOLDERS
63
  ActionsScreen(), // 2 β€” TO-DO
64
- GraphScreen(), // 3 β€” GRAPH
65
  ProfileScreen(), // 4 β€” YOU
66
  ];
67
 
 
14
  import '../features/actions/views/actions_screen.dart';
15
  import '../features/capture/views/capture_sheet.dart';
16
  import '../features/collections/views/collections_screen.dart';
17
+ import '../features/feed/views/knowledge_feed_screen.dart';
18
  import '../features/library/view_models/library_view_model.dart';
19
  import '../features/library/views/library_screen.dart';
20
  import '../features/profile/views/profile_screen.dart';
 
41
  _NavDef(icon: PhosphorIconsRegular.house, activeIcon: PhosphorIconsFill.house, label: 'HOME'),
42
  _NavDef(icon: PhosphorIconsRegular.folder, activeIcon: PhosphorIconsFill.folder, label: 'FOLDERS'),
43
  _NavDef(icon: PhosphorIconsRegular.listChecks, activeIcon: PhosphorIconsFill.listChecks, label: 'TO-DO'),
44
+ _NavDef(icon: PhosphorIconsRegular.cardsThree, activeIcon: PhosphorIconsFill.cardsThree, label: 'FEED'),
45
  _NavDef(icon: PhosphorIconsRegular.user, activeIcon: PhosphorIconsFill.user, label: 'YOU'),
46
  ];
47
 
 
61
  LibraryScreen(), // 0 β€” HOME
62
  CollectionsScreen(), // 1 β€” FOLDERS
63
  ActionsScreen(), // 2 β€” TO-DO
64
+ KnowledgeFeedScreen(inShell: true), // 3 β€” FEED
65
  ProfileScreen(), // 4 β€” YOU
66
  ];
67
 
app/lib/ui/core/ios_back_gesture.dart ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /// A genuine iOS-style interactive back gesture for all platforms.
2
+ ///
3
+ /// Unlike a plain "swipe β†’ pop" (which fires instantly with no feedback), this
4
+ /// drives the route's transition animation directly: the top screen tracks your
5
+ /// finger, the previous screen parallax-slides in behind it, and the gesture
6
+ /// commits (pops) or springs back based on how far / how fast you dragged β€” the
7
+ /// real iOS "back screen" feel.
8
+ ///
9
+ /// Adapted from Flutter's private CupertinoPageTransitionsBuilder internals
10
+ /// (flutter/lib/src/cupertino/route.dart), with two deliberate changes:
11
+ /// 1. it's used on every platform (the app wants iOS transitions everywhere), and
12
+ /// 2. the edge activation zone is a little wider than iOS's 20dp for an easier
13
+ /// grab β€” but still EDGE-based, so it never fights horizontal content like
14
+ /// the library's tab swipe or horizontal lists.
15
+ library;
16
+
17
+ import 'dart:math';
18
+ import 'dart:ui' show lerpDouble;
19
+
20
+ import 'package:flutter/cupertino.dart';
21
+ import 'package:flutter/gestures.dart';
22
+
23
+ /// Width of the left-edge zone that starts the back gesture. iOS uses ~20; a bit
24
+ /// wider is easier to hit without interfering with centre-screen content.
25
+ const double _kBackGestureWidth = 44.0;
26
+ const double _kMinFlingVelocity = 1.0; // screen widths per second
27
+ const int _kMaxDroppedSwipePageForwardAnimationTime = 800; // ms
28
+ const int _kMaxPageBackAnimationTime = 300; // ms
29
+
30
+ /// Routes whose back gesture is mid-drag β€” used to switch the transition to a
31
+ /// linear curve so it tracks the finger 1:1 while dragging.
32
+ final Set<PageRoute<dynamic>> _gesturesInProgress = <PageRoute<dynamic>>{};
33
+
34
+ class IOSPageTransitionsBuilder extends PageTransitionsBuilder {
35
+ const IOSPageTransitionsBuilder();
36
+
37
+ @override
38
+ Widget buildTransitions<T>(
39
+ PageRoute<T> route,
40
+ BuildContext context,
41
+ Animation<double> animation,
42
+ Animation<double> secondaryAnimation,
43
+ Widget child,
44
+ ) {
45
+ return CupertinoPageTransition(
46
+ primaryRouteAnimation: animation,
47
+ secondaryRouteAnimation: secondaryAnimation,
48
+ // Linear (no easing) while the user is actively dragging so the page
49
+ // stays glued to the finger; eased otherwise.
50
+ linearTransition: _gesturesInProgress.contains(route),
51
+ child: _BackGestureDetector<T>(
52
+ enabledCallback: () => _isPopGestureEnabled<T>(route),
53
+ onStartPopGesture: () => _startPopGesture<T>(route),
54
+ child: child,
55
+ ),
56
+ );
57
+ }
58
+ }
59
+
60
+ bool _isPopGestureEnabled<T>(PageRoute<T> route) {
61
+ // Can't go back from the first route, or one handling its own pop, or a
62
+ // fullscreen dialog; and only when the route is settled and idle.
63
+ if (route.isFirst) return false;
64
+ if (route.willHandlePopInternally) return false;
65
+ if (route.popDisposition == RoutePopDisposition.doNotPop) return false;
66
+ if (route.fullscreenDialog) return false;
67
+ if (route.animation!.status != AnimationStatus.completed) return false;
68
+ if (route.secondaryAnimation!.status != AnimationStatus.dismissed) return false;
69
+ if (_gesturesInProgress.contains(route)) return false;
70
+ return true;
71
+ }
72
+
73
+ _BackGestureController<T> _startPopGesture<T>(PageRoute<T> route) {
74
+ _gesturesInProgress.add(route);
75
+ return _BackGestureController<T>(
76
+ navigator: route.navigator!,
77
+ // The transition animation the route is driven by. Protected on
78
+ // TransitionRoute, but reading it here mirrors what Cupertino does internally.
79
+ // ignore: invalid_use_of_protected_member
80
+ controller: route.controller!,
81
+ onEnded: () => _gesturesInProgress.remove(route),
82
+ );
83
+ }
84
+
85
+ /// Drives the route's [AnimationController] from drag input, then settles it
86
+ /// forward (cancel) or in reverse (pop) on release. Mirrors Cupertino's
87
+ /// `_CupertinoBackGestureController`.
88
+ class _BackGestureController<T> {
89
+ _BackGestureController({
90
+ required this.navigator,
91
+ required this.controller,
92
+ required this.onEnded,
93
+ }) {
94
+ navigator.didStartUserGesture();
95
+ }
96
+
97
+ final AnimationController controller;
98
+ final NavigatorState navigator;
99
+ final VoidCallback onEnded;
100
+
101
+ /// Fraction of screen width dragged this frame (positive = toward dismiss).
102
+ void dragUpdate(double delta) {
103
+ controller.value -= delta;
104
+ }
105
+
106
+ void dragEnd(double velocity) {
107
+ const Curve animationCurve = Curves.fastLinearToSlowEaseIn;
108
+
109
+ final bool animateForward;
110
+ if (velocity.abs() >= _kMinFlingVelocity) {
111
+ // A fling: direction wins over position.
112
+ animateForward = velocity <= 0;
113
+ } else {
114
+ // Released: past the halfway point commits the pop.
115
+ animateForward = controller.value > 0.5;
116
+ }
117
+
118
+ if (animateForward) {
119
+ final int droppedPageForwardAnimationTime = min(
120
+ lerpDouble(
121
+ _kMaxDroppedSwipePageForwardAnimationTime,
122
+ 0,
123
+ controller.value,
124
+ )!.floor(),
125
+ _kMaxPageBackAnimationTime,
126
+ );
127
+ controller.animateTo(
128
+ 1.0,
129
+ duration: Duration(milliseconds: droppedPageForwardAnimationTime),
130
+ curve: animationCurve,
131
+ );
132
+ } else {
133
+ navigator.pop();
134
+ if (controller.isAnimating) {
135
+ final int droppedPageBackAnimationTime = lerpDouble(
136
+ 0,
137
+ _kMaxDroppedSwipePageForwardAnimationTime,
138
+ controller.value,
139
+ )!.floor();
140
+ controller.animateBack(
141
+ 0.0,
142
+ duration: Duration(milliseconds: droppedPageBackAnimationTime),
143
+ curve: animationCurve,
144
+ );
145
+ }
146
+ }
147
+
148
+ if (controller.isAnimating) {
149
+ late AnimationStatusListener animationStatusCallback;
150
+ animationStatusCallback = (AnimationStatus status) {
151
+ _finish();
152
+ controller.removeStatusListener(animationStatusCallback);
153
+ };
154
+ controller.addStatusListener(animationStatusCallback);
155
+ } else {
156
+ _finish();
157
+ }
158
+ }
159
+
160
+ void _finish() {
161
+ onEnded();
162
+ navigator.didStopUserGesture();
163
+ }
164
+ }
165
+
166
+ /// Left-edge drag recognizer that hands input to a [_BackGestureController].
167
+ class _BackGestureDetector<T> extends StatefulWidget {
168
+ const _BackGestureDetector({
169
+ required this.enabledCallback,
170
+ required this.onStartPopGesture,
171
+ required this.child,
172
+ });
173
+
174
+ final ValueGetter<bool> enabledCallback;
175
+ final ValueGetter<_BackGestureController<T>> onStartPopGesture;
176
+ final Widget child;
177
+
178
+ @override
179
+ State<_BackGestureDetector<T>> createState() => _BackGestureDetectorState<T>();
180
+ }
181
+
182
+ class _BackGestureDetectorState<T> extends State<_BackGestureDetector<T>> {
183
+ _BackGestureController<T>? _backGestureController;
184
+ late HorizontalDragGestureRecognizer _recognizer;
185
+
186
+ @override
187
+ void initState() {
188
+ super.initState();
189
+ _recognizer = HorizontalDragGestureRecognizer(debugOwner: this)
190
+ ..onStart = _handleDragStart
191
+ ..onUpdate = _handleDragUpdate
192
+ ..onEnd = _handleDragEnd
193
+ ..onCancel = _handleDragCancel;
194
+ }
195
+
196
+ @override
197
+ void dispose() {
198
+ _recognizer.dispose();
199
+ super.dispose();
200
+ }
201
+
202
+ void _handleDragStart(DragStartDetails details) {
203
+ _backGestureController = widget.onStartPopGesture();
204
+ }
205
+
206
+ void _handleDragUpdate(DragUpdateDetails details) {
207
+ _backGestureController?.dragUpdate(
208
+ _convertToLogical(details.primaryDelta! / context.size!.width),
209
+ );
210
+ }
211
+
212
+ void _handleDragEnd(DragEndDetails details) {
213
+ _backGestureController?.dragEnd(
214
+ _convertToLogical(details.velocity.pixelsPerSecond.dx / context.size!.width),
215
+ );
216
+ _backGestureController = null;
217
+ }
218
+
219
+ void _handleDragCancel() {
220
+ _backGestureController?.dragEnd(0.0);
221
+ _backGestureController = null;
222
+ }
223
+
224
+ void _handlePointerDown(PointerDownEvent event) {
225
+ if (widget.enabledCallback()) {
226
+ _recognizer.addPointer(event);
227
+ }
228
+ }
229
+
230
+ double _convertToLogical(double value) {
231
+ return switch (Directionality.of(context)) {
232
+ TextDirection.rtl => -value,
233
+ TextDirection.ltr => value,
234
+ };
235
+ }
236
+
237
+ @override
238
+ Widget build(BuildContext context) {
239
+ final double dragAreaWidth = Directionality.of(context) == TextDirection.ltr
240
+ ? MediaQuery.paddingOf(context).left + _kBackGestureWidth
241
+ : MediaQuery.paddingOf(context).right + _kBackGestureWidth;
242
+
243
+ return Stack(
244
+ fit: StackFit.passthrough,
245
+ children: [
246
+ widget.child,
247
+ PositionedDirectional(
248
+ start: 0,
249
+ width: dragAreaWidth,
250
+ top: 0,
251
+ bottom: 0,
252
+ child: Listener(
253
+ onPointerDown: _handlePointerDown,
254
+ behavior: HitTestBehavior.translucent,
255
+ ),
256
+ ),
257
+ ],
258
+ );
259
+ }
260
+ }
app/lib/ui/core/theme.dart CHANGED
@@ -5,6 +5,7 @@ library;
5
  import 'package:flutter/material.dart';
6
 
7
  import 'brand.dart';
 
8
 
9
  class AppTheme {
10
  static ThemeData light() => _build(Brightness.light);
@@ -165,59 +166,5 @@ class Insets {
165
  static const splitPane = 1100.0;
166
  }
167
 
168
- /// Custom transition builder allowing swipe-right-to-go-back from ANYWHERE
169
- /// on the screen, while preserving iOS slide transitions.
170
- class IOSPageTransitionsBuilder extends PageTransitionsBuilder {
171
- const IOSPageTransitionsBuilder();
172
-
173
- @override
174
- Widget buildTransitions<T>(
175
- PageRoute<T> route,
176
- BuildContext context,
177
- Animation<double> animation,
178
- Animation<double> secondaryAnimation,
179
- Widget child,
180
- ) {
181
- return const CupertinoPageTransitionsBuilder().buildTransitions<T>(
182
- route,
183
- context,
184
- animation,
185
- secondaryAnimation,
186
- _SwipeBackGate(child: child),
187
- );
188
- }
189
- }
190
-
191
- class _SwipeBackGate extends StatefulWidget {
192
- const _SwipeBackGate({required this.child});
193
- final Widget child;
194
-
195
- @override
196
- State<_SwipeBackGate> createState() => _SwipeBackGateState();
197
- }
198
-
199
- class _SwipeBackGateState extends State<_SwipeBackGate> {
200
- double _dragDistance = 0;
201
-
202
- @override
203
- Widget build(BuildContext context) {
204
- return GestureDetector(
205
- behavior: HitTestBehavior.translucent,
206
- onHorizontalDragStart: (_) => _dragDistance = 0,
207
- onHorizontalDragUpdate: (details) {
208
- _dragDistance += details.delta.dx;
209
- },
210
- onHorizontalDragEnd: (details) {
211
- final velocity = details.primaryVelocity ?? 0;
212
- if ((velocity > 300 || _dragDistance > 70) && _dragDistance > 0) {
213
- final nav = Navigator.of(context);
214
- if (nav.canPop()) {
215
- nav.pop();
216
- }
217
- }
218
- _dragDistance = 0;
219
- },
220
- child: widget.child,
221
- );
222
- }
223
- }
 
5
  import 'package:flutter/material.dart';
6
 
7
  import 'brand.dart';
8
+ import 'ios_back_gesture.dart';
9
 
10
  class AppTheme {
11
  static ThemeData light() => _build(Brightness.light);
 
166
  static const splitPane = 1100.0;
167
  }
168
 
169
+ // The iOS-style interactive back gesture + transition lives in
170
+ // ios_back_gesture.dart (IOSPageTransitionsBuilder), imported above.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/lib/ui/features/feed/views/knowledge_feed_screen.dart CHANGED
@@ -8,7 +8,9 @@
8
  /// not a filing cabinet.
9
  library;
10
 
 
11
  import 'package:flutter/material.dart';
 
12
  import 'package:flutter_animate/flutter_animate.dart';
13
  import 'package:phosphor_flutter/phosphor_flutter.dart';
14
  import 'package:provider/provider.dart';
@@ -26,20 +28,25 @@ import '../../reader/views/reader_screen.dart';
26
  import '../view_models/knowledge_feed_view_model.dart';
27
 
28
  class KnowledgeFeedScreen extends StatelessWidget {
29
- const KnowledgeFeedScreen({super.key});
 
 
 
 
30
 
31
  @override
32
  Widget build(BuildContext context) {
33
  return ChangeNotifierProvider(
34
  create: (ctx) =>
35
  KnowledgeFeedViewModel(repository: ctx.read<CardRepository>())..load(),
36
- child: const _FeedView(),
37
  );
38
  }
39
  }
40
 
41
  class _FeedView extends StatefulWidget {
42
- const _FeedView();
 
43
 
44
  @override
45
  State<_FeedView> createState() => _FeedViewState();
@@ -47,31 +54,80 @@ class _FeedView extends StatefulWidget {
47
 
48
  class _FeedViewState extends State<_FeedView> {
49
  final _controller = PageController();
 
50
  int _page = 0;
51
 
52
  @override
53
  void dispose() {
54
  _controller.dispose();
 
55
  super.dispose();
56
  }
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  @override
59
  Widget build(BuildContext context) {
60
  final vm = context.watch<KnowledgeFeedViewModel>();
61
  final scheme = Theme.of(context).colorScheme;
 
 
 
 
62
 
63
  return Scaffold(
64
  backgroundColor: scheme.surface,
65
- body: Stack(
66
- children: [
67
- _content(context, vm),
68
- _TopBar(
69
- index: _page,
70
- total: vm.items.length,
71
- onClose: () => Navigator.of(context).maybePop(),
72
- onRefresh: vm.loading ? null : vm.refresh,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  ),
74
- ],
75
  ),
76
  );
77
  }
@@ -101,14 +157,106 @@ class _FeedViewState extends State<_FeedView> {
101
  );
102
  }
103
 
104
- return PageView.builder(
105
- controller: _controller,
106
- scrollDirection: Axis.vertical,
107
- itemCount: vm.items.length,
108
- onPageChanged: (i) => setState(() => _page = i),
109
- itemBuilder: (ctx, i) => _MomentPage(
110
- item: vm.items[i],
111
- showSwipeHint: i == 0 && vm.items.length > 1,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  ),
113
  );
114
  }
@@ -120,11 +268,13 @@ class _TopBar extends StatelessWidget {
120
  const _TopBar({
121
  required this.index,
122
  required this.total,
 
123
  required this.onClose,
124
  required this.onRefresh,
125
  });
126
  final int index;
127
  final int total;
 
128
  final VoidCallback onClose;
129
  final VoidCallback? onRefresh;
130
 
@@ -136,11 +286,14 @@ class _TopBar extends StatelessWidget {
136
  padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
137
  child: Row(
138
  children: [
139
- IconButton(
140
- onPressed: onClose,
141
- icon: const PhosphorIcon(PhosphorIconsRegular.x),
142
- tooltip: 'Close',
143
- ),
 
 
 
144
  const Spacer(),
145
  if (total > 0)
146
  Text('${index + 1} / $total',
@@ -162,10 +315,13 @@ class _TopBar extends StatelessWidget {
162
  // ── One moment (a single full-screen page) ──────────────────────────────── //
163
 
164
  class _MomentPage extends StatelessWidget {
165
- const _MomentPage({required this.item, this.showSwipeHint = false});
166
  final FeedItem item;
167
  final bool showSwipeHint;
168
 
 
 
 
169
  @override
170
  Widget build(BuildContext context) {
171
  final accent = ContentAccent.of(item.card.contentType);
@@ -185,20 +341,36 @@ class _MomentPage extends StatelessWidget {
185
  ),
186
  ),
187
  child: SafeArea(
188
- child: Padding(
189
- padding: const EdgeInsets.fromLTRB(28, 64, 28, 28),
190
- child: Column(
191
- crossAxisAlignment: CrossAxisAlignment.start,
192
- children: [
193
- _Eyebrow(item: item, accent: accent),
194
- Expanded(child: _MomentBody(item: item, accent: accent)),
195
- _SourceFooter(item: item, accent: accent),
196
- if (showSwipeHint) ...[
197
- const SizedBox(height: 10),
198
- const Center(child: _SwipeHint()),
199
- ],
200
- ],
201
- ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  ),
203
  ),
204
  );
 
8
  /// not a filing cabinet.
9
  library;
10
 
11
+ import 'package:flutter/gestures.dart';
12
  import 'package:flutter/material.dart';
13
+ import 'package:flutter/services.dart';
14
  import 'package:flutter_animate/flutter_animate.dart';
15
  import 'package:phosphor_flutter/phosphor_flutter.dart';
16
  import 'package:provider/provider.dart';
 
28
  import '../view_models/knowledge_feed_view_model.dart';
29
 
30
  class KnowledgeFeedScreen extends StatelessWidget {
31
+ const KnowledgeFeedScreen({super.key, this.inShell = false});
32
+
33
+ /// True when hosted as a bottom-nav tab (vs pushed as its own route): hides the
34
+ /// close button and pads content clear of the floating nav bar.
35
+ final bool inShell;
36
 
37
  @override
38
  Widget build(BuildContext context) {
39
  return ChangeNotifierProvider(
40
  create: (ctx) =>
41
  KnowledgeFeedViewModel(repository: ctx.read<CardRepository>())..load(),
42
+ child: _FeedView(inShell: inShell),
43
  );
44
  }
45
  }
46
 
47
  class _FeedView extends StatefulWidget {
48
+ const _FeedView({this.inShell = false});
49
+ final bool inShell;
50
 
51
  @override
52
  State<_FeedView> createState() => _FeedViewState();
 
54
 
55
  class _FeedViewState extends State<_FeedView> {
56
  final _controller = PageController();
57
+ final _focusNode = FocusNode(debugLabel: 'KnowledgeFeed');
58
  int _page = 0;
59
 
60
  @override
61
  void dispose() {
62
  _controller.dispose();
63
+ _focusNode.dispose();
64
  super.dispose();
65
  }
66
 
67
+ void _goTo(int page, int total) {
68
+ final target = page.clamp(0, total - 1);
69
+ if (target == _page || !_controller.hasClients) return;
70
+ _controller.animateToPage(
71
+ target,
72
+ duration: const Duration(milliseconds: 320),
73
+ curve: Curves.easeOutCubic,
74
+ );
75
+ }
76
+
77
+ void _next(int total) => _goTo(_page + 1, total);
78
+ void _prev(int total) => _goTo(_page - 1, total);
79
+
80
  @override
81
  Widget build(BuildContext context) {
82
  final vm = context.watch<KnowledgeFeedViewModel>();
83
  final scheme = Theme.of(context).colorScheme;
84
+ final total = vm.items.length;
85
+ // Show explicit prev/next controls on pointer-first (wide / web) layouts.
86
+ final showControls = total > 1 &&
87
+ MediaQuery.sizeOf(context).width >= Insets.desktop;
88
 
89
  return Scaffold(
90
  backgroundColor: scheme.surface,
91
+ // Keyboard navigation (arrows / page keys) for web + desktop. Focus is
92
+ // grabbed on hover so keys work without an explicit click, and autofocus
93
+ // only when pushed as its own route (not as a background shell tab).
94
+ body: CallbackShortcuts(
95
+ bindings: {
96
+ const SingleActivator(LogicalKeyboardKey.arrowDown): () => _next(total),
97
+ const SingleActivator(LogicalKeyboardKey.arrowUp): () => _prev(total),
98
+ const SingleActivator(LogicalKeyboardKey.pageDown): () => _next(total),
99
+ const SingleActivator(LogicalKeyboardKey.pageUp): () => _prev(total),
100
+ const SingleActivator(LogicalKeyboardKey.arrowRight): () => _next(total),
101
+ const SingleActivator(LogicalKeyboardKey.arrowLeft): () => _prev(total),
102
+ },
103
+ child: Focus(
104
+ focusNode: _focusNode,
105
+ autofocus: !widget.inShell,
106
+ child: MouseRegion(
107
+ onEnter: (_) {
108
+ if (!_focusNode.hasFocus) _focusNode.requestFocus();
109
+ },
110
+ child: Stack(
111
+ children: [
112
+ _content(context, vm),
113
+ _TopBar(
114
+ index: _page,
115
+ total: total,
116
+ showClose: !widget.inShell,
117
+ onClose: () => Navigator.of(context).maybePop(),
118
+ onRefresh: vm.loading ? null : vm.refresh,
119
+ ),
120
+ if (showControls)
121
+ _NavButtons(
122
+ canPrev: _page > 0,
123
+ canNext: _page < total - 1,
124
+ onPrev: () => _prev(total),
125
+ onNext: () => _next(total),
126
+ ),
127
+ ],
128
+ ),
129
  ),
130
+ ),
131
  ),
132
  );
133
  }
 
157
  );
158
  }
159
 
160
+ // Enable click-drag + trackpad paging in the browser (Flutter disables mouse
161
+ // drag on scrollables by default), on top of the native touch swipe + wheel.
162
+ return ScrollConfiguration(
163
+ behavior: const _FeedScrollBehavior(),
164
+ child: PageView.builder(
165
+ controller: _controller,
166
+ scrollDirection: Axis.vertical,
167
+ itemCount: vm.items.length,
168
+ onPageChanged: (i) => setState(() => _page = i),
169
+ itemBuilder: (ctx, i) => _MomentPage(
170
+ item: vm.items[i],
171
+ showSwipeHint: i == 0 && vm.items.length > 1,
172
+ bottomInset: widget.inShell ? 84 : 0,
173
+ ),
174
+ ),
175
+ );
176
+ }
177
+ }
178
+
179
+ /// Lets the vertical feed be dragged with a mouse / trackpad in the browser β€”
180
+ /// Flutter excludes those pointer kinds from drag-scrolling by default. Touch
181
+ /// swipe and scroll-wheel/trackpad paging keep working alongside this.
182
+ class _FeedScrollBehavior extends MaterialScrollBehavior {
183
+ const _FeedScrollBehavior();
184
+
185
+ @override
186
+ Set<PointerDeviceKind> get dragDevices => PointerDeviceKind.values.toSet();
187
+ }
188
+
189
+ /// On-screen previous/next controls, shown on pointer-first (web / desktop)
190
+ /// layouts so mouse users have an obvious affordance beyond swiping.
191
+ class _NavButtons extends StatelessWidget {
192
+ const _NavButtons({
193
+ required this.canPrev,
194
+ required this.canNext,
195
+ required this.onPrev,
196
+ required this.onNext,
197
+ });
198
+ final bool canPrev;
199
+ final bool canNext;
200
+ final VoidCallback onPrev;
201
+ final VoidCallback onNext;
202
+
203
+ @override
204
+ Widget build(BuildContext context) {
205
+ return Positioned(
206
+ right: 16,
207
+ top: 0,
208
+ bottom: 0,
209
+ child: Center(
210
+ child: Column(
211
+ mainAxisSize: MainAxisSize.min,
212
+ children: [
213
+ _NavButton(
214
+ icon: PhosphorIconsRegular.caretUp,
215
+ tooltip: 'Previous',
216
+ onTap: canPrev ? onPrev : null,
217
+ ),
218
+ const SizedBox(height: 12),
219
+ _NavButton(
220
+ icon: PhosphorIconsRegular.caretDown,
221
+ tooltip: 'Next',
222
+ onTap: canNext ? onNext : null,
223
+ ),
224
+ ],
225
+ ),
226
+ ),
227
+ );
228
+ }
229
+ }
230
+
231
+ class _NavButton extends StatelessWidget {
232
+ const _NavButton({required this.icon, required this.onTap, required this.tooltip});
233
+ final IconData icon;
234
+ final VoidCallback? onTap;
235
+ final String tooltip;
236
+
237
+ @override
238
+ Widget build(BuildContext context) {
239
+ final scheme = Theme.of(context).colorScheme;
240
+ final enabled = onTap != null;
241
+ return Tooltip(
242
+ message: tooltip,
243
+ child: Material(
244
+ color: scheme.surfaceContainerHighest.withValues(alpha: enabled ? 0.9 : 0.35),
245
+ shape: const CircleBorder(),
246
+ clipBehavior: Clip.antiAlias,
247
+ child: InkWell(
248
+ onTap: onTap,
249
+ child: Padding(
250
+ padding: const EdgeInsets.all(10),
251
+ child: PhosphorIcon(
252
+ icon,
253
+ size: 20,
254
+ color: enabled
255
+ ? scheme.onSurface
256
+ : scheme.onSurfaceVariant.withValues(alpha: 0.4),
257
+ ),
258
+ ),
259
+ ),
260
  ),
261
  );
262
  }
 
268
  const _TopBar({
269
  required this.index,
270
  required this.total,
271
+ required this.showClose,
272
  required this.onClose,
273
  required this.onRefresh,
274
  });
275
  final int index;
276
  final int total;
277
+ final bool showClose;
278
  final VoidCallback onClose;
279
  final VoidCallback? onRefresh;
280
 
 
286
  padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
287
  child: Row(
288
  children: [
289
+ if (showClose)
290
+ IconButton(
291
+ onPressed: onClose,
292
+ icon: const PhosphorIcon(PhosphorIconsRegular.x),
293
+ tooltip: 'Close',
294
+ )
295
+ else
296
+ const SizedBox(width: 48),
297
  const Spacer(),
298
  if (total > 0)
299
  Text('${index + 1} / $total',
 
315
  // ── One moment (a single full-screen page) ──────────────────────────────── //
316
 
317
  class _MomentPage extends StatelessWidget {
318
+ const _MomentPage({required this.item, this.showSwipeHint = false, this.bottomInset = 0});
319
  final FeedItem item;
320
  final bool showSwipeHint;
321
 
322
+ /// Extra bottom padding to clear the floating nav bar when hosted as a tab.
323
+ final double bottomInset;
324
+
325
  @override
326
  Widget build(BuildContext context) {
327
  final accent = ContentAccent.of(item.card.contentType);
 
341
  ),
342
  ),
343
  child: SafeArea(
344
+ child: LayoutBuilder(
345
+ builder: (context, constraints) {
346
+ // Constrain the content to a centered reading column so it doesn't
347
+ // stretch edge-to-edge on a wide browser window (the gradient stays
348
+ // full-bleed behind it). Full width on narrow / mobile layouts.
349
+ final width = constraints.maxWidth > Insets.readingColumn
350
+ ? Insets.readingColumn
351
+ : constraints.maxWidth;
352
+ return Center(
353
+ child: SizedBox(
354
+ width: width,
355
+ height: constraints.maxHeight,
356
+ child: Padding(
357
+ padding: EdgeInsets.fromLTRB(28, 64, 28, 28 + bottomInset),
358
+ child: Column(
359
+ crossAxisAlignment: CrossAxisAlignment.start,
360
+ children: [
361
+ _Eyebrow(item: item, accent: accent),
362
+ Expanded(child: _MomentBody(item: item, accent: accent)),
363
+ _SourceFooter(item: item, accent: accent),
364
+ if (showSwipeHint) ...[
365
+ const SizedBox(height: 10),
366
+ const Center(child: _SwipeHint()),
367
+ ],
368
+ ],
369
+ ),
370
+ ),
371
+ ),
372
+ );
373
+ },
374
  ),
375
  ),
376
  );
app/lib/ui/features/library/views/library_screen.dart CHANGED
@@ -31,7 +31,7 @@ import '../../../core/widgets/spot_art.dart';
31
  import '../../capture/views/capture_sheet.dart';
32
  import '../../catalog/views/catalog_screen.dart';
33
  import '../../concepts/views/concepts_screen.dart';
34
- import '../../feed/views/knowledge_feed_screen.dart';
35
  import '../../library/views/library_chat_screen.dart';
36
  import '../../reader/views/reader_screen.dart';
37
  import '../../search/views/search_screen.dart';
@@ -76,10 +76,10 @@ class _LibraryView extends StatelessWidget {
76
  ),
77
  ),
78
  IconButton(
79
- tooltip: 'Feed',
80
- icon: const PhosphorIcon(PhosphorIconsRegular.cardsThree),
81
  onPressed: () => Navigator.of(context).push(
82
- MaterialPageRoute(builder: (_) => const KnowledgeFeedScreen()),
83
  ),
84
  ),
85
  IconButton(
 
31
  import '../../capture/views/capture_sheet.dart';
32
  import '../../catalog/views/catalog_screen.dart';
33
  import '../../concepts/views/concepts_screen.dart';
34
+ import '../../graph/views/graph_screen.dart';
35
  import '../../library/views/library_chat_screen.dart';
36
  import '../../reader/views/reader_screen.dart';
37
  import '../../search/views/search_screen.dart';
 
76
  ),
77
  ),
78
  IconButton(
79
+ tooltip: 'Graph',
80
+ icon: const PhosphorIcon(PhosphorIconsRegular.graph),
81
  onPressed: () => Navigator.of(context).push(
82
+ MaterialPageRoute(builder: (_) => const GraphScreen()),
83
  ),
84
  ),
85
  IconButton(