kasy-cli 1.31.14 → 1.34.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.
Files changed (127) hide show
  1. package/bin/kasy.js +42 -0
  2. package/lib/commands/apple-web.js +222 -0
  3. package/lib/commands/configure.js +3 -91
  4. package/lib/commands/doctor.js +20 -0
  5. package/lib/commands/facebook.js +189 -0
  6. package/lib/commands/new.js +65 -3
  7. package/lib/scaffold/CHANGELOG.json +27 -0
  8. package/lib/scaffold/backends/api/patch/README.md +87 -2
  9. package/lib/scaffold/backends/api/patch/lib/features/authentication/api/authentication_api.dart +34 -0
  10. package/lib/scaffold/backends/api/patch/lib/features/subscriptions/api/stripe_backend_api.dart +12 -2
  11. package/lib/scaffold/backends/firebase/setup-from-scratch.js +186 -0
  12. package/lib/scaffold/backends/supabase/deploy.js +92 -0
  13. package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/README.md +26 -22
  14. package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/index.ts +8 -11
  15. package/lib/scaffold/backends/supabase/edge-functions/stripe-create-checkout-session/index.ts +3 -1
  16. package/lib/scaffold/backends/supabase/edge-functions/stripe-create-portal-session/index.ts +60 -3
  17. package/lib/scaffold/backends/supabase/patch/lib/features/authentication/api/authentication_api.dart +22 -0
  18. package/lib/scaffold/backends/supabase/patch/lib/features/subscriptions/api/stripe_backend_api.dart +12 -2
  19. package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +3 -2
  20. package/lib/scaffold/generate.js +1 -1
  21. package/lib/scaffold/shared/generator-utils.js +34 -3
  22. package/lib/utils/apple-web.js +147 -0
  23. package/lib/utils/facebook.js +162 -0
  24. package/lib/utils/i18n/messages-en.js +64 -0
  25. package/lib/utils/i18n/messages-es.js +64 -0
  26. package/lib/utils/i18n/messages-pt.js +64 -0
  27. package/package.json +2 -2
  28. package/templates/firebase/AGENTS.md +87 -0
  29. package/templates/firebase/CLAUDE.md +16 -0
  30. package/templates/firebase/DESIGN_SYSTEM.md +234 -0
  31. package/templates/firebase/docs/auth-setup.en.md +7 -1
  32. package/templates/firebase/docs/auth-setup.es.md +7 -1
  33. package/templates/firebase/docs/auth-setup.pt.md +7 -1
  34. package/templates/firebase/functions/src/subscriptions/stripe_functions.ts +79 -5
  35. package/templates/firebase/lib/components/components.dart +1 -0
  36. package/templates/firebase/lib/components/kasy_accordion.dart +2 -2
  37. package/templates/firebase/lib/components/kasy_alert.dart +1 -1
  38. package/templates/firebase/lib/components/kasy_app_bar.dart +7 -4
  39. package/templates/firebase/lib/components/kasy_bottom_sheet.dart +1 -1
  40. package/templates/firebase/lib/components/kasy_button.dart +8 -8
  41. package/templates/firebase/lib/components/kasy_chip.dart +1 -1
  42. package/templates/firebase/lib/components/kasy_date_picker.dart +26 -21
  43. package/templates/firebase/lib/components/kasy_dialog.dart +2 -2
  44. package/templates/firebase/lib/components/kasy_screen.dart +114 -0
  45. package/templates/firebase/lib/components/kasy_sidebar.dart +2 -2
  46. package/templates/firebase/lib/components/kasy_tabs.dart +2 -2
  47. package/templates/firebase/lib/components/kasy_text_area.dart +37 -5
  48. package/templates/firebase/lib/components/kasy_text_field.dart +77 -16
  49. package/templates/firebase/lib/components/kasy_toast.dart +39 -70
  50. package/templates/firebase/lib/components/kasy_web_header.dart +4 -3
  51. package/templates/firebase/lib/core/chrome/chrome_visibility.dart +22 -0
  52. package/templates/firebase/lib/core/config/features.dart +18 -0
  53. package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +21 -0
  54. package/templates/firebase/lib/core/rating/widgets/rate_banner.dart +1 -1
  55. package/templates/firebase/lib/core/rating/widgets/review_popup.dart +46 -124
  56. package/templates/firebase/lib/core/theme/icon_sizes.dart +47 -0
  57. package/templates/firebase/lib/core/theme/shadows.dart +13 -0
  58. package/templates/firebase/lib/core/theme/texts.dart +32 -0
  59. package/templates/firebase/lib/core/theme/theme.dart +2 -0
  60. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +3 -0
  61. package/templates/firebase/lib/core/web_viewport_scale.dart +23 -4
  62. package/templates/firebase/lib/core/widgets/update_bottom_sheet.dart +29 -126
  63. package/templates/firebase/lib/features/ai_chat/ai_chat_page.dart +11 -7
  64. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_composer.dart +21 -0
  65. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_list.dart +1 -1
  66. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +1 -1
  67. package/templates/firebase/lib/features/authentication/api/authentication_api.dart +61 -0
  68. package/templates/firebase/lib/features/authentication/ui/components/otp_verification.dart +1 -1
  69. package/templates/firebase/lib/features/authentication/ui/components/phone_input.dart +2 -1
  70. package/templates/firebase/lib/features/authentication/ui/recover_password_page.dart +3 -1
  71. package/templates/firebase/lib/features/authentication/ui/signin_page.dart +57 -29
  72. package/templates/firebase/lib/features/authentication/ui/signup_page.dart +47 -25
  73. package/templates/firebase/lib/features/authentication/ui/widgets/recover_password_result.dart +1 -1
  74. package/templates/firebase/lib/features/feedbacks/ui/feedback_page.dart +1 -1
  75. package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +1 -1
  76. package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +2 -3
  77. package/templates/firebase/lib/features/home/home_components_page.dart +7 -1
  78. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +54 -3
  79. package/templates/firebase/lib/features/home/home_image_grid.dart +1 -1
  80. package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +165 -209
  81. package/templates/firebase/lib/features/notifications/ui/components/notification_settings_sheet.dart +2 -2
  82. package/templates/firebase/lib/features/notifications/ui/components/notification_tile.dart +21 -8
  83. package/templates/firebase/lib/features/notifications/ui/components/push_notification_switcher.dart +2 -2
  84. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +3 -6
  85. package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +6 -1
  86. package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +104 -156
  87. package/templates/firebase/lib/features/notifications/ui/widgets/permission_request_view.dart +1 -1
  88. package/templates/firebase/lib/features/onboarding/ui/components/onboarding_loader.dart +1 -1
  89. package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_illustration_scaffold.dart +3 -4
  90. package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_module_mockups.dart +3 -3
  91. package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +2 -4
  92. package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +3 -2
  93. package/templates/firebase/lib/features/settings/settings_page.dart +264 -307
  94. package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +17 -8
  95. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +4 -4
  96. package/templates/firebase/lib/features/settings/ui/components/edit_name_sheet.dart +115 -0
  97. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +2 -2
  98. package/templates/firebase/lib/features/settings/ui/widgets/settings_bottom_sheet_option_tile.dart +1 -1
  99. package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +13 -5
  100. package/templates/firebase/lib/features/subscriptions/api/stripe_backend_api.dart +12 -2
  101. package/templates/firebase/lib/features/subscriptions/api/stripe_payment_api.dart +7 -1
  102. package/templates/firebase/lib/features/subscriptions/providers/premium_page_provider.dart +11 -3
  103. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_row.dart +1 -1
  104. package/templates/firebase/lib/features/subscriptions/ui/widgets/comparison_table.dart +1 -1
  105. package/templates/firebase/lib/features/subscriptions/ui/widgets/feature_line.dart +2 -2
  106. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_close_button.dart +1 -1
  107. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_feature.dart +1 -1
  108. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_col.dart +3 -3
  109. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_row.dart +1 -1
  110. package/templates/firebase/lib/i18n/en.i18n.json +13 -4
  111. package/templates/firebase/lib/i18n/es.i18n.json +13 -4
  112. package/templates/firebase/lib/i18n/pt.i18n.json +13 -4
  113. package/templates/firebase/lib/router.dart +2 -0
  114. package/templates/firebase/pubspec.yaml +1 -2
  115. package/templates/firebase/tool/design_check.dart +152 -0
  116. package/templates/firebase/web/stripe_success.html +64 -26
  117. package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/animations/movefade_anim.dart +0 -34
  118. package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/components/onboarding_att_setup.dart +0 -67
  119. package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +0 -183
  120. package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/animations/movefade_anim.dart +0 -34
  121. package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/components/onboarding_att_setup.dart +0 -67
  122. package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +0 -183
  123. package/templates/firebase/assets/images/review.png +0 -0
  124. package/templates/firebase/assets/images/update.png +0 -0
  125. package/templates/firebase/lib/features/authentication/ui/components/facebook_signin.dart +0 -19
  126. package/templates/firebase/lib/features/notifications/ui/components/notifications_header.dart +0 -32
  127. package/templates/firebase/login-redesign-preview.png +0 -0
@@ -0,0 +1,234 @@
1
+ # Kasy Design System
2
+
3
+ **Version 1.0.0** · last updated 2026-06-11
4
+
5
+ The single source of truth for how the app looks. Everything here lives in code
6
+ under `lib/core/theme/` and `lib/components/`, so changing a token in one place
7
+ updates the whole app (web, Android, iOS) at once. This document just writes the
8
+ rules down so anyone, including projects generated by the Kasy CLI, inherits the
9
+ same standard without guessing.
10
+
11
+ Design principle: **clean, modern, minimal**. Lots of surface, one accent colour
12
+ used sparingly, no loud weights. The Home screen (sidebar + header + elevated
13
+ `KasyCard`) is the reference for "what good looks like"; new screens should
14
+ mirror it.
15
+
16
+ ---
17
+
18
+ ## The golden rule
19
+
20
+ Use **tokens and roles**, never bespoke values.
21
+
22
+ | Instead of… | Use… |
23
+ | ----------------------------------- | -------------------------------------------- |
24
+ | `fontSize: 16, fontWeight: w600` | `context.textTheme.*` / `KasyTextTheme.*` |
25
+ | `Icon(size: 20)` | `Icon(size: KasyIconSize.lg)` |
26
+ | `EdgeInsets.all(16)` | `EdgeInsets.all(KasySpacing.md)` |
27
+ | `BorderRadius.circular(16)` | `BorderRadius.circular(KasyRadius.lg)` |
28
+ | `Color(0xFF…)` / `Colors.*` | `context.colors.*` |
29
+ | `ElevatedButton`, `TextButton` | `KasyButton` |
30
+ | `AlertDialog`, `SnackBar` | `showKasyConfirmDialog`, `showKasyToast` |
31
+
32
+ A bespoke number is only acceptable when a component is being *calibrated* (the
33
+ sidebar, the date-picker grid, badges) and there is no token that fits. When in
34
+ doubt, add or reuse a role rather than hardcoding.
35
+
36
+ ---
37
+
38
+ ## Typography — `KasyTextTheme` (`lib/core/theme/texts.dart`)
39
+
40
+ ### HeroUI scale (the raw roles, Inter)
41
+
42
+ | Role | Size / weight / line-height |
43
+ | --------------- | --------------------------- |
44
+ | `heading1` | 36 / w800 / 40 |
45
+ | `heading2` | 24 / w700 / 32 |
46
+ | `heading3` | 20 / w600 / 28 |
47
+ | `heading4` | 16 / w600 / 24 |
48
+ | `bodyBase` | 16 / w400 |
49
+ | `bodyBaseMedium`| 16 / w500 |
50
+ | `bodySm` | 14 / w400 |
51
+ | `bodySmMedium` | 14 / w500 |
52
+ | `bodyXs` | 12 / w400 |
53
+ | `bodyXsMedium` | 12 / w500 |
54
+
55
+ ### Semantic app roles (prefer these on screens)
56
+
57
+ | Role | Maps to | Use for |
58
+ | -------------- | ------------------------------ | ----------------------------------------- |
59
+ | `pageTitle` | `heading2` (24/w700) | Screen / page titles, onboarding titles |
60
+ | `sectionTitle` | `heading4` (16/w600) | Detail / section headings |
61
+ | `sectionLabel` | 12 / w600 / letter-spacing 0.5 | Uppercase group eyebrows (lists, settings)|
62
+ | `rowTitle` | `bodySmMedium` (14/w500) | List / settings row titles |
63
+ | `rowValue` | `bodySm` (14/w400) | List / settings row values |
64
+ | `cardTitle` | `bodySmMedium` (14/w500) | Card titles |
65
+ | `cardSubtitle` | `bodyXs` (12/w400) | Card secondary text |
66
+ | `caption` | `bodyXs` (12/w400) | Captions, helper text |
67
+
68
+ Access roles in widgets via `context.textTheme.*` (Material slots, colour
69
+ applied) or `KasyTextTheme.*` (raw role, apply colour with `.copyWith`).
70
+
71
+ Material → role mapping: `headlineMedium = heading2`, `headlineSmall =
72
+ titleLarge = heading3`, `titleMedium = heading4`.
73
+
74
+ The chrome/app-bar title is **w700** (not w900). Black weights read as heavy and
75
+ fight the rest of the UI.
76
+
77
+ ---
78
+
79
+ ## Icon sizes — `KasyIconSize` (`lib/core/theme/icon_sizes.dart`)
80
+
81
+ | Token | px | Token alias | = |
82
+ | ----------- | -- | ------------ | -- |
83
+ | `xxs` | 12 | `rowLeading` | `lg` (20) |
84
+ | `xs` | 14 | `rowTrailing`| `sm` (16) |
85
+ | `sm` | 16 | `chrome` | `lg` (20) |
86
+ | `md` | 18 | `inline` | `md` (18) |
87
+ | `lg` | 20 | | |
88
+ | `xl` | 24 | | |
89
+ | `xxl` | 28 | | |
90
+ | `display` | 36 | | |
91
+ | `hero` | 72 | | |
92
+
93
+ Never pass a numeric `size:` to `Icon`; use a token so re-scaling is one edit.
94
+
95
+ ---
96
+
97
+ ## Spacing — `KasySpacing` (`lib/core/theme/spacing.dart`)
98
+
99
+ `xs 4 · sm 8 · smd 12 · md 16 · lg 24 · xl 32 · xxl 48 · xxxl 64`
100
+
101
+ `pageHorizontalGutter = md (16)` · `pageVerticalGutter = sm (8)` ·
102
+ `belowChromeContentGap = 20`.
103
+
104
+ ## Radius — `KasyRadius` (`lib/core/theme/radius.dart`)
105
+
106
+ Semantic: `xs 4 · sm 8 · md 12 · lg 16 · xl 24 · full 999`.
107
+ `KasyCard` defaults to `lg` (16).
108
+
109
+ ---
110
+
111
+ ## Colours — `KasyColors`, via `context.colors.*`
112
+
113
+ Always pull colours from `context.colors.*` so light/dark resolve automatically.
114
+ Key tokens: `primary`, `onPrimary`, `surface`, `onSurface`, `onBackground`,
115
+ `muted`, `fieldLabel`, `outline`, `error` / `onError`, `success`, `warning`,
116
+ `surfacePrimarySoft`, `surfaceNeutralSoft`, `surfaceErrorSoft`.
117
+
118
+ Brand: avocado green `#D2F51E` (accent), deep green `#011820` (dark ground).
119
+ Disabled state: blend the colour toward the surface (opaque), never raw opacity.
120
+
121
+ ---
122
+
123
+ ## Components
124
+
125
+ ### `KasyButton`
126
+ Sizes (height): `small 40` · `medium 45` (default) · `large 54`. Radius
127
+ `KasyRadius.md`. Variants: primary, secondary, tertiary, destructive,
128
+ destructiveSoft, inverse, link, soft, neutral, outline, ghost.
129
+
130
+ ### `KasyTextField`
131
+ Canonical single-line height: **`singleLineHeight = 45`** (drives the field's
132
+ vertical padding so the box itself is exactly that tall on every platform — web
133
+ density is normalised to `standard`). Matches the medium button.
134
+
135
+ Variants:
136
+ - **primary** — surface fill + hairline border + soft shadow (floats; good on a
137
+ bare background).
138
+ - **secondary** — elevated fill (contrasts on a card) + border, no shadow.
139
+ - **flat** — primary's surface fill + a soft border, **no shadow**. For fields
140
+ sitting on a same-coloured card. Border comes from
141
+ `KasyShadows.inputFieldFlatBorder` (derived from `onSurface`, so it reads in
142
+ both light and dark; subtle by design).
143
+ - **embedded** — transparent, no border, no shadow.
144
+
145
+ Auth screens (sign in / sign up / recover) use **flat** on every breakpoint.
146
+ A custom `contentPadding` opts a field out of the 45 lock (e.g. the compact
147
+ header search). Use `forceFocusBorder` to show the "active" focused border from
148
+ state without taking real focus (composite triggers like the date picker).
149
+
150
+ ### `KasyTextArea`
151
+ Multi-line sibling of `KasyTextField`; same variants (primary / secondary / flat
152
+ / embedded). Height grows with content (not locked to 45).
153
+
154
+ ### `KasyDatePicker`
155
+ Reuses `KasyTextField` as a read-only trigger, so it inherits the field look and
156
+ the 45 height. The "open" border is painted from state (`forceFocusBorder`), so
157
+ a mouse click never doubles the keyboard focus ring and the border never drops
158
+ while the calendar is open.
159
+
160
+ ### `KasyCard`
161
+ Elevated surface panel, radius `KasyRadius.lg` (16). The base for screen content.
162
+
163
+ ### `KasyScreen`
164
+ Opt-in screen scaffold. A new page wrapped in it gets the internal-screen
165
+ contract for free: page background, a centred content column capped at
166
+ `maxContentWidth` (~600), the page gutter, scroll handling and an optional
167
+ `KasyCard`. It is a thin convenience over `Scaffold` (same `appBar` /
168
+ `floatingActionButton` / `bottomNavigationBar` slots), so it never blocks a
169
+ screen that needs bespoke chrome.
170
+
171
+ ```dart
172
+ KasyScreen(
173
+ appBar: KasyAppBar.root(title: 'Settings'),
174
+ card: true,
175
+ child: Column(children: [...]),
176
+ )
177
+ ```
178
+
179
+ ---
180
+
181
+ ## Internal-screen contract
182
+
183
+ New internal screens follow the sidebar / Home ruler:
184
+ - Body text 14, icons 20 (`KasyIconSize.lg`), titles ~16-17 (`sectionTitle`).
185
+ - `KasyCard` radius 16, content width contained (~600), no oversized boxes.
186
+ - Use `KasyButton`, `showKasyConfirmDialog`, `showKasyToast` — not raw Material.
187
+ - Haptics only on native (`if (!kIsWeb)`); "hide chrome on scroll" only on the
188
+ phone breakpoint (`width < 768`).
189
+
190
+ ---
191
+
192
+ ## Keeping it consistent — the guard-rail (optional)
193
+
194
+ `tool/design_check.dart` is a plain Dart script that flags a **feature screen**
195
+ reaching around the design system (raw `fontSize`, raw `Color`, raw Material
196
+ widgets, literal icon sizes). The design system's own code (`lib/components`,
197
+ `lib/core`, `lib/main.dart`) is out of scope — that is where the primitives are
198
+ built.
199
+
200
+ ```bash
201
+ dart run tool/design_check.dart
202
+ ```
203
+
204
+ It is **opt-in and non-blocking by default**: nothing runs it automatically (not
205
+ wired into `flutter analyze`, builds or git hooks). Run it locally or add it to
206
+ CI to turn the convention into a gate. It has no extra dependency — read it, tune
207
+ the allowlists at the top, or delete it. For a deliberate one-off, put
208
+ `// design-check: ignore` on the line.
209
+
210
+ This is intentionally not a cage: a project built with the kit can relax or drop
211
+ it freely, and rebranding is still a token change (colours / type) in one place.
212
+
213
+ ---
214
+
215
+ ## Changelog
216
+
217
+ ### 1.1.0 — 2026-06-11
218
+ - `KasyScreen` scaffold added (opt-in internal-screen contract).
219
+ - Design guard-rail added (`tool/design_check.dart`), opt-in / non-blocking.
220
+ - Feature layer brought to a green baseline against the guard-rail (deliberate
221
+ exceptions marked with `// design-check: ignore`).
222
+
223
+
224
+ ### 1.0.0 — 2026-06-11
225
+ - Icon sizes centralised into `KasyIconSize`; product migrated off numeric sizes.
226
+ - Semantic typography roles added (`pageTitle`, `sectionTitle`, `sectionLabel`,
227
+ `rowTitle`, …).
228
+ - `KasyTextField`: `flat` variant + `forceFocusBorder`; single-line height
229
+ locked to **45** via derived padding (platform-independent); flat border role
230
+ `inputFieldFlatBorder` (visible in both themes, subtle).
231
+ - `KasyButton` medium height aligned to **45**.
232
+ - `KasyTextArea` gained the variant set.
233
+ - App-bar title weight `w900 → w700`; onboarding titles `28/26 → 24` (page-title
234
+ scale); notifications group eyebrow → `sectionLabel`.
@@ -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
- > **Android and Web**: no additional setup needed. The Firebase SDK handles the flow on Android. For Web, the Services ID (Step 3) already covers the redirect flow via `firebaseapp.com`.
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)**: after Steps 1-3, run `kasy apple-web` — it writes the Services ID + Team ID + Key ID + `.p8` into the Firebase Apple provider and turns on `withAppleWebSignin` (which ships `false`). The Services ID Return URL (`firebaseapp.com/__/auth/handler`) covers the popup flow. Firebase re-signs the secret itself (never expires).
77
+ >
78
+ > **Web (Supabase)**: Apple on the web for Supabase is **not wired in the app yet** (roadmap) — the button stays hidden on web. On Supabase, Apple works on **native iOS only** (already set up by `kasy new`). See `ROADMAP.md`.
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
- > **Android y Web**: no se necesita configuración adicional. El SDK de Firebase gestiona el flujo en Android. Para Web, el Services ID (Paso 3) ya cubre el flujo de redirect vía `firebaseapp.com`.
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)**: tras los Pasos 1 a 3, ejecuta `kasy apple-web` — escribe el Services ID + Team ID + Key ID + `.p8` en el proveedor Apple de Firebase y activa `withAppleWebSignin` (que viene `false`). La Return URL del Services ID (`firebaseapp.com/__/auth/handler`) cubre el flujo de popup. Firebase vuelve a firmar el secret solo (no expira).
77
+ >
78
+ > **Web (Supabase)**: Apple en la web para Supabase **aún no viene activado en la app** (roadmap) — el botón queda oculto en la web. En Supabase, Apple funciona solo en **iOS nativo** (ya configurado por `kasy new`). Consulta `ROADMAP.md`.
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
- > **Android e Web**: não precisam de configuração adicional. O Firebase SDK gerencia o fluxo no Android. Para Web, o Services ID (Passo 3) já cobre o fluxo de redirect via `firebaseapp.com`.
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)**: depois dos Passos 1 a 3, rode `kasy apple-web` — ele grava o Services ID + Team ID + Key ID + `.p8` no provedor Apple do Firebase e liga `withAppleWebSignin` (que nasce `false`). A Return URL do Services ID (`firebaseapp.com/__/auth/handler`) cobre o fluxo de popup. O Firebase re-assina o secret sozinho (não expira).
77
+ >
78
+ > **Web (Supabase)**: Apple na web no Supabase **não vem ligado no app ainda** (roadmap) — o botão fica escondido na web. No Supabase, o Apple funciona só no **iOS nativo** (já configurado pelo `kasy new`). Veja `ROADMAP.md`.
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
- // createPortalSessionStripe Customer Portal (manage / cancel).
148
+ // getOrCreatePortalConfigcreates (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?.email,
288
+ email: user.email,
215
289
  },
216
290
  subscriptionsRepository,
217
291
  );
@@ -21,6 +21,7 @@ export 'kasy_date_picker.dart';
21
21
  export 'kasy_dialog.dart';
22
22
  export 'kasy_image_viewer.dart';
23
23
  export 'kasy_otp_verification_bottom_sheet.dart';
24
+ export 'kasy_screen.dart';
24
25
  export 'kasy_sidebar.dart';
25
26
  export 'kasy_skeleton.dart';
26
27
  export 'kasy_status_tag.dart';
@@ -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: 18.5,
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: 18.5,
379
+ size: KasyIconSize.md,
380
380
  color: context.colors.muted,
381
381
  ),
382
382
  );
@@ -232,7 +232,7 @@ class KasyAlertCircleButton extends StatelessWidget {
232
232
  height: 36,
233
233
  child: Icon(
234
234
  icon,
235
- size: 19,
235
+ size: KasyIconSize.md,
236
236
  color: context.colors.onSurface.withValues(alpha: 0.58),
237
237
  ),
238
238
  ),
@@ -292,7 +292,7 @@ class KasyAppBar extends StatelessWidget {
292
292
  ),
293
293
  _ => KasyChromeOrbIconButton(
294
294
  icon: KasyIcons.arrowBackIos,
295
- iconSize: 18,
295
+ iconSize: KasyIconSize.md,
296
296
  foregroundColor: orbFg,
297
297
  fillColor: orbFill,
298
298
  onPressed: handleBack,
@@ -304,7 +304,10 @@ class KasyAppBar extends StatelessWidget {
304
304
  context.textTheme.headlineSmall ?? context.textTheme.titleLarge;
305
305
  final TextStyle? titleStyle = titleRef?.copyWith(
306
306
  fontSize: (titleRef.fontSize ?? 24) * kasyAppBarTitleFontScale,
307
- fontWeight: FontWeight.w900,
307
+ // w700 (not w900): a clean page-title weight that matches the Home /
308
+ // sidebar scale. w900 read as a heavy "black" that fought the rest of
309
+ // the chrome.
310
+ fontWeight: FontWeight.w700,
308
311
  letterSpacing: -0.35,
309
312
  color: context.colors.onBackground,
310
313
  );
@@ -404,7 +407,7 @@ class KasyAppBar extends StatelessWidget {
404
407
  case KasyAppBarStyle.subpage:
405
408
  return KasyChromeOrbIconButton(
406
409
  icon: isDark ? KasyIcons.lightMode : KasyIcons.darkMode,
407
- iconSize: 20,
410
+ iconSize: KasyIconSize.lg,
408
411
  foregroundColor: iconFg,
409
412
  fillColor: orbFill,
410
413
  onPressed: () {
@@ -421,7 +424,7 @@ class KasyAppBar extends StatelessWidget {
421
424
  if (trailing != null) return trailing!;
422
425
  return KasyChromeOrbIconButton(
423
426
  icon: isDark ? KasyIcons.lightMode : KasyIcons.darkMode,
424
- iconSize: 20,
427
+ iconSize: KasyIconSize.lg,
425
428
  foregroundColor: iconFg,
426
429
  fillColor: orbFill,
427
430
  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: 30, color: p.foreground),
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: 16,
421
- iconOnlyGlyphSize: 12,
420
+ iconSize: KasyIconSize.sm,
421
+ iconOnlyGlyphSize: KasyIconSize.xxs,
422
422
  loadingSpinnerExtent: 13,
423
423
  ),
424
424
  KasyButtonSize.medium => const _KasyButtonMetrics(
425
- height: 47,
426
- iconOnlyExtent: 47,
425
+ height: 45,
426
+ iconOnlyExtent: 45,
427
427
  horizontalPadding: EdgeInsets.symmetric(horizontal: 18),
428
428
  labelFontSize: 14,
429
- iconSize: 18,
430
- iconOnlyGlyphSize: 14,
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: 19,
439
- iconOnlyGlyphSize: 18,
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: 18, color: enabled ? c.primary : c.muted);
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)),