kasy-cli 1.31.14 → 1.32.0
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 +15 -1
- package/lib/scaffold/CHANGELOG.json +9 -0
- package/lib/scaffold/backends/api/patch/README.md +87 -2
- package/lib/scaffold/backends/api/patch/lib/features/authentication/api/authentication_api.dart +34 -0
- package/lib/scaffold/backends/api/patch/lib/features/subscriptions/api/stripe_backend_api.dart +12 -2
- package/lib/scaffold/backends/firebase/setup-from-scratch.js +22 -0
- package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/README.md +26 -22
- package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/index.ts +8 -11
- package/lib/scaffold/backends/supabase/edge-functions/stripe-create-checkout-session/index.ts +3 -1
- package/lib/scaffold/backends/supabase/edge-functions/stripe-create-portal-session/index.ts +60 -3
- package/lib/scaffold/backends/supabase/patch/lib/features/authentication/api/authentication_api.dart +12 -0
- package/lib/scaffold/backends/supabase/patch/lib/features/subscriptions/api/stripe_backend_api.dart +12 -2
- package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +3 -2
- package/lib/scaffold/generate.js +1 -1
- package/lib/scaffold/shared/generator-utils.js +22 -3
- package/lib/utils/i18n/messages-en.js +2 -0
- package/lib/utils/i18n/messages-es.js +2 -0
- package/lib/utils/i18n/messages-pt.js +2 -0
- package/package.json +2 -2
- package/templates/firebase/docs/auth-setup.en.md +7 -1
- package/templates/firebase/docs/auth-setup.es.md +7 -1
- package/templates/firebase/docs/auth-setup.pt.md +7 -1
- package/templates/firebase/functions/src/subscriptions/stripe_functions.ts +79 -5
- package/templates/firebase/lib/components/kasy_accordion.dart +2 -2
- package/templates/firebase/lib/components/kasy_alert.dart +1 -1
- package/templates/firebase/lib/components/kasy_app_bar.dart +3 -3
- package/templates/firebase/lib/components/kasy_bottom_sheet.dart +1 -1
- package/templates/firebase/lib/components/kasy_button.dart +8 -8
- package/templates/firebase/lib/components/kasy_chip.dart +1 -1
- package/templates/firebase/lib/components/kasy_date_picker.dart +26 -21
- package/templates/firebase/lib/components/kasy_dialog.dart +2 -2
- package/templates/firebase/lib/components/kasy_sidebar.dart +2 -2
- package/templates/firebase/lib/components/kasy_tabs.dart +2 -2
- package/templates/firebase/lib/components/kasy_text_area.dart +37 -5
- package/templates/firebase/lib/components/kasy_text_field.dart +77 -16
- package/templates/firebase/lib/components/kasy_toast.dart +1 -1
- package/templates/firebase/lib/components/kasy_web_header.dart +4 -3
- package/templates/firebase/lib/core/config/features.dart +13 -0
- package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +21 -0
- package/templates/firebase/lib/core/rating/widgets/rate_banner.dart +1 -1
- package/templates/firebase/lib/core/rating/widgets/review_popup.dart +1 -1
- package/templates/firebase/lib/core/theme/icon_sizes.dart +47 -0
- package/templates/firebase/lib/core/theme/shadows.dart +13 -0
- package/templates/firebase/lib/core/theme/texts.dart +32 -0
- package/templates/firebase/lib/core/theme/theme.dart +2 -0
- package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +3 -0
- package/templates/firebase/lib/core/web_viewport_scale.dart +23 -4
- package/templates/firebase/lib/core/widgets/update_bottom_sheet.dart +1 -1
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +1 -1
- package/templates/firebase/lib/features/authentication/ui/components/otp_verification.dart +1 -1
- package/templates/firebase/lib/features/authentication/ui/components/phone_input.dart +2 -1
- package/templates/firebase/lib/features/authentication/ui/recover_password_page.dart +3 -1
- package/templates/firebase/lib/features/authentication/ui/signin_page.dart +36 -14
- package/templates/firebase/lib/features/authentication/ui/signup_page.dart +27 -11
- package/templates/firebase/lib/features/authentication/ui/widgets/recover_password_result.dart +1 -1
- package/templates/firebase/lib/features/feedbacks/ui/feedback_page.dart +1 -1
- package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +1 -1
- package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +1 -1
- package/templates/firebase/lib/features/home/home_components_preview_registry.dart +22 -3
- package/templates/firebase/lib/features/home/home_image_grid.dart +1 -1
- package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +2 -2
- package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +6 -1
- package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +35 -38
- package/templates/firebase/lib/features/notifications/ui/widgets/permission_request_view.dart +1 -1
- package/templates/firebase/lib/features/onboarding/ui/components/onboarding_loader.dart +1 -1
- package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_module_mockups.dart +3 -3
- package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +1 -1
- package/templates/firebase/lib/features/settings/settings_page.dart +264 -307
- package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +2 -2
- package/templates/firebase/lib/features/settings/ui/components/edit_name_sheet.dart +115 -0
- package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +2 -2
- package/templates/firebase/lib/features/settings/ui/widgets/settings_bottom_sheet_option_tile.dart +1 -1
- package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +13 -5
- package/templates/firebase/lib/features/subscriptions/api/stripe_backend_api.dart +12 -2
- package/templates/firebase/lib/features/subscriptions/api/stripe_payment_api.dart +7 -1
- package/templates/firebase/lib/features/subscriptions/providers/premium_page_provider.dart +11 -3
- package/templates/firebase/lib/features/subscriptions/ui/component/paywall_row.dart +1 -1
- package/templates/firebase/lib/features/subscriptions/ui/widgets/comparison_table.dart +1 -1
- package/templates/firebase/lib/features/subscriptions/ui/widgets/feature_line.dart +2 -2
- package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_close_button.dart +1 -1
- package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_feature.dart +1 -1
- package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_col.dart +3 -3
- package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_row.dart +1 -1
- package/templates/firebase/lib/i18n/en.i18n.json +8 -0
- package/templates/firebase/lib/i18n/es.i18n.json +8 -0
- package/templates/firebase/lib/i18n/pt.i18n.json +8 -0
- package/templates/firebase/pubspec.yaml +0 -1
- package/templates/firebase/web/stripe_success.html +64 -26
- package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/animations/movefade_anim.dart +0 -34
- package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/components/onboarding_att_setup.dart +0 -67
- package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +0 -183
- package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/animations/movefade_anim.dart +0 -34
- package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/components/onboarding_att_setup.dart +0 -67
- package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +0 -183
- package/templates/firebase/lib/features/authentication/ui/components/facebook_signin.dart +0 -19
- package/templates/firebase/login-redesign-preview.png +0 -0
|
@@ -616,7 +616,7 @@ async function writeRouter(projectDir, modules, packageName, defaultPaywall = 'b
|
|
|
616
616
|
* @param {object} [answers={}] - moduleAnswers (rcTestKey, stripe keys, etc.)
|
|
617
617
|
* @param {string} [language='en'] - User's CLI language (en, pt, es)
|
|
618
618
|
*/
|
|
619
|
-
async function writeFeaturesConfig(projectDir, modules, answers = {}, language = 'en') {
|
|
619
|
+
async function writeFeaturesConfig(projectDir, modules, answers = {}, language = 'en', backend = 'firebase') {
|
|
620
620
|
const withOnboarding = modules.includes('onboarding');
|
|
621
621
|
const withAiChat = modules.includes('ai_chat');
|
|
622
622
|
const withFeedback = modules.includes('feedback');
|
|
@@ -624,6 +624,10 @@ async function writeFeaturesConfig(projectDir, modules, answers = {}, language =
|
|
|
624
624
|
const withStripe = modules.includes('stripe');
|
|
625
625
|
const withLocalReminders = modules.includes('local_reminders');
|
|
626
626
|
const withWeb = modules.includes('web');
|
|
627
|
+
// Apple sign-in on web only works on Firebase (signInWithPopup). Supabase/API
|
|
628
|
+
// throw on web (no Service ID + token exchange), so they must ship this false
|
|
629
|
+
// and hide the Apple button on web. Native always shows it.
|
|
630
|
+
const withAppleWebSignin = backend === 'firebase';
|
|
627
631
|
|
|
628
632
|
const f = getStrings(language).features;
|
|
629
633
|
const content = `${f.comment1}
|
|
@@ -635,7 +639,14 @@ const bool withFeedback = ${withFeedback};
|
|
|
635
639
|
const bool withRevenuecat = ${withRevenuecat};
|
|
636
640
|
// Stripe web subscriptions module (independent from RevenueCat mobile IAP).
|
|
637
641
|
const bool withStripe = ${withStripe};
|
|
642
|
+
// When true, Stripe Checkout shows a promo-code / coupon field.
|
|
643
|
+
const bool withStripePromoCodes = true;
|
|
644
|
+
// When true, the Stripe Customer Portal lets subscribers switch plans (upgrade / downgrade).
|
|
645
|
+
const bool withStripePlanSwitching = true;
|
|
638
646
|
const bool withLocalReminders = ${withLocalReminders};
|
|
647
|
+
// Apple sign-in on web: Firebase supports it (signInWithPopup); Supabase/API throw
|
|
648
|
+
// on web, so they ship false. Native always shows the Apple button.
|
|
649
|
+
const bool withAppleWebSignin = ${withAppleWebSignin};
|
|
639
650
|
${f.comment3}
|
|
640
651
|
${f.comment4}
|
|
641
652
|
${f.comment5}
|
|
@@ -1347,10 +1358,18 @@ async function removeFacebookSigninFromAuthPages(projectDir) {
|
|
|
1347
1358
|
for (const p of pages) {
|
|
1348
1359
|
if (!(await fs.pathExists(p))) continue;
|
|
1349
1360
|
let content = await fs.readFile(p, 'utf8');
|
|
1350
|
-
//
|
|
1361
|
+
// Legacy: remove the standalone FacebookSigninComponent + its import, if present.
|
|
1351
1362
|
content = content.replace(/^import 'package:[^']+\/features\/authentication\/ui\/components\/facebook_signin\.dart';\n/m, '');
|
|
1352
|
-
// Remove component line (any leading whitespace)
|
|
1353
1363
|
content = content.replace(/[ \t]*const FacebookSigninComponent\(\),\n/g, '');
|
|
1364
|
+
// Current UI: the Facebook button is an inline _SocialSigninTile/_SocialSignupTile
|
|
1365
|
+
// in the social row (label: t.auth.signin.facebook ... signinWithFacebook()). Strip
|
|
1366
|
+
// the whole tile plus the SizedBox spacer that precedes it, anchored on the facebook
|
|
1367
|
+
// label so the Google/Apple tiles are never touched. Runs before dartFix/format, so
|
|
1368
|
+
// it matches the kit's raw formatting verbatim.
|
|
1369
|
+
content = content.replace(
|
|
1370
|
+
/\n[ \t]*const SizedBox\(width: KasySpacing\.sm\),\n[ \t]*Expanded\(\n[ \t]*child: _Social(?:Signin|Signup)Tile\(\n[ \t]*label: t\.auth\.signin\.facebook,[\s\S]*?\.signinWithFacebook\(\),\n[ \t]*\),\n[ \t]*\),/,
|
|
1371
|
+
'',
|
|
1372
|
+
);
|
|
1354
1373
|
await fs.writeFile(p, content, 'utf8');
|
|
1355
1374
|
}
|
|
1356
1375
|
}
|
|
@@ -740,6 +740,7 @@ module.exports = {
|
|
|
740
740
|
'new.success.featuresInstalled': 'Features enabled:',
|
|
741
741
|
'new.success.bundleId': 'App identifier (bundle ID)',
|
|
742
742
|
'new.success.bundleId.hint': "Your app's unique identifier on Android, iOS and Firebase (push).",
|
|
743
|
+
'new.success.api.serverContracts': 'API backend: you must implement the server contracts (delete account, AI chat and push). See patch/README.md',
|
|
743
744
|
'new.success.nextSteps': 'Next steps:',
|
|
744
745
|
'new.success.step.cd': 'Go to your project folder:',
|
|
745
746
|
'new.success.step.deploy': 'Push the server to Firebase (DB + functions):',
|
|
@@ -754,6 +755,7 @@ module.exports = {
|
|
|
754
755
|
'new.google.manualHint': 'Google Sign-In: enable manually in the Console (Google provider):',
|
|
755
756
|
'new.google.manualHint.noEmail': 'Google Sign-In: could not detect a support email (gcloud has no account). Enable manually in the Console:',
|
|
756
757
|
'new.google.supabaseManual': 'Google Sign-In: client created, but the secret was not available yet. Enable it later in the Supabase dashboard (Authentication > Providers > Google).',
|
|
758
|
+
'new.google.localhostDomainWarn': 'Google sign-in on web: could not authorize localhost in the Firebase authorized domains automatically. If the Google popup fails with "unauthorized-domain", add localhost in Firebase Console > Authentication > Settings > Authorized domains.',
|
|
757
759
|
'new.fcm.ok': 'generated automatically',
|
|
758
760
|
'new.fcm.failSupabase': 'not generated (GCP permission still propagating); set FIREBASE_SERVICE_ACCOUNT_JSON in your Supabase secrets',
|
|
759
761
|
'new.fcm.failApi': 'not generated (GCP permission still propagating); run the command again in a few minutes',
|
|
@@ -740,6 +740,7 @@ module.exports = {
|
|
|
740
740
|
'new.success.featuresInstalled': 'Recursos activados:',
|
|
741
741
|
'new.success.bundleId': 'Identificador de la app (bundle ID)',
|
|
742
742
|
'new.success.bundleId.hint': 'Identificador único de tu app en Android, iOS y Firebase (push).',
|
|
743
|
+
'new.success.api.serverContracts': 'Backend API: debes implementar los contratos del servidor (eliminar cuenta, IA chat y push). Consulta patch/README.md',
|
|
743
744
|
'new.success.nextSteps': 'Próximos pasos:',
|
|
744
745
|
'new.success.step.cd': 'Ve a la carpeta del proyecto:',
|
|
745
746
|
'new.success.step.deploy': 'Sube el servidor a Firebase (DB + funciones):',
|
|
@@ -754,6 +755,7 @@ module.exports = {
|
|
|
754
755
|
'new.google.manualHint': 'Inicio de sesión con Google: actívalo manualmente en la consola (proveedor Google):',
|
|
755
756
|
'new.google.manualHint.noEmail': 'Inicio de sesión con Google: no detecté un email de soporte (gcloud sin cuenta). Actívalo manualmente en la consola:',
|
|
756
757
|
'new.google.supabaseManual': 'Inicio de sesión con Google: cliente creado, pero el secret aún no estaba disponible. Actívalo luego en el panel de Supabase (Authentication > Providers > Google).',
|
|
758
|
+
'new.google.localhostDomainWarn': 'Inicio de sesión con Google en web: no se pudo autorizar localhost en los dominios de Firebase automáticamente. Si el popup de Google falla con "unauthorized-domain", agrega localhost en Firebase Console > Authentication > Settings > Authorized domains.',
|
|
757
759
|
'new.fcm.ok': 'generada automáticamente',
|
|
758
760
|
'new.fcm.failSupabase': 'no generada (permiso de GCP aún propagando); define FIREBASE_SERVICE_ACCOUNT_JSON en los secrets de Supabase',
|
|
759
761
|
'new.fcm.failApi': 'no generada (permiso de GCP aún propagando); ejecuta el comando de nuevo en unos minutos',
|
|
@@ -740,6 +740,7 @@ module.exports = {
|
|
|
740
740
|
'new.success.featuresInstalled': 'Recursos ativados:',
|
|
741
741
|
'new.success.bundleId': 'Identificador do app (bundle ID)',
|
|
742
742
|
'new.success.bundleId.hint': 'Identificador único do seu app no Android, iOS e Firebase (push).',
|
|
743
|
+
'new.success.api.serverContracts': 'Backend API: você precisa implementar os contratos do servidor (excluir conta, IA chat e push). Veja patch/README.md',
|
|
743
744
|
'new.success.nextSteps': 'Próximos passos:',
|
|
744
745
|
'new.success.step.cd': 'Entre na pasta do projeto:',
|
|
745
746
|
'new.success.step.deploy': 'Suba o servidor pro Firebase (banco + funções):',
|
|
@@ -754,6 +755,7 @@ module.exports = {
|
|
|
754
755
|
'new.google.manualHint': 'Login com Google: ative manualmente no Console (provedor Google):',
|
|
755
756
|
'new.google.manualHint.noEmail': 'Login com Google: não consegui detectar um e-mail de suporte (gcloud sem conta). Ative manualmente no Console:',
|
|
756
757
|
'new.google.supabaseManual': 'Login com Google: client criado, mas o secret ainda não estava disponível. Ative depois no painel do Supabase (Authentication > Providers > Google).',
|
|
758
|
+
'new.google.localhostDomainWarn': 'Login com Google na web: não consegui autorizar o localhost nos domínios do Firebase automaticamente. Se o popup do Google falhar com "unauthorized-domain", adicione localhost em Firebase Console > Authentication > Settings > Authorized domains.',
|
|
757
759
|
'new.fcm.ok': 'gerada automaticamente',
|
|
758
760
|
'new.fcm.failSupabase': 'não gerada (permissão do GCP ainda propagando); defina FIREBASE_SERVICE_ACCOUNT_JSON nos secrets do Supabase',
|
|
759
761
|
'new.fcm.failApi': 'não gerada (permissão do GCP ainda propagando); rode o comando de novo em alguns minutos',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kasy-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.32.0",
|
|
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 && node test/supabase-verify-jwt.test.js && node test/supabase-cors.test.js && node test/supabase-google-web.test.js && node test/backend-pubspec-local-reminders.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 && node test/supabase-cors.test.js && node test/supabase-google-web.test.js && node test/backend-pubspec-local-reminders.test.js && node test/stripe-webhook-orphan-guard.test.js && node test/facebook-strip.test.js && node test/path-provider-pin.test.js && node test/features-flags-parity.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",
|
|
@@ -69,7 +69,13 @@ Requires an [Apple Developer](https://developer.apple.com) account (paid).
|
|
|
69
69
|
1. Open `ios/Runner.xcworkspace` in Xcode
|
|
70
70
|
2. Target **Runner** → **Signing & Capabilities** → **+ Capability** → add **Sign In with Apple**
|
|
71
71
|
|
|
72
|
-
> **
|
|
72
|
+
> **iOS / macOS**: the Apple button shows automatically once the steps above are done.
|
|
73
|
+
>
|
|
74
|
+
> **Android**: the Apple button is hidden by design (it needs the paid Services ID web flow and adds little on Android for a SaaS). Leave it hidden.
|
|
75
|
+
>
|
|
76
|
+
> **Web (Firebase)**: works after Steps 1-4 above (`withAppleWebSignin` already ships `true`). The Services ID Return URL (`firebaseapp.com/__/auth/handler`) covers the popup flow.
|
|
77
|
+
>
|
|
78
|
+
> **Web (Supabase)**: the CLI ships `withAppleWebSignin = false` (native iOS works; web needs more). To enable it: in Supabase → Authentication → Providers → Apple, add the **client secret** (a JWT signed with your `.p8` key + Services ID), then set `withAppleWebSignin = true` in `lib/core/config/features.dart`.
|
|
73
79
|
|
|
74
80
|
---
|
|
75
81
|
|
|
@@ -69,7 +69,13 @@ Requiere cuenta de [Apple Developer](https://developer.apple.com) (de pago).
|
|
|
69
69
|
1. Abre `ios/Runner.xcworkspace` en Xcode
|
|
70
70
|
2. Target **Runner** → **Signing & Capabilities** → **+ Capability** → agrega **Sign In with Apple**
|
|
71
71
|
|
|
72
|
-
> **
|
|
72
|
+
> **iOS / macOS**: el botón Apple aparece automáticamente tras los pasos anteriores.
|
|
73
|
+
>
|
|
74
|
+
> **Android**: el botón Apple queda oculto por defecto (necesita el flujo del Services ID de pago y aporta poco en Android para un SaaS). Déjalo oculto.
|
|
75
|
+
>
|
|
76
|
+
> **Web (Firebase)**: funciona tras los Pasos 1 a 4 anteriores (`withAppleWebSignin` ya viene `true`). La Return URL del Services ID (`firebaseapp.com/__/auth/handler`) cubre el flujo de popup.
|
|
77
|
+
>
|
|
78
|
+
> **Web (Supabase)**: la CLI genera `withAppleWebSignin = false` (iOS nativo funciona; la web necesita más). Para habilitar: en Supabase → Authentication → Providers → Apple, agrega el **client secret** (un JWT firmado con la clave `.p8` + Services ID) y luego define `withAppleWebSignin = true` en `lib/core/config/features.dart`.
|
|
73
79
|
|
|
74
80
|
---
|
|
75
81
|
|
|
@@ -69,7 +69,13 @@ Requer conta [Apple Developer](https://developer.apple.com) (paga).
|
|
|
69
69
|
1. Abra `ios/Runner.xcworkspace` no Xcode
|
|
70
70
|
2. Target **Runner** → **Signing & Capabilities** → **+ Capability** → adicione **Sign In with Apple**
|
|
71
71
|
|
|
72
|
-
> **
|
|
72
|
+
> **iOS / macOS**: o botão Apple aparece automaticamente depois dos passos acima.
|
|
73
|
+
>
|
|
74
|
+
> **Android**: o botão Apple fica escondido por padrão (exige o fluxo do Services ID pago e agrega pouco no Android para um SaaS). Deixe escondido.
|
|
75
|
+
>
|
|
76
|
+
> **Web (Firebase)**: funciona depois dos Passos 1 a 4 acima (`withAppleWebSignin` já vem `true`). A Return URL do Services ID (`firebaseapp.com/__/auth/handler`) cobre o fluxo de popup.
|
|
77
|
+
>
|
|
78
|
+
> **Web (Supabase)**: a CLI gera `withAppleWebSignin = false` (iOS nativo funciona; web precisa de mais). Para habilitar: em Supabase → Authentication → Providers → Apple, adicione o **client secret** (um JWT assinado com a chave `.p8` + Services ID) e depois defina `withAppleWebSignin = true` em `lib/core/config/features.dart`.
|
|
73
79
|
|
|
74
80
|
---
|
|
75
81
|
|
|
@@ -125,6 +125,8 @@ export const createCheckoutSession = onCall(
|
|
|
125
125
|
const price = await stripe.prices.retrieve(priceId, {expand: ["product"]});
|
|
126
126
|
const trialDays = trialDaysFor(price, price.product as Stripe.Product);
|
|
127
127
|
|
|
128
|
+
const allowPromoCodes = request.data?.allowPromoCodes as boolean | undefined;
|
|
129
|
+
|
|
128
130
|
const session = await stripe.checkout.sessions.create({
|
|
129
131
|
mode: "subscription",
|
|
130
132
|
customer: customerId,
|
|
@@ -136,13 +138,70 @@ export const createCheckoutSession = onCall(
|
|
|
136
138
|
metadata: {firebaseUID: uid},
|
|
137
139
|
...(trialDays ? {trial_period_days: trialDays} : {}),
|
|
138
140
|
},
|
|
141
|
+
...(allowPromoCodes ? {allow_promotion_codes: true} : {}),
|
|
139
142
|
});
|
|
140
143
|
return {url: session.url};
|
|
141
144
|
},
|
|
142
145
|
);
|
|
143
146
|
|
|
144
147
|
// ---------------------------------------------------------------------------
|
|
145
|
-
//
|
|
148
|
+
// getOrCreatePortalConfig — creates (once) a Customer Portal configuration
|
|
149
|
+
// with subscription_update (plan switching) enabled and caches its ID in
|
|
150
|
+
// Firestore so we don't recreate it on every portal open. Falls back to
|
|
151
|
+
// undefined (default portal) if there are no prices to switch between.
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
async function getOrCreatePortalConfig(stripe: Stripe): Promise<string | undefined> {
|
|
154
|
+
const db = admin.firestore();
|
|
155
|
+
const configRef = db.doc("_config/stripe_portal");
|
|
156
|
+
const snap = await configRef.get();
|
|
157
|
+
const cachedId = snap.data()?.configId as string | undefined;
|
|
158
|
+
if (cachedId) {
|
|
159
|
+
try {
|
|
160
|
+
const cfg = await stripe.billingPortal.configurations.retrieve(cachedId);
|
|
161
|
+
if (cfg.active) return cachedId;
|
|
162
|
+
} catch {
|
|
163
|
+
// Cached config was deleted on Stripe — recreate below
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Build allowed-prices list grouped by product. STRIPE_PRODUCT_ID narrows the
|
|
168
|
+
// query to a single product; if it's not set we use all active recurring prices.
|
|
169
|
+
const productId = stripeProductId.value();
|
|
170
|
+
const priceParams: Stripe.PriceListParams = {active: true, type: "recurring", limit: 100};
|
|
171
|
+
if (productId) priceParams.product = productId;
|
|
172
|
+
const {data: prices} = await stripe.prices.list(priceParams);
|
|
173
|
+
|
|
174
|
+
if (prices.length === 0) return undefined;
|
|
175
|
+
|
|
176
|
+
const byProduct: Record<string, string[]> = {};
|
|
177
|
+
for (const p of prices) {
|
|
178
|
+
const pid = typeof p.product === "string" ? p.product : p.product.id;
|
|
179
|
+
if (!byProduct[pid]) byProduct[pid] = [];
|
|
180
|
+
byProduct[pid].push(p.id);
|
|
181
|
+
}
|
|
182
|
+
const products = Object.entries(byProduct).map(([prod, priceIds]) => ({
|
|
183
|
+
product: prod,
|
|
184
|
+
prices: priceIds,
|
|
185
|
+
}));
|
|
186
|
+
|
|
187
|
+
const config = await stripe.billingPortal.configurations.create({
|
|
188
|
+
features: {
|
|
189
|
+
subscription_update: {
|
|
190
|
+
enabled: true,
|
|
191
|
+
default_allowed_updates: ["price"],
|
|
192
|
+
products,
|
|
193
|
+
},
|
|
194
|
+
subscription_cancel: {enabled: true, mode: "at_period_end"},
|
|
195
|
+
payment_method_update: {enabled: true},
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
await configRef.set({configId: config.id}, {merge: true});
|
|
200
|
+
return config.id;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// createPortalSession — Stripe Customer Portal (manage / cancel / switch plan).
|
|
146
205
|
// ---------------------------------------------------------------------------
|
|
147
206
|
export const createPortalSession = onCall(
|
|
148
207
|
{secrets: [stripeSecretKey]},
|
|
@@ -150,6 +209,7 @@ export const createPortalSession = onCall(
|
|
|
150
209
|
const uid = request.auth?.uid;
|
|
151
210
|
if (!uid) throw new HttpsError("unauthenticated", "Sign in required");
|
|
152
211
|
const returnUrl = (request.data?.returnUrl as string | undefined) ?? "";
|
|
212
|
+
const planSwitching = request.data?.planSwitching as boolean | undefined;
|
|
153
213
|
|
|
154
214
|
const stripe = stripeClient();
|
|
155
215
|
const snap = await admin
|
|
@@ -161,9 +221,16 @@ export const createPortalSession = onCall(
|
|
|
161
221
|
if (!customerId) {
|
|
162
222
|
throw new HttpsError("failed-precondition", "No Stripe customer for user");
|
|
163
223
|
}
|
|
224
|
+
|
|
225
|
+
// When plan switching is requested, resolve (or create) a portal configuration
|
|
226
|
+
// that has subscription_update enabled. This removes the need for any manual
|
|
227
|
+
// setup in the Stripe dashboard.
|
|
228
|
+
const configId = planSwitching ? await getOrCreatePortalConfig(stripe) : undefined;
|
|
229
|
+
|
|
164
230
|
const session = await stripe.billingPortal.sessions.create({
|
|
165
231
|
customer: customerId,
|
|
166
232
|
return_url: returnUrl,
|
|
233
|
+
...(configId ? {configuration: configId} : {}),
|
|
167
234
|
});
|
|
168
235
|
return {url: session.url};
|
|
169
236
|
},
|
|
@@ -189,11 +256,18 @@ async function upsertFromStripeSubscription(sub: Stripe.Subscription): Promise<v
|
|
|
189
256
|
console.log("[stripe-webhook] subscription without firebaseUID metadata, skipping");
|
|
190
257
|
return;
|
|
191
258
|
}
|
|
259
|
+
// Skip if the user no longer exists. Deleting an account cancels the Stripe
|
|
260
|
+
// customer, which fires customer.subscription.deleted AFTER deleteUserAccount
|
|
261
|
+
// already removed subscriptions/{uid} — without this guard the webhook would
|
|
262
|
+
// re-create an orphan doc for a user that is gone. (The Supabase webhook does
|
|
263
|
+
// the same check.) The lookup also gives us the email to denormalize below.
|
|
264
|
+
const user = await usersRepository.getFromId(uid);
|
|
265
|
+
if (!user) {
|
|
266
|
+
console.log(`[stripe-webhook] user ${uid} not found (likely deleted), skipping`);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
192
269
|
const now = Timestamp.now();
|
|
193
270
|
const existing = await subscriptionsRepository.getFromUserId(uid);
|
|
194
|
-
// Denormalize the subscriber's email onto the Firestore subscription doc (see
|
|
195
|
-
// SubscriptionData.email) so a subscribers list reads it without a second hop.
|
|
196
|
-
const user = await usersRepository.getFromId(uid);
|
|
197
271
|
// In Stripe API v18 the billing period lives on each subscription item.
|
|
198
272
|
const item = sub.items.data[0];
|
|
199
273
|
const priceId = item?.price?.id ?? "";
|
|
@@ -211,7 +285,7 @@ async function upsertFromStripeSubscription(sub: Stripe.Subscription): Promise<v
|
|
|
211
285
|
expirationDate: expiration,
|
|
212
286
|
store: Stores.STRIPE,
|
|
213
287
|
productId: priceId,
|
|
214
|
-
email: user
|
|
288
|
+
email: user.email,
|
|
215
289
|
},
|
|
216
290
|
subscriptionsRepository,
|
|
217
291
|
);
|
|
@@ -366,7 +366,7 @@ class _TrailingChevron extends StatelessWidget {
|
|
|
366
366
|
duration: const Duration(milliseconds: 200),
|
|
367
367
|
child: Icon(
|
|
368
368
|
KasyIcons.chevronDown,
|
|
369
|
-
size:
|
|
369
|
+
size: KasyIconSize.md,
|
|
370
370
|
color: context.colors.muted,
|
|
371
371
|
),
|
|
372
372
|
);
|
|
@@ -376,7 +376,7 @@ class _TrailingChevron extends StatelessWidget {
|
|
|
376
376
|
duration: const Duration(milliseconds: 200),
|
|
377
377
|
child: Icon(
|
|
378
378
|
KasyIcons.chevronRight,
|
|
379
|
-
size:
|
|
379
|
+
size: KasyIconSize.md,
|
|
380
380
|
color: context.colors.muted,
|
|
381
381
|
),
|
|
382
382
|
);
|
|
@@ -292,7 +292,7 @@ class KasyAppBar extends StatelessWidget {
|
|
|
292
292
|
),
|
|
293
293
|
_ => KasyChromeOrbIconButton(
|
|
294
294
|
icon: KasyIcons.arrowBackIos,
|
|
295
|
-
iconSize:
|
|
295
|
+
iconSize: KasyIconSize.md,
|
|
296
296
|
foregroundColor: orbFg,
|
|
297
297
|
fillColor: orbFill,
|
|
298
298
|
onPressed: handleBack,
|
|
@@ -404,7 +404,7 @@ class KasyAppBar extends StatelessWidget {
|
|
|
404
404
|
case KasyAppBarStyle.subpage:
|
|
405
405
|
return KasyChromeOrbIconButton(
|
|
406
406
|
icon: isDark ? KasyIcons.lightMode : KasyIcons.darkMode,
|
|
407
|
-
iconSize:
|
|
407
|
+
iconSize: KasyIconSize.lg,
|
|
408
408
|
foregroundColor: iconFg,
|
|
409
409
|
fillColor: orbFill,
|
|
410
410
|
onPressed: () {
|
|
@@ -421,7 +421,7 @@ class KasyAppBar extends StatelessWidget {
|
|
|
421
421
|
if (trailing != null) return trailing!;
|
|
422
422
|
return KasyChromeOrbIconButton(
|
|
423
423
|
icon: isDark ? KasyIcons.lightMode : KasyIcons.darkMode,
|
|
424
|
-
iconSize:
|
|
424
|
+
iconSize: KasyIconSize.lg,
|
|
425
425
|
foregroundColor: iconFg,
|
|
426
426
|
fillColor: orbFill,
|
|
427
427
|
onPressed: () {
|
|
@@ -298,7 +298,7 @@ class _IconBubble extends StatelessWidget {
|
|
|
298
298
|
width: 72,
|
|
299
299
|
height: 72,
|
|
300
300
|
decoration: BoxDecoration(color: p.background, shape: BoxShape.circle),
|
|
301
|
-
child: Icon(icon, size:
|
|
301
|
+
child: Icon(icon, size: KasyIconSize.xxl, color: p.foreground),
|
|
302
302
|
);
|
|
303
303
|
}
|
|
304
304
|
|
|
@@ -417,17 +417,17 @@ class KasyButton extends StatelessWidget {
|
|
|
417
417
|
iconOnlyExtent: 40,
|
|
418
418
|
horizontalPadding: EdgeInsets.symmetric(horizontal: 16),
|
|
419
419
|
labelFontSize: 13,
|
|
420
|
-
iconSize:
|
|
421
|
-
iconOnlyGlyphSize:
|
|
420
|
+
iconSize: KasyIconSize.sm,
|
|
421
|
+
iconOnlyGlyphSize: KasyIconSize.xxs,
|
|
422
422
|
loadingSpinnerExtent: 13,
|
|
423
423
|
),
|
|
424
424
|
KasyButtonSize.medium => const _KasyButtonMetrics(
|
|
425
|
-
height:
|
|
426
|
-
iconOnlyExtent:
|
|
425
|
+
height: 45,
|
|
426
|
+
iconOnlyExtent: 45,
|
|
427
427
|
horizontalPadding: EdgeInsets.symmetric(horizontal: 18),
|
|
428
428
|
labelFontSize: 14,
|
|
429
|
-
iconSize:
|
|
430
|
-
iconOnlyGlyphSize:
|
|
429
|
+
iconSize: KasyIconSize.md,
|
|
430
|
+
iconOnlyGlyphSize: KasyIconSize.xs,
|
|
431
431
|
loadingSpinnerExtent: 14,
|
|
432
432
|
),
|
|
433
433
|
KasyButtonSize.large => const _KasyButtonMetrics(
|
|
@@ -435,8 +435,8 @@ class KasyButton extends StatelessWidget {
|
|
|
435
435
|
iconOnlyExtent: 54,
|
|
436
436
|
horizontalPadding: EdgeInsets.symmetric(horizontal: 22),
|
|
437
437
|
labelFontSize: 15,
|
|
438
|
-
iconSize:
|
|
439
|
-
iconOnlyGlyphSize:
|
|
438
|
+
iconSize: KasyIconSize.md,
|
|
439
|
+
iconOnlyGlyphSize: KasyIconSize.md,
|
|
440
440
|
loadingSpinnerExtent: 15,
|
|
441
441
|
),
|
|
442
442
|
};
|
|
@@ -36,7 +36,7 @@ class KasyChip extends StatelessWidget {
|
|
|
36
36
|
final KasyColors c = context.colors;
|
|
37
37
|
final Widget? avatar = icon == null
|
|
38
38
|
? null
|
|
39
|
-
: Icon(icon, size:
|
|
39
|
+
: Icon(icon, size: KasyIconSize.md, color: enabled ? c.primary : c.muted);
|
|
40
40
|
final OutlinedBorder chipShape = RoundedRectangleBorder(
|
|
41
41
|
borderRadius: BorderRadius.circular(KasyRadius.full),
|
|
42
42
|
side: BorderSide(color: c.outline.withValues(alpha: 0.45)),
|
|
@@ -552,11 +552,13 @@ class _KasyDatePickerState extends State<KasyDatePicker>
|
|
|
552
552
|
// Key on the trigger field — used to measure its width when opening the popover.
|
|
553
553
|
final GlobalKey _fieldKey = GlobalKey();
|
|
554
554
|
|
|
555
|
-
//
|
|
556
|
-
//
|
|
557
|
-
//
|
|
558
|
-
//
|
|
559
|
-
|
|
555
|
+
// Drives the trigger's "active" (focused-look) border while the calendar or
|
|
556
|
+
// overlay is open. We deliberately do NOT focus the field for this: real
|
|
557
|
+
// focus would (a) light the wrapping KasyFocusRing's keyboard ring on a mouse
|
|
558
|
+
// click — a second outline — and (b) get stolen by the overlay after a
|
|
559
|
+
// moment, dropping the border. KasyTextField.forceFocusBorder paints it from
|
|
560
|
+
// this flag instead, so it stays put for as long as the calendar is open.
|
|
561
|
+
bool _triggerActive = false;
|
|
560
562
|
|
|
561
563
|
// Controller backing the trigger KasyTextField — text mirrors the formatted
|
|
562
564
|
// date (or stays empty so the hint renders).
|
|
@@ -639,7 +641,6 @@ class _KasyDatePickerState extends State<KasyDatePicker>
|
|
|
639
641
|
@override
|
|
640
642
|
void dispose() {
|
|
641
643
|
_displayController.dispose();
|
|
642
|
-
_fieldFocusNode.dispose();
|
|
643
644
|
_animCtrl.dispose();
|
|
644
645
|
super.dispose();
|
|
645
646
|
}
|
|
@@ -741,11 +742,12 @@ class _KasyDatePickerState extends State<KasyDatePicker>
|
|
|
741
742
|
|
|
742
743
|
_portalController.show();
|
|
743
744
|
_animCtrl.forward();
|
|
744
|
-
//
|
|
745
|
-
//
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
745
|
+
// Paint the trigger's "active" border while the calendar is open — driven
|
|
746
|
+
// by state, not real focus (see _triggerActive).
|
|
747
|
+
setState(() {
|
|
748
|
+
_isOpen = true;
|
|
749
|
+
_triggerActive = true;
|
|
750
|
+
});
|
|
749
751
|
}
|
|
750
752
|
|
|
751
753
|
void _close() {
|
|
@@ -765,7 +767,7 @@ class _KasyDatePickerState extends State<KasyDatePicker>
|
|
|
765
767
|
_pendingPopoverRange = null;
|
|
766
768
|
widget.onRangeChanged?.call(pendingRange);
|
|
767
769
|
}
|
|
768
|
-
|
|
770
|
+
setState(() => _triggerActive = false);
|
|
769
771
|
});
|
|
770
772
|
setState(() => _isOpen = false);
|
|
771
773
|
}
|
|
@@ -830,7 +832,7 @@ class _KasyDatePickerState extends State<KasyDatePicker>
|
|
|
830
832
|
// endpoint right away).
|
|
831
833
|
DateTime dialogViewMonth = _viewMonth;
|
|
832
834
|
KasyDateRange? dialogRange = _effectiveRange;
|
|
833
|
-
|
|
835
|
+
setState(() => _triggerActive = true);
|
|
834
836
|
|
|
835
837
|
showKasyDialog<void>(
|
|
836
838
|
context: context,
|
|
@@ -904,12 +906,12 @@ class _KasyDatePickerState extends State<KasyDatePicker>
|
|
|
904
906
|
},
|
|
905
907
|
),
|
|
906
908
|
).whenComplete(() {
|
|
907
|
-
if (mounted)
|
|
909
|
+
if (mounted) setState(() => _triggerActive = false);
|
|
908
910
|
});
|
|
909
911
|
}
|
|
910
912
|
|
|
911
913
|
void _openBottomSheet() {
|
|
912
|
-
|
|
914
|
+
setState(() => _triggerActive = true);
|
|
913
915
|
showKasyBottomSheet<void>(
|
|
914
916
|
context: context,
|
|
915
917
|
isScrollControlled: true,
|
|
@@ -925,7 +927,7 @@ class _KasyDatePickerState extends State<KasyDatePicker>
|
|
|
925
927
|
monthsToShow: widget.monthsToShow,
|
|
926
928
|
),
|
|
927
929
|
).whenComplete(() {
|
|
928
|
-
if (mounted)
|
|
930
|
+
if (mounted) setState(() => _triggerActive = false);
|
|
929
931
|
});
|
|
930
932
|
}
|
|
931
933
|
|
|
@@ -1050,15 +1052,18 @@ class _KasyDatePickerState extends State<KasyDatePicker>
|
|
|
1050
1052
|
child: KasyTextField(
|
|
1051
1053
|
key: _fieldKey,
|
|
1052
1054
|
controller: _displayController,
|
|
1053
|
-
focusNode: _fieldFocusNode,
|
|
1054
1055
|
readOnly: true,
|
|
1055
1056
|
enabled: widget.enabled,
|
|
1056
1057
|
hint: _resolvedPlaceholder,
|
|
1057
1058
|
isInvalid: hasInvalidState,
|
|
1058
1059
|
variant: widget.variant,
|
|
1059
1060
|
focusBorder: widget.focusBorder,
|
|
1060
|
-
//
|
|
1061
|
-
//
|
|
1061
|
+
// "Active" border while the calendar is open — painted from
|
|
1062
|
+
// state, not real focus, so it never doubles the wrapping
|
|
1063
|
+
// KasyFocusRing's keyboard ring on click and never gets
|
|
1064
|
+
// dropped when the overlay steals focus.
|
|
1065
|
+
forceFocusBorder: widget.focusBorder && _triggerActive,
|
|
1066
|
+
// No caret, no selection handles, no "blue text" — keeps the
|
|
1062
1067
|
// field reading as a button, not an editable input.
|
|
1063
1068
|
enableInteractiveSelection: false,
|
|
1064
1069
|
suffix: widget.showSuffix
|
|
@@ -1691,7 +1696,7 @@ class _CalendarNavRow extends StatelessWidget {
|
|
|
1691
1696
|
viewMode == _CalendarViewMode.month
|
|
1692
1697
|
? KasyIcons.chevronRight
|
|
1693
1698
|
: KasyIcons.chevronDown,
|
|
1694
|
-
size:
|
|
1699
|
+
size: KasyIconSize.md,
|
|
1695
1700
|
weight: 700,
|
|
1696
1701
|
color: c.primary,
|
|
1697
1702
|
),
|
|
@@ -1766,7 +1771,7 @@ class _NavArrowButton extends StatelessWidget {
|
|
|
1766
1771
|
child: Center(
|
|
1767
1772
|
child: Icon(
|
|
1768
1773
|
icon,
|
|
1769
|
-
size:
|
|
1774
|
+
size: KasyIconSize.lg,
|
|
1770
1775
|
weight: 700,
|
|
1771
1776
|
color: disabled ? c.muted.withValues(alpha: 0.45) : c.primary,
|
|
1772
1777
|
),
|
|
@@ -144,7 +144,7 @@ class KasyDialog extends StatelessWidget {
|
|
|
144
144
|
padding: const EdgeInsets.all(6),
|
|
145
145
|
minimumSize: const Size(40, 40),
|
|
146
146
|
),
|
|
147
|
-
icon: const Icon(KasyIcons.close, size:
|
|
147
|
+
icon: const Icon(KasyIcons.close, size: KasyIconSize.md),
|
|
148
148
|
),
|
|
149
149
|
);
|
|
150
150
|
}
|
|
@@ -449,7 +449,7 @@ class _DialogIconBubble extends StatelessWidget {
|
|
|
449
449
|
width: _size,
|
|
450
450
|
height: _size,
|
|
451
451
|
decoration: BoxDecoration(color: p.background, shape: BoxShape.circle),
|
|
452
|
-
child: Icon(icon, size:
|
|
452
|
+
child: Icon(icon, size: KasyIconSize.lg, color: p.foreground),
|
|
453
453
|
);
|
|
454
454
|
}
|
|
455
455
|
|
|
@@ -1227,7 +1227,7 @@ class _ProHoverPopupIconState extends State<_ProHoverPopupIcon> {
|
|
|
1227
1227
|
color: widget.iconBg,
|
|
1228
1228
|
borderRadius: BorderRadius.circular(_kItemRadius),
|
|
1229
1229
|
),
|
|
1230
|
-
child: Icon(widget.icon, size:
|
|
1230
|
+
child: Icon(widget.icon, size: KasyIconSize.lg, color: widget.iconColor),
|
|
1231
1231
|
),
|
|
1232
1232
|
),
|
|
1233
1233
|
),
|
|
@@ -1365,7 +1365,7 @@ class _ProTooltipIconState extends State<_ProTooltipIcon> {
|
|
|
1365
1365
|
color: widget.iconBg,
|
|
1366
1366
|
borderRadius: BorderRadius.circular(_kItemRadius),
|
|
1367
1367
|
),
|
|
1368
|
-
child: Icon(widget.icon, size:
|
|
1368
|
+
child: Icon(widget.icon, size: KasyIconSize.lg, color: widget.iconColor),
|
|
1369
1369
|
),
|
|
1370
1370
|
),
|
|
1371
1371
|
),
|
|
@@ -486,7 +486,7 @@ class _PrimaryTabState extends State<_PrimaryTab> {
|
|
|
486
486
|
opacity: disabled ? 0.4 : 1.0,
|
|
487
487
|
child: Icon(
|
|
488
488
|
item.icon,
|
|
489
|
-
size:
|
|
489
|
+
size: KasyIconSize.sm,
|
|
490
490
|
color: fg,
|
|
491
491
|
),
|
|
492
492
|
)
|
|
@@ -628,7 +628,7 @@ class _SecondaryTabState extends State<_SecondaryTab> {
|
|
|
628
628
|
opacity: disabled ? 0.4 : 1.0,
|
|
629
629
|
child: Icon(
|
|
630
630
|
item.icon,
|
|
631
|
-
size:
|
|
631
|
+
size: KasyIconSize.sm,
|
|
632
632
|
color: fg,
|
|
633
633
|
),
|
|
634
634
|
),
|