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.
- 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 -16
- package/lib/scaffold/catalog.js +24 -0
- 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 +22 -33
- 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/features/home/home_feed.dart +7 -1
- 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/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,26 +570,24 @@ async function deployFunctions(projectDir, functionNames = []) {
|
|
|
570
570
|
}
|
|
571
571
|
if (toDeploy.length === 0) return { ok: true, skipped: true };
|
|
572
572
|
|
|
573
|
-
//
|
|
574
|
-
//
|
|
575
|
-
//
|
|
576
|
-
//
|
|
577
|
-
//
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
// web app sees "Failed to fetch".
|
|
581
|
-
//
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
|
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 };
|
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
|
};
|
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 && 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",
|
|
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
|
|
|
@@ -159,42 +160,30 @@ final class KasyPaddedSurfaceBottomBarFactory extends BartBottomBarFactory {
|
|
|
159
160
|
),
|
|
160
161
|
);
|
|
161
162
|
|
|
162
|
-
//
|
|
163
|
-
// (
|
|
164
|
-
// pill
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
}
|