kasy-cli 1.31.9 → 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 (25) 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 -16
  5. package/lib/scaffold/catalog.js +24 -0
  6. package/package.json +2 -2
  7. package/templates/firebase/assets/images/premium-bg.jpg +0 -0
  8. package/templates/firebase/assets/images/premium-switch-header.png +0 -0
  9. package/templates/firebase/lib/components/kasy_app_bar.dart +107 -1
  10. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +26 -7
  11. package/templates/firebase/lib/core/bottom_menu/kasy_bottom_bar_factory.dart +22 -33
  12. package/templates/firebase/lib/core/chrome/chrome_visibility.dart +119 -0
  13. package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +12 -0
  14. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +25 -3
  15. package/templates/firebase/lib/features/home/home_feed.dart +7 -1
  16. package/templates/firebase/lib/features/home/home_page.dart +6 -8
  17. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +1 -0
  18. package/templates/firebase/lib/features/settings/settings_page.dart +26 -0
  19. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_minimal.dart +169 -90
  20. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_row.dart +219 -202
  21. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_with_switch.dart +67 -30
  22. package/templates/firebase/lib/features/subscriptions/ui/component/premium_content.dart +16 -6
  23. package/templates/firebase/lib/i18n/en.i18n.json +1 -0
  24. package/templates/firebase/lib/i18n/es.i18n.json +1 -0
  25. package/templates/firebase/lib/i18n/pt.i18n.json +1 -0
@@ -34,7 +34,7 @@ const {
34
34
  runChecks,
35
35
  hasRequiredFailures,
36
36
  } = require('../utils/checks');
37
- const { normalizeBackend, getVisibleFeatures, FEATURE_CATALOG } = require('../scaffold/catalog');
37
+ const { normalizeBackend, getVisibleFeatures, computeQuickModules, FEATURE_CATALOG } = require('../scaffold/catalog');
38
38
 
39
39
  // Audience gate: set KASY_INTERNAL=1 to reveal beta/internal features.
40
40
  const KASY_AUDIENCE = process.env.KASY_INTERNAL === '1' ? 'internal' : 'public';
@@ -1436,15 +1436,12 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1436
1436
  // --with flag was passed: use those modules directly, skip preset prompt.
1437
1437
  modules = preselectedModules;
1438
1438
  } else if (isQuick) {
1439
- // Quick mode: ship all features the backend supports. Facebook is excluded
1440
- // because it requires App ID + token that we can't auto-generate — the user
1441
- // adds it later with `kasy add facebook` when they have the credentials.
1442
- // Filter by availableIn so backend-specific features (e.g. feedback needs a
1443
- // DB) don't leak into a backend that can't support them.
1444
- const quickAvailable = new Set(
1445
- getVisibleFeatures({ audience: KASY_AUDIENCE, backend }).map((f) => f.id)
1446
- );
1447
- modules = (MODULE_PRESETS.full || []).filter((m) => m !== 'facebook' && quickAvailable.has(m));
1439
+ // Quick mode: ship everything this backend supports, minus Facebook (needs an
1440
+ // App ID + token we can't auto-generate — added later via `kasy add facebook`).
1441
+ // computeQuickModules is shared with the anti-drift guard test, so the preset
1442
+ // can't silently drop a feature again. Dropping local_reminders is what used to
1443
+ // remove its dir and force the trimmed router that lost /admin + transitions.
1444
+ modules = computeQuickModules({ backend, audience: KASY_AUDIENCE });
1448
1445
  } else {
1449
1446
  section('new.advanced.section.features');
1450
1447
  // Advanced mode: full multiselect — built from catalog, filtered by audience + backend.
@@ -1,4 +1,13 @@
1
1
  {
2
+ "1.31.10": {
3
+ "modules": {
4
+ "core": {
5
+ "pt": "Web preview de dispositivo não lança mais o erro \"Not initialized\" no primeiro frame: ele espera o DevicePreview terminar de inicializar antes de ajustar a orientação.",
6
+ "en": "Device preview on web no longer throws \"Not initialized\" on the first frame: it waits for DevicePreview to finish initializing before syncing orientation.",
7
+ "es": "La vista previa de dispositivo en web ya no lanza \"Not initialized\" en el primer frame: espera a que DevicePreview termine de inicializar antes de ajustar la orientación."
8
+ }
9
+ }
10
+ },
2
11
  "1.22.0": {
3
12
  "modules": {
4
13
  "components": {
@@ -28,7 +28,44 @@ enable_anonymous_sign_ins = true
28
28
  enable_confirmations = false
29
29
  double_confirm_changes = false
30
30
 
31
+ # Edge Functions — every function skips the platform JWT gate (verify_jwt = false)
32
+ # and authenticates ITSELF inside the handler. This is the authoritative setting:
33
+ # `supabase functions deploy` reads it (the legacy --no-verify-jwt flag is deprecated
34
+ # and silently ignored by recent CLIs). Two reasons every function needs it:
35
+ # 1. Browser-invoked functions receive a CORS preflight (OPTIONS, no Authorization
36
+ # header). With the gate ON the platform rejects the preflight before our CORS
37
+ # handler runs, so the web app sees "Failed to fetch".
38
+ # 2. Webhooks (Stripe, RevenueCat) are called server-to-server with a provider
39
+ # signature/key, never a Supabase user JWT.
40
+ # Security is preserved: each handler checks the user token (getUser) or the webhook
41
+ # signature/key before doing any work. Keep this list in sync with edge-functions/ —
42
+ # cli/test/supabase-verify-jwt.test.js fails the publish if a function is missing.
43
+ [functions.admin-list-users]
44
+ verify_jwt = false
45
+
46
+ [functions.ai-chat]
47
+ verify_jwt = false
48
+
49
+ [functions.delete-user-account]
50
+ verify_jwt = false
51
+
52
+ [functions.meta-track-event]
53
+ verify_jwt = false
54
+
31
55
  [functions.revenuecat-webhook]
32
- # Webhook receives requests from RevenueCat, not from authenticated users.
33
- # JWT verification must be disabled — auth is done via REVENUECAT_WEBHOOK_KEY.
56
+ verify_jwt = false
57
+
58
+ [functions.send-push-notification]
59
+ verify_jwt = false
60
+
61
+ [functions.stripe-create-checkout-session]
62
+ verify_jwt = false
63
+
64
+ [functions.stripe-create-portal-session]
65
+ verify_jwt = false
66
+
67
+ [functions.stripe-list-prices]
68
+ verify_jwt = false
69
+
70
+ [functions.stripe-webhook]
34
71
  verify_jwt = false
@@ -570,26 +570,24 @@ async function deployFunctions(projectDir, functionNames = []) {
570
570
  }
571
571
  if (toDeploy.length === 0) return { ok: true, skipped: true };
572
572
 
573
- // Functions deployed WITHOUT the platform JWT gate (--no-verify-jwt). Two cases:
574
- // 1. Webhooks (Stripe, RevenueCat, push) called server-to-server, no user JWT.
575
- // 2. Browser-invoked functions that authenticate THEMSELVES inside the handler:
576
- // delete-user-account (checks the token), admin-list-users (token + admin
577
- // role), stripe-list-prices (public prices), ai-chat (checks the token).
578
- // With the platform gate ON, the browser's CORS preflight (an OPTIONS with
579
- // no Authorization header) gets rejected before our CORS handler runs, so the
580
- // web app sees "Failed to fetch". Letting the function do its own auth makes
581
- // the preflight pass while staying secure (the handler still 401s anon calls).
582
- const NO_VERIFY_JWT = new Set([
583
- 'ai-chat', 'revenuecat-webhook', 'send-push-notification', 'stripe-webhook',
584
- 'delete-user-account', 'admin-list-users', 'stripe-list-prices',
585
- ]);
586
-
573
+ // EVERY function in this kit skips the platform JWT gate. The authoritative
574
+ // setting lives in supabase/config.toml ([functions.<name>] verify_jwt = false),
575
+ // which `supabase functions deploy` reads; we ALSO pass --no-verify-jwt here as a
576
+ // belt-and-suspenders for older CLIs (the flag is deprecated but harmless). Why
577
+ // all of them:
578
+ // 1. Browser-invoked functions get a CORS preflight (OPTIONS, no Authorization
579
+ // header). With the gate ON the platform rejects the preflight before our
580
+ // CORS handler runs, so the web app sees "Failed to fetch".
581
+ // 2. Webhooks (Stripe, RevenueCat) are called server-to-server with a provider
582
+ // signature/key, never a Supabase user JWT.
583
+ // It stays secure because each handler authenticates itself (getUser on the token,
584
+ // or the webhook signature/key) before doing any work. config.toml is the source
585
+ // of truth — cli/test/supabase-verify-jwt.test.js asserts it covers every function.
587
586
  const steps = [];
588
587
  for (const name of toDeploy) {
589
588
  const fnPath = path.join(functionsDir, name);
590
589
  if (!(await fs.pathExists(fnPath))) continue;
591
- const noVerifyJwt = NO_VERIFY_JWT.has(name) ? ' --no-verify-jwt' : '';
592
- const result = await run(`supabase functions deploy ${name}${noVerifyJwt}`, projectDir);
590
+ const result = await run(`supabase functions deploy ${name} --no-verify-jwt`, projectDir);
593
591
  steps.push({ name: `deploy ${name}`, ok: result.ok, error: result.error });
594
592
  }
595
593
  return steps.length > 0 ? steps : { ok: true, skipped: true };
@@ -93,6 +93,29 @@ function getVisibleFeatures({ audience = 'public', backend = null } = {}) {
93
93
  });
94
94
  }
95
95
 
96
+ /**
97
+ * Modules shipped by `kasy new` Quick mode (the recommended path): EVERYTHING the
98
+ * backend supports, minus Facebook (needs an App ID + token we can't auto-generate;
99
+ * added later via `kasy add facebook`).
100
+ *
101
+ * Single source of truth — used by both new.js (the real wizard) and the anti-drift
102
+ * guard test (cli/test/generated-matches-kit.test.js). They MUST agree: if Quick
103
+ * silently drops a feature, the kit's router/main can't be kept verbatim and the
104
+ * generator falls back to the trimmed builder that historically lost /admin and the
105
+ * page transitions. Computing it in one place means the test exercises the exact set
106
+ * the user gets, so that regression can never ship unnoticed again.
107
+ *
108
+ * @param {object} opts
109
+ * @param {string|null} opts.backend - filter by backend (availableIn), or null for all
110
+ * @param {'public'|'internal'} opts.audience
111
+ * @returns {string[]} feature ids
112
+ */
113
+ function computeQuickModules({ backend = null, audience = 'public' } = {}) {
114
+ return getVisibleFeatures({ audience, backend })
115
+ .map((f) => f.id)
116
+ .filter((id) => id !== 'facebook');
117
+ }
118
+
96
119
  // Flat list kept for backward-compatibility with code that uses feature ids as strings.
97
120
  const AVAILABLE_FEATURES = FEATURE_CATALOG.map((f) => f.id);
98
121
 
@@ -255,4 +278,5 @@ module.exports = {
255
278
  parseFeatureList,
256
279
  getVisibleFeatures,
257
280
  getBaseFeatures,
281
+ computeQuickModules,
258
282
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kasy-cli",
3
- "version": "1.31.9",
3
+ "version": "1.31.10",
4
4
  "description": "CLI for scaffolding production-ready Flutter SaaS apps with Firebase, Supabase, or API REST backends.",
5
5
  "bin": {
6
6
  "kasy": "./bin/kasy.js"
@@ -31,7 +31,7 @@
31
31
  "access": "public"
32
32
  },
33
33
  "scripts": {
34
- "prepack": "node scripts/check-feature-patches.js && node scripts/bundle-template.js && node test/generated-matches-kit.test.js",
34
+ "prepack": "node scripts/check-feature-patches.js && node scripts/bundle-template.js && node test/generated-matches-kit.test.js && node test/supabase-verify-jwt.test.js",
35
35
  "start": "node ./bin/kasy.js",
36
36
  "setup": "node ./bin/kasy.js setup",
37
37
  "doctor": "node ./bin/kasy.js doctor",
@@ -28,6 +28,7 @@ import 'dart:ui' show ImageFilter;
28
28
  import 'package:flutter/foundation.dart' show kIsWeb;
29
29
  import 'package:flutter/material.dart';
30
30
  import 'package:flutter/services.dart' show SystemUiOverlayStyle;
31
+ import 'package:kasy_kit/core/chrome/chrome_visibility.dart';
31
32
  import 'package:kasy_kit/core/theme/theme.dart';
32
33
  import 'package:kasy_kit/core/widgets/kasy_focus_ring.dart';
33
34
  import 'package:kasy_kit/core/widgets/responsive_layout.dart';
@@ -182,6 +183,59 @@ class KasyFrostedChromeBackground extends StatelessWidget {
182
183
  }
183
184
  }
184
185
 
186
+ /// A soft top wash — the page [KasyColors.background] fading to transparent —
187
+ /// sized to the app bar's height. Place it behind the [KasyAppBar] in overlay
188
+ /// layouts: when the bar hides on scroll, content melts into the background at
189
+ /// the top instead of hard-cutting under the status bar. Mirrors the wash under
190
+ /// the bottom navigation bar.
191
+ ///
192
+ /// It is invisible while the bar is shown (it sits directly behind it) and a
193
+ /// no-op on desktop (there is no app bar there), so it is always safe to add.
194
+ class KasyTopScrollFade extends StatelessWidget {
195
+ const KasyTopScrollFade({super.key});
196
+
197
+ /// Always-solid strong band at the very top, ON TOP of the status-bar inset.
198
+ /// Fixed (not derived from the inset) so the top reads 100% solid even on web,
199
+ /// where there is no status-bar inset.
200
+ static const double _solidBand = 20;
201
+
202
+ /// Smooth fade below the solid band before the wash is fully transparent.
203
+ static const double _fadeTail = 18;
204
+
205
+ @override
206
+ Widget build(BuildContext context) {
207
+ final double topInset = MediaQuery.paddingOf(context).top;
208
+ final double solidHeight = topInset + _solidBand;
209
+ final double height = solidHeight + _fadeTail;
210
+ final Color bg = context.colors.background;
211
+ // Fully solid (100%) from the very top through the status-bar strip and the
212
+ // fixed band, then an eased fade to transparent — strong at the top, soft at
213
+ // the bottom. The middle stop curves the fade so it is gradual, not linear.
214
+ final double solidStop = (solidHeight / height).clamp(0.0, 1.0);
215
+ final double midStop = solidStop + (1 - solidStop) * 0.5;
216
+ return IgnorePointer(
217
+ child: SizedBox(
218
+ height: height,
219
+ child: DecoratedBox(
220
+ decoration: BoxDecoration(
221
+ gradient: LinearGradient(
222
+ begin: Alignment.topCenter,
223
+ end: Alignment.bottomCenter,
224
+ colors: <Color>[
225
+ bg,
226
+ bg,
227
+ bg.withValues(alpha: 0.4),
228
+ bg.withValues(alpha: 0),
229
+ ],
230
+ stops: <double>[0.0, solidStop, midStop, 1.0],
231
+ ),
232
+ ),
233
+ ),
234
+ ),
235
+ );
236
+ }
237
+ }
238
+
185
239
  /// Layout preset for [KasyAppBar] (pick one; avoids composing many public pieces).
186
240
  enum KasyAppBarStyle {
187
241
  /// Back + centred title + light/dark theme toggle (dashboard sub-routes).
@@ -223,6 +277,13 @@ class KasyAppBar extends StatelessWidget {
223
277
  /// where there is no status-bar inset to provide natural breathing room.
224
278
  final double? topInset;
225
279
 
280
+ /// When true, the bar slides up and fades out as the user scrolls down and
281
+ /// returns on scroll up / at the top, in lock-step with the bottom menu (see
282
+ /// [KasyChromeVisibility]). Opt-in: only safe for pages that lay the bar over
283
+ /// full-height scroll content (the overlay pattern), so an empty gap never
284
+ /// appears where the bar was.
285
+ final bool hideOnScroll;
286
+
226
287
  const KasyAppBar({
227
288
  super.key,
228
289
  required this.title,
@@ -234,6 +295,7 @@ class KasyAppBar extends StatelessWidget {
234
295
  this.onThemeToggle,
235
296
  this.toolbarHeight,
236
297
  this.topInset,
298
+ this.hideOnScroll = false,
237
299
  });
238
300
 
239
301
  @override
@@ -312,11 +374,49 @@ class KasyAppBar extends StatelessWidget {
312
374
  children: [SizedBox(height: inset), bar],
313
375
  )
314
376
  : bar;
315
- return KasyFrostedChromeBackground(
377
+ final Widget chrome = KasyFrostedChromeBackground(
316
378
  blurSigma: frostBlurSigma,
317
379
  padForStatusBar: useSafeArea,
318
380
  child: barContent,
319
381
  );
382
+
383
+ Widget animatedChrome = chrome;
384
+ if (hideOnScroll) {
385
+ // Slide up + fade as the chrome collapses; the same notifier drives the
386
+ // bottom bar, so the two move together.
387
+ final double barHeight = kasyAppBarBodyTopOverlap(context);
388
+ animatedChrome = ValueListenableBuilder<double>(
389
+ valueListenable: KasyChromeVisibility.instance.reveal,
390
+ builder: (BuildContext context, double reveal, Widget? child) {
391
+ final double hidden = 1 - reveal;
392
+ return Transform.translate(
393
+ offset: Offset(0, -barHeight * hidden),
394
+ child: Opacity(opacity: reveal, child: child),
395
+ );
396
+ },
397
+ child: chrome,
398
+ );
399
+ }
400
+
401
+ // A top wash sits BEHIND the bar (and stays put even when the bar hides) so
402
+ // content melts into the background at the top — the same pattern on every
403
+ // screen that has an app bar. Skipped for modal / page-sheet bars (custom
404
+ // height or inset), where nothing scrolls behind the bar.
405
+ final bool standardBar =
406
+ useSafeArea && topInset == null && toolbarHeight == null;
407
+ if (!standardBar) return animatedChrome;
408
+ return Stack(
409
+ clipBehavior: Clip.none,
410
+ children: <Widget>[
411
+ const Positioned(
412
+ top: 0,
413
+ left: 0,
414
+ right: 0,
415
+ child: KasyTopScrollFade(),
416
+ ),
417
+ animatedChrome,
418
+ ],
419
+ );
320
420
  }
321
421
 
322
422
  Widget _buildTrailing(BuildContext context, Color iconFg, Color orbFill) {
@@ -378,6 +478,10 @@ class KasyOverlayScaffold extends StatelessWidget {
378
478
  final Future<void> Function()? onRefresh;
379
479
  final double refreshIndicatorDisplacement;
380
480
 
481
+ /// Forwarded to [KasyAppBar.hideOnScroll]. Safe here because this scaffold is
482
+ /// the overlay pattern (bar floats over full-height scroll content).
483
+ final bool hideAppBarOnScroll;
484
+
381
485
  const KasyOverlayScaffold({
382
486
  super.key,
383
487
  required this.title,
@@ -392,6 +496,7 @@ class KasyOverlayScaffold extends StatelessWidget {
392
496
  this.backgroundColor,
393
497
  this.onRefresh,
394
498
  this.refreshIndicatorDisplacement = 48,
499
+ this.hideAppBarOnScroll = false,
395
500
  });
396
501
 
397
502
  @override
@@ -433,6 +538,7 @@ class KasyOverlayScaffold extends StatelessWidget {
433
538
  onBack: onBack,
434
539
  onThemeToggle: onThemeToggle,
435
540
  trailing: trailing,
541
+ hideOnScroll: hideAppBarOnScroll,
436
542
  ),
437
543
  ),
438
544
  ],
@@ -8,6 +8,7 @@ import 'package:kasy_kit/core/bottom_menu/active_tab_notifier.dart';
8
8
  import 'package:kasy_kit/core/bottom_menu/bottom_router.dart';
9
9
  import 'package:kasy_kit/core/bottom_menu/kasy_bottom_bar_factory.dart';
10
10
  import 'package:kasy_kit/core/bottom_menu/web_content_wrapper.dart';
11
+ import 'package:kasy_kit/core/chrome/chrome_visibility.dart';
11
12
  import 'package:kasy_kit/core/data/models/user.dart';
12
13
  import 'package:kasy_kit/core/states/logout_action.dart';
13
14
  import 'package:kasy_kit/core/states/user_state_notifier.dart';
@@ -20,6 +21,9 @@ import 'package:kasy_kit/i18n/translations.g.dart';
20
21
  /// [bart.BartScaffold.onRouteChanged]. See [activeTabRouteNotifier].
21
22
  void _rememberActiveTab(bart.BartMenuRoute route) {
22
23
  activeTabRouteNotifier.value = route.path;
24
+ // A fresh tab always starts with the chrome shown (it may have been hidden by
25
+ // scrolling on the previous tab).
26
+ KasyChromeVisibility.instance.resetShown();
23
27
  }
24
28
 
25
29
  /// Bottom navigation host powered by Bart (https://pub.dev/packages/bart).
@@ -106,13 +110,28 @@ class BottomMenu extends StatelessWidget {
106
110
  Brightness.light => SystemUiOverlayStyle.dark,
107
111
  },
108
112
  child: ResponsiveLayout(
109
- small: bart.BartScaffold(
110
- routesBuilder: subRoutes,
111
- bottomBar: kasyPaddedSurfaceBottomBar(),
112
- initialRoute: resolvedInitialRoute,
113
- showBottomBarOnStart: showBottomBarOnStart,
114
- scaffoldOptions: scaffoldOptions,
115
- onRouteChanged: _rememberActiveTab,
113
+ small: Consumer(
114
+ builder: (context, ref, _) {
115
+ // Watching the provider here keeps the persisted on/off setting in
116
+ // sync with the controller, and scopes scroll tracking to the
117
+ // mobile shell only (detail screens push on the root navigator, so
118
+ // their scrolls never reach this listener).
119
+ ref.watch(hideChromeOnScrollProvider);
120
+ return NotificationListener<ScrollUpdateNotification>(
121
+ onNotification: (notification) {
122
+ KasyChromeVisibility.instance.handleScrollUpdate(notification);
123
+ return false; // let the notification keep bubbling
124
+ },
125
+ child: bart.BartScaffold(
126
+ routesBuilder: subRoutes,
127
+ bottomBar: kasyPaddedSurfaceBottomBar(),
128
+ initialRoute: resolvedInitialRoute,
129
+ showBottomBarOnStart: showBottomBarOnStart,
130
+ scaffoldOptions: scaffoldOptions,
131
+ onRouteChanged: _rememberActiveTab,
132
+ ),
133
+ );
134
+ },
116
135
  ),
117
136
  medium: connectedScaffold(),
118
137
  large: connectedScaffold(),
@@ -3,6 +3,7 @@ import 'package:bart/bart/bart_model.dart';
3
3
  import 'package:bart/bart/router_delegate.dart';
4
4
  import 'package:bart/bart/widgets/bottom_bar/styles/bottom_bar_custom.dart';
5
5
  import 'package:flutter/material.dart';
6
+ import 'package:kasy_kit/core/chrome/chrome_visibility.dart';
6
7
  import 'package:kasy_kit/core/haptics/kasy_haptics.dart';
7
8
  import 'package:kasy_kit/core/theme/theme.dart';
8
9
 
@@ -159,42 +160,30 @@ final class KasyPaddedSurfaceBottomBarFactory extends BartBottomBarFactory {
159
160
  ),
160
161
  );
161
162
 
162
- // Low bottom-up fade in the page background color: opaque at the very bottom
163
- // (hides content scrolling under the bar), fully transparent again around the
164
- // pill's mid-height so it stays a discreet wash low on the bar rather than a
165
- // tall veil. Follows light/dark via the background token; same RGB at both
166
- // ends so there is no grey halo.
167
- final Widget fade = IgnorePointer(
168
- child: DecoratedBox(
169
- decoration: BoxDecoration(
170
- gradient: LinearGradient(
171
- begin: Alignment.bottomCenter,
172
- end: Alignment.topCenter,
173
- colors: [
174
- colors.background,
175
- colors.background,
176
- colors.background.withValues(alpha: 0),
177
- ],
178
- stops: const [0.0, 0.33, 0.66],
179
- ),
163
+ // Slide the pill down as the chrome collapses, in lock-step with the top
164
+ // app bar (same notifier). Travel covers the whole floating height so the
165
+ // pill fully clears the screen edge when hidden. No bottom wash: the pill
166
+ // just slides off (and never goes translucent).
167
+ final double travel = _kFloatingTopGap + _kBarHeight + bottomGap;
168
+ return ValueListenableBuilder<double>(
169
+ valueListenable: KasyChromeVisibility.instance.reveal,
170
+ builder: (BuildContext context, double reveal, Widget? child) {
171
+ final double hidden = 1 - reveal;
172
+ return Transform.translate(
173
+ offset: Offset(0, travel * hidden),
174
+ child: child,
175
+ );
176
+ },
177
+ child: Padding(
178
+ padding: EdgeInsets.only(
179
+ left: _kFloatingSideMargin,
180
+ right: _kFloatingSideMargin,
181
+ top: _kFloatingTopGap,
182
+ bottom: bottomGap,
180
183
  ),
184
+ child: pill,
181
185
  ),
182
186
  );
183
-
184
- return Stack(
185
- children: [
186
- Positioned.fill(child: fade),
187
- Padding(
188
- padding: EdgeInsets.only(
189
- left: _kFloatingSideMargin,
190
- right: _kFloatingSideMargin,
191
- top: _kFloatingTopGap,
192
- bottom: bottomGap,
193
- ),
194
- child: pill,
195
- ),
196
- ],
197
- );
198
187
  }
199
188
  }
200
189
 
@@ -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
  }