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
@@ -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,12 +570,24 @@ async function deployFunctions(projectDir, functionNames = []) {
570
570
  }
571
571
  if (toDeploy.length === 0) return { ok: true, skipped: true };
572
572
 
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.
573
586
  const steps = [];
574
587
  for (const name of toDeploy) {
575
588
  const fnPath = path.join(functionsDir, name);
576
589
  if (!(await fs.pathExists(fnPath))) continue;
577
- const noVerifyJwt = (name === 'ai-chat' || name === 'revenuecat-webhook' || name === 'send-push-notification' || name === 'stripe-webhook') ? ' --no-verify-jwt' : '';
578
- const result = await run(`supabase functions deploy ${name}${noVerifyJwt}`, projectDir);
590
+ const result = await run(`supabase functions deploy ${name} --no-verify-jwt`, projectDir);
579
591
  steps.push({ name: `deploy ${name}`, ok: result.ok, error: result.error });
580
592
  }
581
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
  };
@@ -315,6 +315,21 @@ async function writeRouter(projectDir, modules, packageName, defaultPaywall = 'b
315
315
  const withAnalytics = modules.includes('analytics');
316
316
  const withWidget = modules.includes('widget');
317
317
 
318
+ // The engine already copied the kit's router.dart here (with the package name
319
+ // rewritten). That kit router is the SOURCE OF TRUTH: backend-agnostic, feature
320
+ // -flag aware (reads core/config/features.dart), and it carries the real
321
+ // navigation — the custom kasyTransitionPage transitions, the /admin console and
322
+ // every route. When all router-relevant features are present (the default Quick
323
+ // setup) we KEEP it verbatim instead of rebuilding a hand-written copy that
324
+ // drifts from the kit. We only fall through to the trimmed builder below when a
325
+ // feature was removed, because its source files (and deps) are gone and its
326
+ // imports must go with them. See PUBLISHING.md ("propagação para os 3 backends").
327
+ const routerPath = path.join(projectDir, 'lib', 'router.dart');
328
+ const ROUTER_FEATURES = ['onboarding', 'revenuecat', 'feedback', 'local_reminders', 'widget', 'analytics'];
329
+ if (ROUTER_FEATURES.every((m) => modules.includes(m)) && (await fs.pathExists(routerPath))) {
330
+ return;
331
+ }
332
+
318
333
  const fallback = withOnboarding ? '/onboarding' : '/signin';
319
334
 
320
335
  const lines = [];
@@ -856,6 +871,40 @@ async function writeMainDart(projectDir, backend, modules, packageName, options
856
871
  // makes Firebase.initializeApp safe to call in all of them.
857
872
  const withFirebase = true;
858
873
 
874
+ // Derive main.dart FROM the kit (the source of truth) whenever every
875
+ // main-relevant feature is present (the default Quick setup). The engine already
876
+ // copied the kit's main.dart here with the package name rewritten; that file is a
877
+ // complete Firebase main — the custom WebViewportScale proportion, the web device
878
+ // preview, and every Initializer service. For Firebase and API backends it is
879
+ // already exactly right, so we keep it verbatim. For Supabase we splice in
880
+ // Supabase.initialize and keep everything else, so kit improvements still
881
+ // propagate to all three backends. We only fall through to the hand-built builder
882
+ // below when a feature was removed (its service/import must be trimmed) or, as a
883
+ // safety net, when the kit main changed shape and the Supabase splice can't find
884
+ // its anchors. See PUBLISHING.md ("propagação para os 3 backends").
885
+ const mainPath = path.join(projectDir, 'lib', 'main.dart');
886
+ const MAIN_FEATURES = ['sentry', 'widget', 'revenuecat'];
887
+ if (MAIN_FEATURES.every((m) => modules.includes(m)) && (await fs.pathExists(mainPath))) {
888
+ if (backend !== 'supabase') {
889
+ // Firebase & API: the copied kit main is already correct.
890
+ return;
891
+ }
892
+ let src = await fs.readFile(mainPath, 'utf8');
893
+ src = src.replace(
894
+ `import 'package:device_preview/device_preview.dart';\n`,
895
+ `import 'package:device_preview/device_preview.dart';\nimport 'package:supabase_flutter/supabase_flutter.dart';\n`,
896
+ );
897
+ src = src.replace(
898
+ ` // initialize firebase app for notifications\n`,
899
+ ` // initialize Supabase\n await Supabase.initialize(\n url: AppEnv.backendUrl,\n publishableKey: AppEnv.supabaseToken,\n );\n\n // initialize firebase app for notifications\n`,
900
+ );
901
+ if (src.includes('Supabase.initialize') && src.includes('supabase_flutter')) {
902
+ await fs.writeFile(mainPath, src, 'utf8');
903
+ return;
904
+ }
905
+ // Anchors not found (kit main changed) — fall back to the hand-built main below.
906
+ }
907
+
859
908
  const lines = [];
860
909
 
861
910
  // ── Imports ────────────────────────────────────────────────────────────────
@@ -886,6 +935,7 @@ async function writeMainDart(projectDir, backend, modules, packageName, options
886
935
  lines.push(`import 'package:${pkg}/core/dev_inspector/dev_inspector.dart';`);
887
936
  lines.push(`import 'package:${pkg}/core/theme/theme.dart';`);
888
937
  lines.push(`import 'package:${pkg}/core/web_device_preview/web_device_preview.dart';`);
938
+ lines.push(`import 'package:${pkg}/core/web_viewport_scale.dart';`);
889
939
  lines.push(`import 'package:${pkg}/environments.dart';`);
890
940
  if (withFirebase) {
891
941
  lines.push(`import 'package:${pkg}/firebase_options_dev.dart' as firebase_dev;`);
@@ -1106,7 +1156,9 @@ async function writeMainDart(projectDir, backend, modules, packageName, options
1106
1156
  lines.push(` analyticsApiProvider,`);
1107
1157
  lines.push(` facebookEventApiProvider,`);
1108
1158
  lines.push(` ],`);
1109
- lines.push(` onReady: DevicePreview.appBuilder(context, child),`);
1159
+ lines.push(` onReady: WebViewportScale.wrap(`);
1160
+ lines.push(` DevicePreview.appBuilder(context, child),`);
1161
+ lines.push(` ),`);
1110
1162
  lines.push(` onError: (_, error) => InitializationErrorPage(error: error),`);
1111
1163
  lines.push(` onLoading: Scaffold(`);
1112
1164
  lines.push(` body: Center(`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kasy-cli",
3
- "version": "1.31.8",
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",
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
 
@@ -17,22 +18,33 @@ const double _kFloatingTopGap = 6;
17
18
  /// inset is used instead so the home indicator stays clear.
18
19
  const double _kFloatingMinBottomGap = 16;
19
20
 
20
- /// Corner radius of the floating pill (HeroUI `rounded-3xl`).
21
- const double _kFloatingRadius = KasyRadius.rounded3xl;
21
+ /// Corner radius of the floating pill (HeroUI `rounded-2.5xl`).
22
+ const double _kFloatingRadius = KasyRadius.rounded2_5xl;
22
23
 
23
24
  /// Bar (pill) inner height — the row of tab items.
24
- const double _kBarHeight = 72;
25
+ const double _kBarHeight = 60;
25
26
 
26
27
  /// Tab icon size.
27
- const double _kNavIconSize = 24;
28
+ const double _kNavIconSize = 22;
28
29
 
29
30
  /// Gap between a tab's icon and its label.
30
31
  const double _kNavLabelGap = 3;
31
32
 
33
+ /// Tab label size. Fixed (not derived from the text theme) so every label —
34
+ /// "Início" and "Configurações" alike — reads at the exact same size and the
35
+ /// longer ones don't get clipped with an ellipsis on narrow phones.
36
+ const double _kNavLabelSize = 11;
37
+
32
38
  /// Selection color-fade timing (muted ↔ primary highlight).
33
39
  const Duration _kSelectDuration = Duration(milliseconds: 200);
34
40
  const Curve _kSelectCurve = Curves.easeOut;
35
41
 
42
+ /// Subtle "pop" the icon does when its tab becomes active: a gentle scale-up
43
+ /// with a slight overshoot that settles — modern, not bouncy.
44
+ const Duration _kPopDuration = Duration(milliseconds: 260);
45
+ const Curve _kPopCurve = Curves.easeOutBack;
46
+ const double _kPopScale = 0.12;
47
+
36
48
  /// [BartScaffold] requires a [bart.BartBottomBar]. This factory draws a single,
37
49
  /// custom navigation bar (same look on iOS and Android) wrapped in a floating,
38
50
  /// fully rounded surface that hovers above the screen edges with a safe-area
@@ -114,9 +126,16 @@ final class KasyPaddedSurfaceBottomBarFactory extends BartBottomBarFactory {
114
126
  Radius.circular(_kFloatingRadius),
115
127
  );
116
128
 
129
+ // In dark mode the cards use `surface`, so a `surface` bar melts into them.
130
+ // A one-step-lighter surface lifts the floating bar above the content and
131
+ // reads as elevated. In light mode the white `surface` already pops against
132
+ // the grey canvas, so it stays as is.
133
+ final Color pillColor =
134
+ context.isDark ? colors.surfaceSecondary : colors.surface;
135
+
117
136
  final Widget pill = Container(
118
137
  decoration: BoxDecoration(
119
- color: colors.surface,
138
+ color: pillColor,
120
139
  borderRadius: radius,
121
140
  // Single, very light shadow — just enough to lift the pill off the canvas.
122
141
  boxShadow: [
@@ -141,42 +160,30 @@ final class KasyPaddedSurfaceBottomBarFactory extends BartBottomBarFactory {
141
160
  ),
142
161
  );
143
162
 
144
- // Low bottom-up fade in the page background color: opaque at the very bottom
145
- // (hides content scrolling under the bar), fully transparent again around the
146
- // pill's mid-height so it stays a discreet wash low on the bar rather than a
147
- // tall veil. Follows light/dark via the background token; same RGB at both
148
- // ends so there is no grey halo.
149
- final Widget fade = IgnorePointer(
150
- child: DecoratedBox(
151
- decoration: BoxDecoration(
152
- gradient: LinearGradient(
153
- begin: Alignment.bottomCenter,
154
- end: Alignment.topCenter,
155
- colors: [
156
- colors.background,
157
- colors.background,
158
- colors.background.withValues(alpha: 0),
159
- ],
160
- stops: const [0.0, 0.33, 0.66],
161
- ),
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,
162
183
  ),
184
+ child: pill,
163
185
  ),
164
186
  );
165
-
166
- return Stack(
167
- children: [
168
- Positioned.fill(child: fade),
169
- Padding(
170
- padding: EdgeInsets.only(
171
- left: _kFloatingSideMargin,
172
- right: _kFloatingSideMargin,
173
- top: _kFloatingTopGap,
174
- bottom: bottomGap,
175
- ),
176
- child: pill,
177
- ),
178
- ],
179
- );
180
187
  }
181
188
  }
182
189
 
@@ -227,13 +234,29 @@ class _KasyNavItem extends StatelessWidget {
227
234
  return Column(
228
235
  mainAxisSize: MainAxisSize.min,
229
236
  children: [
230
- _icon(context, color),
237
+ // Gentle scale pop when the tab becomes active.
238
+ TweenAnimationBuilder<double>(
239
+ tween: Tween<double>(end: selected ? 1.0 : 0.0),
240
+ duration: _kPopDuration,
241
+ curve: _kPopCurve,
242
+ builder: (context, p, child) => Transform.scale(
243
+ scale: 1 + _kPopScale * p,
244
+ child: child,
245
+ ),
246
+ child: _icon(context, color),
247
+ ),
231
248
  const SizedBox(height: _kNavLabelGap),
232
249
  Text(
233
250
  label,
234
251
  maxLines: 1,
235
252
  overflow: TextOverflow.ellipsis,
236
- style: labelBase.copyWith(color: color),
253
+ style: labelBase.copyWith(
254
+ color: color,
255
+ fontSize: _kNavLabelSize,
256
+ height: 1.0,
257
+ fontWeight:
258
+ selected ? FontWeight.w600 : FontWeight.w500,
259
+ ),
237
260
  ),
238
261
  ],
239
262
  );