kasy-cli 1.31.8 → 1.31.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/lib/commands/new.js +7 -10
  2. package/lib/scaffold/CHANGELOG.json +9 -0
  3. package/lib/scaffold/backends/supabase/config.toml +39 -2
  4. package/lib/scaffold/backends/supabase/deploy.js +14 -2
  5. package/lib/scaffold/catalog.js +24 -0
  6. package/lib/scaffold/shared/generator-utils.js +53 -1
  7. package/package.json +2 -2
  8. package/templates/firebase/assets/images/premium-bg.jpg +0 -0
  9. package/templates/firebase/assets/images/premium-switch-header.png +0 -0
  10. package/templates/firebase/lib/components/kasy_app_bar.dart +107 -1
  11. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +26 -7
  12. package/templates/firebase/lib/core/bottom_menu/kasy_bottom_bar_factory.dart +63 -40
  13. package/templates/firebase/lib/core/chrome/chrome_visibility.dart +119 -0
  14. package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +12 -0
  15. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +25 -3
  16. package/templates/firebase/lib/core/web_viewport_scale.dart +15 -4
  17. package/templates/firebase/lib/features/home/home_feed.dart +59 -5
  18. package/templates/firebase/lib/features/home/home_image_grid.dart +81 -52
  19. package/templates/firebase/lib/features/home/home_page.dart +6 -8
  20. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +1 -0
  21. package/templates/firebase/lib/features/settings/settings_page.dart +26 -0
  22. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_minimal.dart +169 -90
  23. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_row.dart +219 -202
  24. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_with_switch.dart +67 -30
  25. package/templates/firebase/lib/features/subscriptions/ui/component/premium_content.dart +16 -6
  26. package/templates/firebase/lib/i18n/en.i18n.json +1 -0
  27. package/templates/firebase/lib/i18n/es.i18n.json +1 -0
  28. package/templates/firebase/lib/i18n/pt.i18n.json +1 -0
  29. package/templates/firebase/pubspec.yaml +1 -1
@@ -0,0 +1,119 @@
1
+ import 'package:flutter/widgets.dart';
2
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+ import 'package:kasy_kit/core/shared_preferences/shared_preferences.dart';
4
+
5
+ /// Single source of truth for how much of the app's "chrome" — the top
6
+ /// [KasyAppBar] and the bottom navigation bar — is revealed while the user
7
+ /// scrolls. `1` = fully shown, `0` = fully hidden; both bars interpolate on
8
+ /// this value so they slide away and come back together.
9
+ ///
10
+ /// Behaviour (mobile only — tablet/desktop use the sidebar, which never hides):
11
+ /// - Scrolling DOWN collapses the chrome glued to the gesture: a fast flick
12
+ /// hides it fast, a slow drag hides it gradually.
13
+ /// - Scrolling UP slowly keeps it hidden; only a fast fling up brings it back.
14
+ /// - Reaching the top always brings it back.
15
+ ///
16
+ /// Exposed as a global singleton (same pattern as `activeTabRouteNotifier` /
17
+ /// `kasyContentFocusTarget`) because both the app bar component and the bottom
18
+ /// bar factory need the same value without threading it through every page.
19
+ class KasyChromeVisibility {
20
+ KasyChromeVisibility._();
21
+
22
+ static final KasyChromeVisibility instance = KasyChromeVisibility._();
23
+
24
+ /// `1` = chrome fully visible, `0` = fully hidden.
25
+ final ValueNotifier<double> reveal = ValueNotifier<double>(1);
26
+
27
+ bool _enabled = true;
28
+
29
+ /// Whether the "hide on scroll" feature is on. Off → the chrome stays pinned.
30
+ bool get enabled => _enabled;
31
+ set enabled(bool value) {
32
+ _enabled = value;
33
+ if (!value) reveal.value = 1; // pin the chrome back when turned off
34
+ }
35
+
36
+ /// Distance (logical px) over which the chrome fully collapses while scrolling
37
+ /// down. Small, so it feels glued to the gesture.
38
+ static const double _collapseDistance = 90;
39
+
40
+ /// An upward scroll delta at least this large in a single update counts as a
41
+ /// fast fling up and snaps the chrome back into view, even mid-page.
42
+ static const double _flingUpThreshold = 14;
43
+
44
+ /// Within this many px of the top, the chrome is always fully shown.
45
+ static const double _topThreshold = 6;
46
+
47
+ /// Bring the chrome fully back. Call on tab changes so a new screen never
48
+ /// starts with hidden chrome.
49
+ void resetShown() => reveal.value = 1;
50
+
51
+ /// Feed every vertical scroll update here (from a [NotificationListener] in
52
+ /// the shell). Updates [reveal] in place.
53
+ void handleScrollUpdate(ScrollUpdateNotification notification) {
54
+ if (!_enabled) return;
55
+ if (notification.metrics.axis != Axis.vertical) return;
56
+ final double delta = notification.scrollDelta ?? 0;
57
+ final double pixels = notification.metrics.pixels;
58
+
59
+ if (pixels <= _topThreshold) {
60
+ reveal.value = 1; // at the top → always shown
61
+ return;
62
+ }
63
+ if (delta > 0) {
64
+ // Scrolling down: collapse proportionally — fast scroll (big delta) hides
65
+ // fast, slow scroll hides gradually. Glued to the gesture.
66
+ reveal.value = (reveal.value - delta / _collapseDistance).clamp(0.0, 1.0);
67
+ } else if (delta < 0 && -delta >= _flingUpThreshold) {
68
+ // Scrolling up: a slow drag keeps it hidden; only a fast fling reveals.
69
+ reveal.value = 1;
70
+ }
71
+ }
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Configuration — edit these to change the experience your app ships with.
76
+ // ---------------------------------------------------------------------------
77
+
78
+ /// Whether the Settings screen shows the "hide bars when scrolling" toggle to
79
+ /// the END USER.
80
+ ///
81
+ /// - `true` → the toggle appears in Settings → Preferences and the user
82
+ /// decides; its initial value is [kHideChromeOnScrollDefault].
83
+ /// - `false` → the toggle is hidden and the behaviour is LOCKED to
84
+ /// [kHideChromeOnScrollDefault] (the user cannot change it).
85
+ const bool kShowHideChromeOnScrollSetting = true;
86
+
87
+ /// The default behaviour: `true` = the app bar and bottom menu hide while
88
+ /// scrolling down (and come back on scroll up / at the top); `false` = they
89
+ /// stay fixed. Used as the toggle's initial value when shown, and as the locked
90
+ /// value when [kShowHideChromeOnScrollSetting] is `false`.
91
+ const bool kHideChromeOnScrollDefault = true;
92
+
93
+ /// Persisted on/off for the hide-on-scroll behaviour. Wires the effective value
94
+ /// into [KasyChromeVisibility] so the bars react immediately.
95
+ final hideChromeOnScrollProvider =
96
+ NotifierProvider<HideChromeOnScrollNotifier, bool>(
97
+ HideChromeOnScrollNotifier.new,
98
+ );
99
+
100
+ class HideChromeOnScrollNotifier extends Notifier<bool> {
101
+ @override
102
+ bool build() {
103
+ // When the user toggle is hidden, the behaviour is locked to the default
104
+ // and any previously saved preference is ignored.
105
+ final bool enabled = kShowHideChromeOnScrollSetting
106
+ ? (ref.read(sharedPreferencesProvider).getHideChromeOnScroll() ??
107
+ kHideChromeOnScrollDefault)
108
+ : kHideChromeOnScrollDefault;
109
+ KasyChromeVisibility.instance.enabled = enabled;
110
+ return enabled;
111
+ }
112
+
113
+ Future<void> toggle() async {
114
+ final bool next = !state;
115
+ state = next;
116
+ KasyChromeVisibility.instance.enabled = next;
117
+ await ref.read(sharedPreferencesProvider).setHideChromeOnScroll(next);
118
+ }
119
+ }
@@ -52,6 +52,18 @@ class SharedPreferencesBuilder implements OnStartService {
52
52
  return prefs.getBool('haptic_feedback_enabled') ?? true;
53
53
  }
54
54
 
55
+ /// Whether the top app bar and bottom menu hide while scrolling down and come
56
+ /// back on scroll up / at the top. Defaults to on.
57
+ Future<void> setHideChromeOnScroll(bool enabled) async {
58
+ await prefs.setBool('hide_chrome_on_scroll', enabled);
59
+ }
60
+
61
+ /// Null when the user has never set it — the caller applies the configured
62
+ /// default ([kHideChromeOnScrollDefault]).
63
+ bool? getHideChromeOnScroll() {
64
+ return prefs.getBool('hide_chrome_on_scroll');
65
+ }
66
+
55
67
  Future<void> setBiometricEnabled(bool enabled) async {
56
68
  await prefs.setBool('biometric_enabled', enabled);
57
69
  }
@@ -533,7 +533,9 @@ class _DeviceSwitchBridgeState extends State<_DeviceSwitchBridge> {
533
533
  void _onFrameVisibleChanged() {
534
534
  if (!mounted) return;
535
535
  final store = Provider.of<DevicePreviewStore>(context, listen: false);
536
- if (store.data.isFrameVisible != widget.frameVisibleNotifier.value) {
536
+ final data = _readData(store);
537
+ if (data == null) return;
538
+ if (data.isFrameVisible != widget.frameVisibleNotifier.value) {
537
539
  store.toggleFrame();
538
540
  }
539
541
  }
@@ -543,11 +545,31 @@ class _DeviceSwitchBridgeState extends State<_DeviceSwitchBridge> {
543
545
  void _syncOrientation() {
544
546
  if (!mounted) return;
545
547
  final store = Provider.of<DevicePreviewStore>(context, listen: false);
548
+ final data = _readData(store);
549
+ if (data == null) {
550
+ // DevicePreview initializes asynchronously (it loads saved preferences), so
551
+ // on the first web frame the store can still be uninitialized and reading
552
+ // store.data throws "Not initialized". Retry next frame instead of surfacing
553
+ // a scary (and harmless) exception in the console.
554
+ WidgetsBinding.instance.addPostFrameCallback((_) => _syncOrientation());
555
+ return;
556
+ }
546
557
  final target = widget.landscapeNotifier.value
547
558
  ? Orientation.landscape
548
559
  : Orientation.portrait;
549
- if (store.data.orientation != target) {
550
- store.data = store.data.copyWith(orientation: target);
560
+ if (data.orientation != target) {
561
+ store.data = data.copyWith(orientation: target);
562
+ }
563
+ }
564
+
565
+ /// [DevicePreviewStore.data] throws while the store is still finishing its async
566
+ /// initialization. Returns null instead of throwing so callers can skip or retry
567
+ /// cleanly (see _syncOrientation).
568
+ DevicePreviewData? _readData(DevicePreviewStore store) {
569
+ try {
570
+ return store.data;
571
+ } catch (_) {
572
+ return null;
551
573
  }
552
574
  }
553
575
 
@@ -4,10 +4,18 @@ import 'package:flutter/widgets.dart';
4
4
  /// Global render scale applied to the app on web.
5
5
  ///
6
6
  /// Flutter web tends to render ~10% larger than equivalent HTML apps at the
7
- /// browser's 100% zoom, so the whole UI feels oversized on desktop. `0.9` brings
8
- /// it to the proportion the design targets (i.e. what 90% zoom looked like)
9
- /// without the user having to touch the browser zoom.
10
- const double kWebViewportScale = 0.9;
7
+ /// browser's 100% zoom, so the whole UI feels oversized on desktop. `0.95`
8
+ /// brings it to the proportion the design targets (i.e. what 95% zoom looked
9
+ /// like) without the user having to touch the browser zoom.
10
+ const double kWebViewportScale = 0.95;
11
+
12
+ /// Minimum real viewport width (logical px) at which the web scale kicks in.
13
+ ///
14
+ /// The "oversized" problem only shows up on tablet/desktop layouts. On mobile
15
+ /// web (a narrow browser) the app should render at its natural size — exactly
16
+ /// like the native iOS/Android build, which never scales. Tied to the tablet
17
+ /// breakpoint so the rule stays in sync with the rest of the responsive system.
18
+ const double kWebViewportScaleMinWidth = 768; // DeviceType.medium.breakpoint
11
19
 
12
20
  /// Renders [child] uniformly scaled by [scale] on web (no-op elsewhere).
13
21
  ///
@@ -33,6 +41,9 @@ class WebViewportScale extends StatelessWidget {
33
41
  Widget build(BuildContext context) {
34
42
  if (!kIsWeb || scale == 1.0) return child;
35
43
  final MediaQueryData mq = MediaQuery.of(context);
44
+ // Mobile web (narrow browser) renders at its natural size, just like the
45
+ // native build. The scale only applies from the tablet breakpoint up.
46
+ if (mq.size.width < kWebViewportScaleMinWidth) return child;
36
47
  final Size logicalSize = Size(
37
48
  mq.size.width / scale,
38
49
  mq.size.height / scale,
@@ -1,4 +1,5 @@
1
1
  import 'package:flutter/material.dart';
2
+ import 'package:kasy_kit/components/kasy_app_bar.dart';
2
3
  import 'package:kasy_kit/components/kasy_card.dart';
3
4
  import 'package:kasy_kit/core/haptics/kasy_haptics.dart';
4
5
  import 'package:kasy_kit/core/theme/theme.dart';
@@ -43,6 +44,14 @@ const Map<HomeCategory, _CategoryData> _categories = <HomeCategory, _CategoryDat
43
44
  '1525966222134-fcfa99b8ae77',
44
45
  '1484704849700-f032a568e944',
45
46
  '1542219550-37153d387c27',
47
+ '1634986666676-ec8fd927c23d',
48
+ '1633899306328-c5e70574aaa2',
49
+ '1634017839464-5c339ebe3cb4',
50
+ '1618005182384-a83a8bd57fbe',
51
+ '1635776062127-d379bfcba9f8',
52
+ '1618556450994-a6a128ef0d9d',
53
+ '1644143379190-08a5f055de1d',
54
+ '1617791160505-6f00504e3519',
46
55
  ],
47
56
  ),
48
57
  HomeCategory.background: _CategoryData(
@@ -62,6 +71,14 @@ const Map<HomeCategory, _CategoryData> _categories = <HomeCategory, _CategoryDat
62
71
  '1454496522488-7a8e488e8606',
63
72
  '1439066615861-d1af74d74000',
64
73
  '1470071459604-3b5ec3a7fe05',
74
+ '1502691876148-a84978e59af8',
75
+ '1574169208507-84376144848b',
76
+ '1518837695005-2083093ee35b',
77
+ '1534796636912-3b95b3ab5986',
78
+ '1508614999368-9260051292e5',
79
+ '1554189097-ffe88e998a2b',
80
+ '1557683316-973673baf926',
81
+ '1557682224-5b8590cd9ec5',
65
82
  ],
66
83
  ),
67
84
  HomeCategory.animated: _CategoryData(
@@ -81,6 +98,14 @@ const Map<HomeCategory, _CategoryData> _categories = <HomeCategory, _CategoryDat
81
98
  '1579546929518-9e396f3cc809',
82
99
  '1614851099175-e5b30eb6f696',
83
100
  '1557672172-298e090bd0f1',
101
+ '1469474968028-56623f02e42e',
102
+ '1472214103451-9374bd1c798e',
103
+ '1447752875215-b2761acb3c5d',
104
+ '1426604966848-d7adac402bff',
105
+ '1554189097-ffe88e998a2b',
106
+ '1508614999368-9260051292e5',
107
+ '1557683316-973673baf926',
108
+ '1557682224-5b8590cd9ec5',
84
109
  ],
85
110
  ),
86
111
  HomeCategory.icons3d: _CategoryData(
@@ -100,6 +125,14 @@ const Map<HomeCategory, _CategoryData> _categories = <HomeCategory, _CategoryDat
100
125
  '1620207418302-439b387441b0',
101
126
  '1604079628040-94301bb21b91',
102
127
  '1557682250-33bd709cbe85',
128
+ '1556228720-195a672e8a03',
129
+ '1542291026-7eec264c27ff',
130
+ '1522338242992-e1a54906a8da',
131
+ '1572635196237-14b3f281503f',
132
+ '1526170375885-4d8ecf77b99f',
133
+ '1560769629-975ec94e6a86',
134
+ '1525966222134-fcfa99b8ae77',
135
+ '1542219550-37153d387c27',
103
136
  ],
104
137
  ),
105
138
  HomeCategory.gradients: _CategoryData(
@@ -119,6 +152,14 @@ const Map<HomeCategory, _CategoryData> _categories = <HomeCategory, _CategoryDat
119
152
  '1508614999368-9260051292e5',
120
153
  '1554189097-ffe88e998a2b',
121
154
  '1620641788421-7a1c342ea42e',
155
+ '1518837695005-2083093ee35b',
156
+ '1534796636912-3b95b3ab5986',
157
+ '1462331940025-496dfbfc7564',
158
+ '1559827260-dc66d52bef19',
159
+ '1541701494587-cb58502866ab',
160
+ '1574169208507-84376144848b',
161
+ '1502691876148-a84978e59af8',
162
+ '1469474968028-56623f02e42e',
122
163
  ],
123
164
  ),
124
165
  };
@@ -173,7 +214,12 @@ class _HomeFeedState extends State<HomeFeed> {
173
214
  child: Column(
174
215
  crossAxisAlignment: CrossAxisAlignment.stretch,
175
216
  children: <Widget>[
176
- const SizedBox(height: KasySpacing.belowChromeContentGap),
217
+ // App bar inset lives INSIDE the scroll so it scrolls away and the
218
+ // feed slides under the frosted bar (overlay pattern).
219
+ SizedBox(
220
+ height: kasyAppBarBodyTopOverlap(context) +
221
+ KasySpacing.belowChromeContentGap,
222
+ ),
177
223
  _FilterRow(selected: _selected, onSelect: _select),
178
224
  const SizedBox(height: KasySpacing.lg),
179
225
  Padding(
@@ -207,10 +253,18 @@ class _FilterRow extends StatelessWidget {
207
253
  Widget build(BuildContext context) {
208
254
  const List<HomeCategory> all = HomeCategory.values;
209
255
 
210
- // Smaller, denser cards on phones; full size on tablet/desktop.
211
- final bool isMobile = MediaQuery.sizeOf(context).width < 768;
212
- final double thumbSize = isMobile ? 56 : 79;
213
- final double cardWidth = isMobile ? 232 : 302;
256
+ // Cards scale fluidly with the screen width instead of snapping at a single
257
+ // breakpoint: smallest/densest on phones, growing smoothly to full size by
258
+ // the time we reach a desktop-width viewport.
259
+ const double minWidth = 360; // small phone
260
+ const double maxWidth = 1024; // desktop — cards at full size from here up
261
+ final double screenWidth = MediaQuery.sizeOf(context).width;
262
+ final double t = ((screenWidth - minWidth) / (maxWidth - minWidth)).clamp(
263
+ 0.0,
264
+ 1.0,
265
+ );
266
+ final double thumbSize = 44 + (79 - 44) * t;
267
+ final double cardWidth = 196 + (302 - 196) * t;
214
268
  final double cardHeight = thumbSize + KasySpacing.md * 2;
215
269
 
216
270
  return SizedBox(
@@ -71,7 +71,8 @@ class _ImageSkeleton extends StatelessWidget {
71
71
 
72
72
  /// Pinterest-style masonry feed. Tile heights vary by a repeating ratio set and
73
73
  /// tiles are balanced across columns by running height. Column count is
74
- /// responsive: 2 on phones, 3 on tablets, 4 on desktop.
74
+ /// responsive: 2 on phones, 3 on tablets, 4 on desktop and up to 8 on very
75
+ /// wide displays.
75
76
  class HomeImageGrid extends StatelessWidget {
76
77
  const HomeImageGrid({super.key, required this.photos});
77
78
 
@@ -91,10 +92,22 @@ class HomeImageGrid extends StatelessWidget {
91
92
  1.4,
92
93
  ];
93
94
 
95
+ /// Target tile width. Columns are derived from the available width so the
96
+ /// grid stays dense on big desktops without ever letting tiles get too
97
+ /// narrow — naturally stepping 5 → 4 → 3 → 2 as the screen shrinks.
98
+ static const double _targetTileWidth = 300;
99
+
100
+ /// Hard floor/ceiling on column count: never fewer than 2 (the mobile feel)
101
+ /// and never more than 8 (lets big desktops fit 6–8 columns while still
102
+ /// capping density so tiles don't get tiny on huge monitors).
103
+ static const int _minColumns = 2;
104
+ static const int _maxColumns = 8;
105
+
94
106
  int _columnsFor(double width) {
95
- if (width >= 1024) return 4;
96
- if (width >= 600) return 3;
97
- return 2;
107
+ final int columns = (width / _targetTileWidth).ceil();
108
+ if (columns < _minColumns) return _minColumns;
109
+ if (columns > _maxColumns) return _maxColumns;
110
+ return columns;
98
111
  }
99
112
 
100
113
  @override
@@ -140,6 +153,10 @@ class HomeImageGrid extends StatelessWidget {
140
153
  }
141
154
  }
142
155
 
156
+ /// Diameter of the circular "like" button — shared so the caption text can
157
+ /// reserve exactly enough room to sit beside it without overlapping.
158
+ const double _likeButtonSize = 36;
159
+
143
160
  class _PhotoTile extends StatefulWidget {
144
161
  final HomePhoto photo;
145
162
  final double aspectRatio;
@@ -178,71 +195,83 @@ class _PhotoTileState extends State<_PhotoTile> {
178
195
  child: Stack(
179
196
  fit: StackFit.expand,
180
197
  children: <Widget>[
181
- // Tapping the photo opens the full-screen viewer; the Hero gives a
182
- // smooth zoom into and out of it.
198
+ // Photo. The Hero gives the smooth zoom into and out of the viewer.
183
199
  Positioned.fill(
184
- child: KasyFocusRing(
185
- onActivate: _openViewer,
186
- borderRadius: BorderRadius.circular(KasyRadius.xl),
187
- child: GestureDetector(
188
- onTap: _openViewer,
189
- behavior: HitTestBehavior.opaque,
190
- child: Hero(
191
- tag: photo.id,
192
- child: KasyNetworkImage(url: photo.url),
193
- ),
194
- ),
200
+ child: Hero(
201
+ tag: photo.id,
202
+ child: KasyNetworkImage(url: photo.url),
195
203
  ),
196
204
  ),
197
205
 
198
206
  // Smooth caption scrim — keeps text legible over any photo, in
199
- // both light and dark themes.
207
+ // both light and dark themes. Decorative only: never eats taps.
200
208
  const Positioned(
201
209
  left: 0,
202
210
  right: 0,
203
211
  bottom: 0,
204
- child: _CaptionScrim(),
212
+ child: IgnorePointer(child: _CaptionScrim()),
205
213
  ),
206
214
 
215
+ // Caption text — decorative only. The right padding reserves room
216
+ // for the like button so the text never runs underneath it.
207
217
  Positioned(
208
218
  left: 0,
209
219
  right: 0,
210
220
  bottom: 0,
211
- child: Padding(
212
- padding: const EdgeInsets.all(KasySpacing.md),
213
- child: Row(
214
- crossAxisAlignment: CrossAxisAlignment.end,
215
- children: <Widget>[
216
- Expanded(
217
- child: Column(
218
- crossAxisAlignment: CrossAxisAlignment.start,
219
- mainAxisSize: MainAxisSize.min,
220
- children: <Widget>[
221
- Text(
222
- photo.author,
223
- maxLines: 1,
224
- overflow: TextOverflow.ellipsis,
225
- style: context.textTheme.titleSmall?.copyWith(
226
- color: context.colors.onSurface,
227
- ),
228
- ),
229
- Text(
230
- photo.ago,
231
- maxLines: 1,
232
- overflow: TextOverflow.ellipsis,
233
- style: context.textTheme.bodySmall?.copyWith(
234
- color: context.colors.muted,
235
- ),
236
- ),
237
- ],
221
+ child: IgnorePointer(
222
+ child: Padding(
223
+ padding: const EdgeInsets.fromLTRB(
224
+ KasySpacing.md,
225
+ KasySpacing.md,
226
+ KasySpacing.md + _likeButtonSize + KasySpacing.sm,
227
+ KasySpacing.md,
228
+ ),
229
+ child: Column(
230
+ crossAxisAlignment: CrossAxisAlignment.start,
231
+ mainAxisSize: MainAxisSize.min,
232
+ children: <Widget>[
233
+ Text(
234
+ photo.author,
235
+ maxLines: 1,
236
+ overflow: TextOverflow.ellipsis,
237
+ style: context.textTheme.titleSmall?.copyWith(
238
+ color: context.colors.onSurface,
239
+ ),
240
+ ),
241
+ Text(
242
+ photo.ago,
243
+ maxLines: 1,
244
+ overflow: TextOverflow.ellipsis,
245
+ style: context.textTheme.bodySmall?.copyWith(
246
+ color: context.colors.muted,
247
+ ),
238
248
  ),
239
- ),
240
- const SizedBox(width: KasySpacing.sm),
241
- _LikeButton(liked: _liked, onTap: _toggleLike),
242
- ],
249
+ ],
250
+ ),
243
251
  ),
244
252
  ),
245
253
  ),
254
+
255
+ // Full-tile tap target sits ABOVE the scrim and caption so tapping
256
+ // anywhere on the tile (gradient included) opens the viewer.
257
+ Positioned.fill(
258
+ child: KasyFocusRing(
259
+ onActivate: _openViewer,
260
+ borderRadius: BorderRadius.circular(KasyRadius.xl),
261
+ child: GestureDetector(
262
+ onTap: _openViewer,
263
+ behavior: HitTestBehavior.opaque,
264
+ ),
265
+ ),
266
+ ),
267
+
268
+ // Like button — kept on top of the tap target so it still wins
269
+ // taps within its own area.
270
+ Positioned(
271
+ right: KasySpacing.md,
272
+ bottom: KasySpacing.md,
273
+ child: _LikeButton(liked: _liked, onTap: _toggleLike),
274
+ ),
246
275
  ],
247
276
  ),
248
277
  ),
@@ -354,8 +383,8 @@ class _LikeButtonState extends State<_LikeButton>
354
383
  child: BackdropFilter(
355
384
  filter: ui.ImageFilter.blur(sigmaX: 8, sigmaY: 8),
356
385
  child: Container(
357
- width: 36,
358
- height: 36,
386
+ width: _likeButtonSize,
387
+ height: _likeButtonSize,
359
388
  alignment: Alignment.center,
360
389
  decoration: BoxDecoration(
361
390
  color: context.colors.surface.withValues(
@@ -31,14 +31,11 @@ class HomePage extends ConsumerWidget {
31
31
  color: context.colors.background,
32
32
  child: Stack(
33
33
  children: [
34
- Positioned.fill(
35
- child: Padding(
36
- padding: EdgeInsets.only(
37
- top: kasyAppBarBodyTopOverlap(context),
38
- ),
39
- child: const HomeFeed(),
40
- ),
41
- ),
34
+ // True overlay: the feed fills the whole height and scrolls UNDER
35
+ // the frosted app bar (the bar's top inset lives inside the feed's
36
+ // scroll, so it scrolls away). This way, when the bar hides on
37
+ // scroll, content already fills the top — no empty app-bar band.
38
+ const Positioned.fill(child: HomeFeed()),
42
39
  Positioned(
43
40
  top: 0,
44
41
  left: 0,
@@ -46,6 +43,7 @@ class HomePage extends ConsumerWidget {
46
43
  child: KasyAppBar(
47
44
  title: t.home.dashboard.brand,
48
45
  style: KasyAppBarStyle.rootTab,
46
+ hideOnScroll: true,
49
47
  onThemeToggle: () {
50
48
  KasyHaptics.light(context);
51
49
  ThemeProvider.of(context).toggle();
@@ -105,6 +105,7 @@ class _NotificationsPageState extends ConsumerState<NotificationsPage> {
105
105
  return KasyOverlayScaffold(
106
106
  title: t.notifications.title,
107
107
  appBarStyle: KasyAppBarStyle.rootTab,
108
+ hideAppBarOnScroll: true,
108
109
  scrollController: _scrollController,
109
110
  trailing: Builder(
110
111
  builder: (ctx) => Row(
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
3
3
  import 'package:flutter_riverpod/flutter_riverpod.dart';
4
4
  import 'package:go_router/go_router.dart';
5
5
  import 'package:kasy_kit/components/components.dart';
6
+ import 'package:kasy_kit/core/chrome/chrome_visibility.dart';
6
7
  import 'package:kasy_kit/core/config/features.dart';
7
8
  import 'package:kasy_kit/core/data/models/user.dart';
8
9
  import 'package:kasy_kit/core/haptics/haptic_feedback_notifier.dart';
@@ -41,6 +42,7 @@ class SettingsPage extends ConsumerWidget {
41
42
  return KasyOverlayScaffold(
42
43
  title: tr.title,
43
44
  appBarStyle: KasyAppBarStyle.rootTab,
45
+ hideAppBarOnScroll: true,
44
46
  trailing: Builder(
45
47
  builder: (ctx) => KasyChromeOrbIconButton(
46
48
  icon: KasyIcons.logout,
@@ -118,6 +120,10 @@ class SettingsPage extends ConsumerWidget {
118
120
  const SettingsDivider(),
119
121
  const HapticFeedbackSwitcher(),
120
122
  const SettingsDivider(),
123
+ if (kShowHideChromeOnScrollSetting) ...[
124
+ const HideChromeOnScrollSwitcher(),
125
+ const SettingsDivider(),
126
+ ],
121
127
  const LanguageSwitcher(),
122
128
  if (withLocalReminders) ...[
123
129
  const SettingsDivider(),
@@ -641,6 +647,10 @@ class _DesktopDetail extends ConsumerWidget {
641
647
  const SettingsDivider(),
642
648
  const HapticFeedbackSwitcher(),
643
649
  const SettingsDivider(),
650
+ if (kShowHideChromeOnScrollSetting) ...[
651
+ const HideChromeOnScrollSwitcher(),
652
+ const SettingsDivider(),
653
+ ],
644
654
  const LanguageSwitcher(),
645
655
  if (withLocalReminders) ...[
646
656
  const SettingsDivider(),
@@ -953,6 +963,22 @@ class HapticFeedbackSwitcher extends ConsumerWidget {
953
963
  }
954
964
  }
955
965
 
966
+ class HideChromeOnScrollSwitcher extends ConsumerWidget {
967
+ const HideChromeOnScrollSwitcher({super.key});
968
+
969
+ @override
970
+ Widget build(BuildContext context, WidgetRef ref) {
971
+ final isEnabled = ref.watch(hideChromeOnScrollProvider);
972
+ return SettingsSwitchTile(
973
+ icon: KasyIcons.eyeOff,
974
+ title: context.t.settings.hide_chrome_on_scroll_title,
975
+ value: isEnabled,
976
+ onChanged: (_) =>
977
+ ref.read(hideChromeOnScrollProvider.notifier).toggle(),
978
+ );
979
+ }
980
+ }
981
+
956
982
  class ThemeSwitcher extends StatelessWidget {
957
983
  const ThemeSwitcher({super.key});
958
984