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.
- package/lib/commands/new.js +7 -10
- package/lib/scaffold/CHANGELOG.json +9 -0
- package/lib/scaffold/backends/supabase/config.toml +39 -2
- package/lib/scaffold/backends/supabase/deploy.js +14 -2
- package/lib/scaffold/catalog.js +24 -0
- package/lib/scaffold/shared/generator-utils.js +53 -1
- package/package.json +2 -2
- package/templates/firebase/assets/images/premium-bg.jpg +0 -0
- package/templates/firebase/assets/images/premium-switch-header.png +0 -0
- package/templates/firebase/lib/components/kasy_app_bar.dart +107 -1
- package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +26 -7
- package/templates/firebase/lib/core/bottom_menu/kasy_bottom_bar_factory.dart +63 -40
- package/templates/firebase/lib/core/chrome/chrome_visibility.dart +119 -0
- package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +12 -0
- package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +25 -3
- package/templates/firebase/lib/core/web_viewport_scale.dart +15 -4
- package/templates/firebase/lib/features/home/home_feed.dart +59 -5
- package/templates/firebase/lib/features/home/home_image_grid.dart +81 -52
- package/templates/firebase/lib/features/home/home_page.dart +6 -8
- package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +1 -0
- package/templates/firebase/lib/features/settings/settings_page.dart +26 -0
- package/templates/firebase/lib/features/subscriptions/ui/component/paywall_minimal.dart +169 -90
- package/templates/firebase/lib/features/subscriptions/ui/component/paywall_row.dart +219 -202
- package/templates/firebase/lib/features/subscriptions/ui/component/paywall_with_switch.dart +67 -30
- package/templates/firebase/lib/features/subscriptions/ui/component/premium_content.dart +16 -6
- package/templates/firebase/lib/i18n/en.i18n.json +1 -0
- package/templates/firebase/lib/i18n/es.i18n.json +1 -0
- package/templates/firebase/lib/i18n/pt.i18n.json +1 -0
- package/templates/firebase/pubspec.yaml +1 -1
package/lib/commands/new.js
CHANGED
|
@@ -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
|
|
1440
|
-
//
|
|
1441
|
-
//
|
|
1442
|
-
//
|
|
1443
|
-
//
|
|
1444
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
|
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 };
|
package/lib/scaffold/catalog.js
CHANGED
|
@@ -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:
|
|
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.
|
|
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",
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
-
|
|
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:
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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-
|
|
21
|
-
const double _kFloatingRadius = KasyRadius.
|
|
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 =
|
|
25
|
+
const double _kBarHeight = 60;
|
|
25
26
|
|
|
26
27
|
/// Tab icon size.
|
|
27
|
-
const double _kNavIconSize =
|
|
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:
|
|
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
|
-
//
|
|
145
|
-
// (
|
|
146
|
-
// pill
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
);
|