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.
- package/bin/kasy.js +42 -0
- package/lib/commands/apple-web.js +222 -0
- package/lib/commands/configure.js +3 -91
- package/lib/commands/doctor.js +20 -0
- package/lib/commands/facebook.js +189 -0
- package/lib/commands/new.js +65 -3
- package/lib/scaffold/CHANGELOG.json +27 -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 +186 -0
- package/lib/scaffold/backends/supabase/deploy.js +92 -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 +22 -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 +34 -3
- package/lib/utils/apple-web.js +147 -0
- package/lib/utils/facebook.js +162 -0
- package/lib/utils/i18n/messages-en.js +64 -0
- package/lib/utils/i18n/messages-es.js +64 -0
- package/lib/utils/i18n/messages-pt.js +64 -0
- package/package.json +2 -2
- package/templates/firebase/AGENTS.md +87 -0
- package/templates/firebase/CLAUDE.md +16 -0
- package/templates/firebase/DESIGN_SYSTEM.md +234 -0
- 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/components.dart +1 -0
- 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 +7 -4
- 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_screen.dart +114 -0
- 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 +39 -70
- package/templates/firebase/lib/components/kasy_web_header.dart +4 -3
- package/templates/firebase/lib/core/chrome/chrome_visibility.dart +22 -0
- package/templates/firebase/lib/core/config/features.dart +18 -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 +46 -124
- 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 +29 -126
- package/templates/firebase/lib/features/ai_chat/ai_chat_page.dart +11 -7
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_composer.dart +21 -0
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_list.dart +1 -1
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +1 -1
- package/templates/firebase/lib/features/authentication/api/authentication_api.dart +61 -0
- 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 +57 -29
- package/templates/firebase/lib/features/authentication/ui/signup_page.dart +47 -25
- 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 +2 -3
- package/templates/firebase/lib/features/home/home_components_page.dart +7 -1
- package/templates/firebase/lib/features/home/home_components_preview_registry.dart +54 -3
- package/templates/firebase/lib/features/home/home_image_grid.dart +1 -1
- package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +165 -209
- package/templates/firebase/lib/features/notifications/ui/components/notification_settings_sheet.dart +2 -2
- package/templates/firebase/lib/features/notifications/ui/components/notification_tile.dart +21 -8
- package/templates/firebase/lib/features/notifications/ui/components/push_notification_switcher.dart +2 -2
- package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +3 -6
- package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +6 -1
- package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +104 -156
- 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_illustration_scaffold.dart +3 -4
- package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_module_mockups.dart +3 -3
- package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +2 -4
- package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +3 -2
- package/templates/firebase/lib/features/settings/settings_page.dart +264 -307
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +17 -8
- package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +4 -4
- 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 +13 -4
- package/templates/firebase/lib/i18n/es.i18n.json +13 -4
- package/templates/firebase/lib/i18n/pt.i18n.json +13 -4
- package/templates/firebase/lib/router.dart +2 -0
- package/templates/firebase/pubspec.yaml +1 -2
- package/templates/firebase/tool/design_check.dart +152 -0
- 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/assets/images/review.png +0 -0
- package/templates/firebase/assets/images/update.png +0 -0
- package/templates/firebase/lib/features/authentication/ui/components/facebook_signin.dart +0 -19
- package/templates/firebase/lib/features/notifications/ui/components/notifications_header.dart +0 -32
- 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
|
-
> **
|
|
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
|
-
> **
|
|
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
|
-
> **
|
|
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
|
-
//
|
|
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
|
);
|
|
@@ -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:
|
|
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,
|
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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)),
|