kasy-cli 1.37.1 → 1.39.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 (120) hide show
  1. package/lib/scaffold/CHANGELOG.json +23 -0
  2. package/lib/scaffold/backends/api/patch/README.md +15 -0
  3. package/lib/scaffold/backends/api/patch/lib/core/data/api/user_api.dart +18 -0
  4. package/lib/scaffold/backends/api/patch/lib/features/notifications/api/device_api.dart +11 -6
  5. package/lib/scaffold/backends/api/patch/lib/features/settings/ui/components/admin/admin_users_api.dart +9 -1
  6. package/lib/scaffold/backends/api/pubspec.yaml.tpl +1 -0
  7. package/lib/scaffold/backends/patch-base-hashes.json +6 -6
  8. package/lib/scaffold/backends/supabase/edge-functions/admin-list-users/index.ts +3 -1
  9. package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/index.ts +3 -0
  10. package/lib/scaffold/backends/supabase/migrations/20240101000012_welcome_decouple_from_push.sql +62 -0
  11. package/lib/scaffold/backends/supabase/patch/lib/core/data/api/user_api.dart +8 -0
  12. package/lib/scaffold/backends/supabase/patch/lib/features/notifications/api/device_api.dart +11 -6
  13. package/lib/scaffold/backends/supabase/patch/lib/features/settings/ui/components/admin/admin_users_api.dart +7 -0
  14. package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +1 -0
  15. package/lib/scaffold/shared/generator-utils.js +12 -6
  16. package/package.json +1 -1
  17. package/templates/firebase/.firebase/hosting.YnVpbGQvd2Vi.cache +23 -23
  18. package/templates/firebase/AGENTS.md +7 -1
  19. package/templates/firebase/DESIGN_SYSTEM.md +35 -8
  20. package/templates/firebase/assets/icons/apple_black.svg +3 -0
  21. package/templates/firebase/assets/icons/apple_white.svg +4 -0
  22. package/templates/firebase/assets/icons/facebook.svg +49 -0
  23. package/templates/firebase/assets/icons/google.svg +1 -0
  24. package/templates/firebase/functions/src/admin/functions.ts +2 -0
  25. package/templates/firebase/functions/src/authentication/functions.ts +13 -7
  26. package/templates/firebase/functions/src/notifications/triggers.ts +6 -2
  27. package/templates/firebase/lib/components/components.dart +1 -1
  28. package/templates/firebase/lib/components/kasy_app_bar.dart +361 -20
  29. package/templates/firebase/lib/components/kasy_bottom_sheet.dart +283 -66
  30. package/templates/firebase/lib/components/kasy_card.dart +4 -0
  31. package/templates/firebase/lib/components/kasy_date_picker.dart +61 -46
  32. package/templates/firebase/lib/components/kasy_drop_down.dart +584 -0
  33. package/templates/firebase/lib/components/kasy_sidebar.dart +412 -31
  34. package/templates/firebase/lib/components/kasy_tabs.dart +31 -10
  35. package/templates/firebase/lib/components/kasy_text_field.dart +29 -7
  36. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +29 -231
  37. package/templates/firebase/lib/core/bottom_menu/sidebar_focus.dart +224 -0
  38. package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +19 -9
  39. package/templates/firebase/lib/core/chrome/app_bar_config.dart +214 -0
  40. package/templates/firebase/lib/core/chrome/app_bar_scope.dart +102 -0
  41. package/templates/firebase/lib/core/data/api/user_api.dart +15 -0
  42. package/templates/firebase/lib/core/data/repositories/user_repository.dart +5 -0
  43. package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +525 -65
  44. package/templates/firebase/lib/core/dev_inspector/dev_inspector_info.dart +47 -0
  45. package/templates/firebase/lib/core/dev_inspector/dev_inspector_service.dart +55 -15
  46. package/templates/firebase/lib/core/icons/kasy_icons.dart +16 -1
  47. package/templates/firebase/lib/core/rating/widgets/review_popup.dart +18 -35
  48. package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +11 -0
  49. package/templates/firebase/lib/core/states/logout_action.dart +11 -1
  50. package/templates/firebase/lib/core/states/user_state_notifier.dart +69 -1
  51. package/templates/firebase/lib/core/theme/texts.dart +21 -6
  52. package/templates/firebase/lib/core/theme/type_scale.dart +34 -15
  53. package/templates/firebase/lib/core/theme/universal_theme.dart +9 -0
  54. package/templates/firebase/lib/core/web_device_preview/png_clipboard.dart +14 -0
  55. package/templates/firebase/lib/core/web_device_preview/png_clipboard_io.dart +9 -0
  56. package/templates/firebase/lib/core/web_device_preview/png_clipboard_web.dart +36 -0
  57. package/templates/firebase/lib/core/web_device_preview/png_export_result.dart +2 -0
  58. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +547 -483
  59. package/templates/firebase/lib/core/web_viewport_scale.dart +64 -35
  60. package/templates/firebase/lib/core/widgets/kasy_pressable_depth.dart +14 -3
  61. package/templates/firebase/lib/core/widgets/responsive_layout.dart +8 -0
  62. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_composer.dart +1 -1
  63. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_conversation_view.dart +52 -35
  64. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_list.dart +1 -1
  65. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +18 -8
  66. package/templates/firebase/lib/features/authentication/ui/signin_page.dart +11 -61
  67. package/templates/firebase/lib/features/authentication/ui/signup_page.dart +11 -61
  68. package/templates/firebase/lib/features/authentication/ui/widgets/auth_card_scaffold.dart +7 -5
  69. package/templates/firebase/lib/features/authentication/ui/widgets/social_auth_tile.dart +83 -0
  70. package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +4 -4
  71. package/templates/firebase/lib/features/home/home_components_page.dart +264 -126
  72. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +231 -57
  73. package/templates/firebase/lib/features/home/home_feed.dart +2 -2
  74. package/templates/firebase/lib/features/home/home_image_grid.dart +3 -3
  75. package/templates/firebase/lib/features/local_reminders/providers/reminder_notifier.dart +8 -1
  76. package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +118 -57
  77. package/templates/firebase/lib/features/notifications/api/device_api.dart +11 -3
  78. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +19 -4
  79. package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +7 -56
  80. package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +5 -5
  81. package/templates/firebase/lib/features/notifications/ui/widgets/push_permission_banner.dart +163 -0
  82. package/templates/firebase/lib/features/notifications/ui/widgets/web_notifications_bell.dart +262 -0
  83. package/templates/firebase/lib/features/onboarding/providers/onboarding_model.dart +9 -0
  84. package/templates/firebase/lib/features/onboarding/providers/onboarding_provider.dart +28 -0
  85. package/templates/firebase/lib/features/onboarding/ui/onboarding_page.dart +51 -12
  86. package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +90 -32
  87. package/templates/firebase/lib/features/settings/settings_page.dart +99 -65
  88. package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +1379 -422
  89. package/templates/firebase/lib/features/settings/ui/components/admin/admin_routes.dart +14 -4
  90. package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_api.dart +7 -0
  91. package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_tab.dart +445 -233
  92. package/templates/firebase/lib/features/settings/ui/components/admin/send_push_notification_page.dart +404 -149
  93. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +24 -31
  94. package/templates/firebase/lib/features/settings/ui/components/delete_user_component.dart +9 -1
  95. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +77 -95
  96. package/templates/firebase/lib/features/settings/ui/widgets/settings_bottom_sheet_option_tile.dart +21 -18
  97. package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +25 -10
  98. package/templates/firebase/lib/i18n/en.i18n.json +749 -698
  99. package/templates/firebase/lib/i18n/es.i18n.json +749 -698
  100. package/templates/firebase/lib/i18n/pt.i18n.json +749 -698
  101. package/templates/firebase/lib/main.dart +20 -7
  102. package/templates/firebase/lib/router.dart +70 -46
  103. package/templates/firebase/pubspec.yaml +2 -1
  104. package/templates/firebase/test/admin_shell_chrome_test.dart +110 -0
  105. package/templates/firebase/test/components/kasy_text_field_height_test.dart +77 -0
  106. package/templates/firebase/test/core/web_viewport_scale_test.dart +23 -16
  107. package/templates/firebase/test/features/authentication/data/api/user_api_fake.dart +3 -0
  108. package/templates/firebase/tool/design_check.dart +9 -0
  109. package/templates/firebase/assets/icons/apple.png +0 -0
  110. package/templates/firebase/assets/icons/facebook.png +0 -0
  111. package/templates/firebase/assets/icons/google.png +0 -0
  112. package/templates/firebase/assets/icons/google_play_games.png +0 -0
  113. package/templates/firebase/lib/components/kasy_web_header.dart +0 -210
  114. package/templates/firebase/lib/features/authentication/ui/components/apple_signin.dart +0 -19
  115. package/templates/firebase/lib/features/authentication/ui/components/google_signin.dart +0 -32
  116. package/templates/firebase/lib/features/authentication/ui/widgets/round_signin.dart +0 -73
  117. package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +0 -66
  118. package/templates/firebase/lib/features/notifications/ui/components/notification_settings_sheet.dart +0 -169
  119. package/templates/firebase/lib/features/notifications/ui/components/push_notification_switcher.dart +0 -106
  120. package/templates/firebase/lib/features/settings/ui/components/admin/admin_paywalls.dart +0 -53
@@ -1,19 +1,31 @@
1
1
  import 'package:flutter/material.dart';
2
2
  import 'package:flutter_riverpod/flutter_riverpod.dart';
3
- import 'package:go_router/go_router.dart';
4
- import 'package:kasy_kit/components/kasy_app_bar.dart';
5
3
  import 'package:kasy_kit/components/kasy_button.dart';
4
+ import 'package:kasy_kit/components/kasy_drop_down.dart';
5
+ import 'package:kasy_kit/components/kasy_tabs.dart';
6
6
  import 'package:kasy_kit/components/kasy_text_area.dart';
7
7
  import 'package:kasy_kit/components/kasy_text_field.dart';
8
+ import 'package:kasy_kit/core/config/features.dart';
8
9
  import 'package:kasy_kit/core/theme/theme.dart';
9
10
  import 'package:kasy_kit/core/toast/toast_service.dart';
10
11
  import 'package:kasy_kit/core/widgets/kasy_scroll_behavior.dart';
11
12
  import 'package:kasy_kit/features/notifications/api/notifications_api.dart';
12
13
  import 'package:kasy_kit/features/settings/ui/components/admin/send_push_notifier.dart';
13
- import 'package:kasy_kit/features/settings/ui/widgets/settings_tile.dart';
14
14
  import 'package:kasy_kit/i18n/translations.g.dart';
15
15
  import 'package:package_info_plus/package_info_plus.dart';
16
16
 
17
+ /// Same canonical pattern the auth `Email` value object validates against — an
18
+ /// address must be well-formed before it can join the recipient list.
19
+ final RegExp _emailPattern = RegExp(
20
+ r'^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$',
21
+ );
22
+
23
+ bool _isValidEmail(String value) => _emailPattern.hasMatch(value.trim());
24
+
25
+ /// Default push destination: the in-app notifications list. Mirrors the
26
+ /// fallback in `Notification.onTap` (no route sent → `/notifications`).
27
+ const String _defaultRoute = '/notifications';
28
+
17
29
  class SendPushNotificationPage extends ConsumerStatefulWidget {
18
30
  const SendPushNotificationPage({super.key});
19
31
 
@@ -27,7 +39,6 @@ class _SendPushNotificationPageState
27
39
  final _titleCtrl = TextEditingController();
28
40
  final _bodyCtrl = TextEditingController();
29
41
  final _imageCtrl = TextEditingController();
30
- final _routeCtrl = TextEditingController();
31
42
  final _emailCtrl = TextEditingController();
32
43
 
33
44
  // Explicit FocusNodes for all fields keeps the keyboard open when switching.
@@ -35,12 +46,16 @@ class _SendPushNotificationPageState
35
46
  final _titleFocus = FocusNode();
36
47
  final _bodyFocus = FocusNode();
37
48
  final _imageFocus = FocusNode();
38
- final _routeFocus = FocusNode();
39
49
 
40
50
  bool _sendToAll = false;
41
51
  final List<String> _emails = [];
42
52
  String _appName = '';
43
53
 
54
+ /// Page the app opens when the user taps the notification. Defaults to the
55
+ /// notifications list (the same fallback `Notification.onTap` uses when no
56
+ /// route is sent), and the admin can repoint it to any available page.
57
+ String _route = _defaultRoute;
58
+
44
59
  @override
45
60
  void initState() {
46
61
  super.initState();
@@ -54,21 +69,26 @@ class _SendPushNotificationPageState
54
69
  _titleCtrl.dispose();
55
70
  _bodyCtrl.dispose();
56
71
  _imageCtrl.dispose();
57
- _routeCtrl.dispose();
58
72
  _emailCtrl.dispose();
59
73
  _emailFocus.dispose();
60
74
  _titleFocus.dispose();
61
75
  _bodyFocus.dispose();
62
76
  _imageFocus.dispose();
63
- _routeFocus.dispose();
64
77
  super.dispose();
65
78
  }
66
79
 
67
- void _addEmail() {
80
+ /// The "add" button stays disabled until the field holds a well-formed,
81
+ /// not-yet-listed e-mail — so it never adds blanks, malformed strings, or
82
+ /// duplicates (the Enter key shares this guard via [_addEmail]).
83
+ bool get _canAddEmail {
68
84
  final email = _emailCtrl.text.trim();
69
- if (email.isEmpty || _emails.contains(email)) return;
85
+ return _isValidEmail(email) && !_emails.contains(email);
86
+ }
87
+
88
+ void _addEmail() {
89
+ if (!_canAddEmail) return;
70
90
  setState(() {
71
- _emails.add(email);
91
+ _emails.add(_emailCtrl.text.trim());
72
92
  _emailCtrl.clear();
73
93
  });
74
94
  _emailFocus.requestFocus();
@@ -78,6 +98,21 @@ class _SendPushNotificationPageState
78
98
  setState(() => _emails.remove(email));
79
99
  }
80
100
 
101
+ /// Clears every field after a successful send. The screen is a console
102
+ /// section (reached from the sidebar), so there is nothing to pop — we reset
103
+ /// in place so the admin can fire another notification right away.
104
+ void _resetForm() {
105
+ _titleCtrl.clear();
106
+ _bodyCtrl.clear();
107
+ _imageCtrl.clear();
108
+ _emailCtrl.clear();
109
+ setState(() {
110
+ _emails.clear();
111
+ _sendToAll = false;
112
+ _route = _defaultRoute;
113
+ });
114
+ }
115
+
81
116
  bool get _canSend {
82
117
  if (_titleCtrl.text.trim().isEmpty) return false;
83
118
  if (_bodyCtrl.text.trim().isEmpty) return false;
@@ -105,9 +140,7 @@ class _SendPushNotificationPageState
105
140
  : _imageCtrl.text.trim(),
106
141
  emails: List.from(_emails),
107
142
  sendToAll: _sendToAll,
108
- route: _routeCtrl.text.trim().isEmpty
109
- ? null
110
- : _routeCtrl.text.trim(),
143
+ route: _route,
111
144
  );
112
145
  }
113
146
 
@@ -128,159 +161,367 @@ class _SendPushNotificationPageState
128
161
  ref
129
162
  .read(toastProvider)
130
163
  .success(title: tr.send_push_title, text: tr.send_push_success);
131
- context.pop();
164
+ _resetForm();
132
165
  }
133
166
  });
134
167
 
135
- final Widget submitButton = _canSend
136
- ? KasyButton(
137
- label: tr.send_push_send_button,
138
- expand: true,
139
- isLoading: state is AsyncLoading,
140
- onPressed: _send,
168
+ Widget buildSubmit({required bool expand}) {
169
+ final KasyButton button = KasyButton(
170
+ label: tr.send_push_send_button,
171
+ expand: expand,
172
+ isLoading: state is AsyncLoading,
173
+ onPressed: _canSend ? _send : null,
174
+ );
175
+ // Disabled: tapping explains what's missing instead of doing nothing.
176
+ return _canSend
177
+ ? button
178
+ : GestureDetector(onTap: _onSendAttempt, child: button);
179
+ }
180
+
181
+ // Recipients: a segmented audience selector (everyone / specific) over the
182
+ // e-mail list, instead of a lone switch — reads as a deliberate choice.
183
+ final Widget recipients = Column(
184
+ crossAxisAlignment: CrossAxisAlignment.stretch,
185
+ mainAxisSize: MainAxisSize.min,
186
+ children: [
187
+ KasyTabs(
188
+ tabs: [tr.send_push_audience_all, tr.send_push_audience_specific],
189
+ selectedIndex: _sendToAll ? 0 : 1,
190
+ onTabSelected: (i) => setState(() => _sendToAll = i == 0),
191
+ mode: KasyTabsMode.fill,
192
+ ),
193
+ const SizedBox(height: KasySpacing.md),
194
+ if (_sendToAll)
195
+ Text(
196
+ tr.send_push_audience_all_hint,
197
+ style: context.textTheme.bodySmall?.copyWith(
198
+ color: context.colors.muted,
199
+ height: 1.35,
200
+ ),
141
201
  )
142
- : GestureDetector(
143
- onTap: _onSendAttempt,
144
- child: KasyButton(
145
- label: tr.send_push_send_button,
146
- expand: true,
147
- onPressed: null,
202
+ else
203
+ _EmailsSection(
204
+ emails: _emails,
205
+ controller: _emailCtrl,
206
+ focusNode: _emailFocus,
207
+ onAdd: _addEmail,
208
+ onRemove: _removeEmail,
209
+ // Rebuild as the admin types so the add button enables/disables live.
210
+ onChanged: () => setState(() {}),
211
+ canAdd: _canAddEmail,
212
+ label: tr.send_push_email_label,
213
+ hint: tr.send_push_email_hint,
214
+ ),
215
+ ],
216
+ );
217
+
218
+ final Widget contentFields = Column(
219
+ mainAxisSize: MainAxisSize.min,
220
+ children: [
221
+ KasyTextField(
222
+ label: tr.send_push_title_label,
223
+ hint: tr.send_push_title_hint,
224
+ controller: _titleCtrl,
225
+ focusNode: _titleFocus,
226
+ maxLength: 64,
227
+ showRequiredIndicator: true,
228
+ // Secondary fill contrasts against the surface-colored section card —
229
+ // the primary (surface) fill would blend white-on-white.
230
+ variant: KasyTextFieldVariant.secondary,
231
+ textInputAction: TextInputAction.next,
232
+ onChanged: (_) => setState(() {}),
233
+ onEditingComplete: () => _bodyFocus.requestFocus(),
234
+ ),
235
+ const SizedBox(height: KasySpacing.md),
236
+ KasyTextArea(
237
+ label: tr.send_push_body_label,
238
+ hint: tr.send_push_body_hint,
239
+ controller: _bodyCtrl,
240
+ focusNode: _bodyFocus,
241
+ maxLength: 250,
242
+ showRequiredIndicator: true,
243
+ variant: KasyTextFieldVariant.secondary,
244
+ minLines: 3,
245
+ maxLines: 5,
246
+ onChanged: (_) => setState(() {}),
247
+ ),
248
+ const SizedBox(height: KasySpacing.md),
249
+ KasyTextField(
250
+ label: tr.send_push_image_label,
251
+ hint: tr.send_push_image_hint,
252
+ controller: _imageCtrl,
253
+ focusNode: _imageFocus,
254
+ variant: KasyTextFieldVariant.secondary,
255
+ onChanged: (_) => setState(() {}),
256
+ // Last text input on the form — close the keyboard on submit.
257
+ textInputAction: TextInputAction.done,
258
+ onEditingComplete: () => _imageFocus.unfocus(),
259
+ ),
260
+ ],
261
+ );
262
+
263
+ // Pick the landing page from the routes this build actually ships (the
264
+ // optional ones are feature-flagged, exactly like the router), instead of
265
+ // typing a raw path. Leaving the default sends the user to /notifications.
266
+ final Widget advancedFields = KasyDropDown<String>(
267
+ label: tr.send_push_route_label,
268
+ description: tr.send_push_route_description,
269
+ value: _route,
270
+ variant: KasyTextFieldVariant.secondary,
271
+ onChanged: (route) => setState(() => _route = route),
272
+ items: <KasyDropDownItem<String>>[
273
+ KasyDropDownItem(
274
+ value: '/notifications',
275
+ label: tr.send_push_route_notifications,
276
+ ),
277
+ KasyDropDownItem(value: '/', label: tr.send_push_route_home),
278
+ KasyDropDownItem(
279
+ value: '/settings',
280
+ label: tr.send_push_route_settings,
281
+ ),
282
+ if (withRevenuecat)
283
+ KasyDropDownItem(
284
+ value: '/premium',
285
+ label: tr.send_push_route_premium,
286
+ ),
287
+ if (withLocalReminders)
288
+ KasyDropDownItem(
289
+ value: '/reminder',
290
+ label: tr.send_push_route_reminder,
291
+ ),
292
+ if (withFeedback)
293
+ KasyDropDownItem(
294
+ value: '/feedback',
295
+ label: tr.send_push_route_feedback,
296
+ ),
297
+ ],
298
+ );
299
+
300
+ final Widget formColumn = Column(
301
+ crossAxisAlignment: CrossAxisAlignment.stretch,
302
+ children: [
303
+ _FormSection(label: tr.send_push_section_recipients, child: recipients),
304
+ const SizedBox(height: KasySpacing.lg),
305
+ _FormSection(label: tr.send_push_section_content, child: contentFields),
306
+ const SizedBox(height: KasySpacing.lg),
307
+ _FormSection(label: tr.send_push_section_advanced, child: advancedFields),
308
+ // Send closes the form (route is optional), so the button flows right
309
+ // after the last section and scrolls with the content — not pinned.
310
+ const SizedBox(height: KasySpacing.lg),
311
+ buildSubmit(expand: true),
312
+ ],
313
+ );
314
+
315
+ final Widget previewPanel = Column(
316
+ crossAxisAlignment: CrossAxisAlignment.stretch,
317
+ mainAxisSize: MainAxisSize.min,
318
+ children: [
319
+ Padding(
320
+ padding: const EdgeInsets.only(
321
+ left: KasySpacing.xs,
322
+ bottom: KasySpacing.smd,
323
+ ),
324
+ child: Text(
325
+ tr.send_push_preview_label.toUpperCase(),
326
+ style: context.kasyTextTheme.sectionLabel.copyWith(
327
+ color: context.colors.muted,
148
328
  ),
149
- );
329
+ ),
330
+ ),
331
+ _NotificationPreview(
332
+ appName: _appName,
333
+ title: _titleCtrl.text,
334
+ body: _bodyCtrl.text,
335
+ imageUrl: _imageCtrl.text,
336
+ ),
337
+ ],
338
+ );
150
339
 
151
- return Scaffold(
152
- // resizeToAvoidBottomInset: true (default) — button rises with keyboard.
153
- body: Stack(
154
- fit: StackFit.expand,
155
- children: [
156
- Positioned.fill(
157
- child: ScrollConfiguration(
158
- behavior: const KasyKitScrollBehavior(),
159
- child: CustomScrollView(
160
- // Drag down to dismiss keyboard the professional standard.
161
- keyboardDismissBehavior:
162
- ScrollViewKeyboardDismissBehavior.onDrag,
163
- slivers: kasyOverlayPaddedSlivers(
164
- context,
165
- contentPadding: EdgeInsets.fromLTRB(
166
- KasySpacing.pageHorizontalGutter,
167
- KasySpacing.belowChromeContentGap,
168
- KasySpacing.pageHorizontalGutter,
169
- MediaQuery.paddingOf(context).bottom +
170
- 52.0 +
171
- KasySpacing.md * 2,
340
+ const double gutter = KasySpacing.pageHorizontalGutter;
341
+
342
+ // Body-only: the admin shell supplies the chrome (web header on desktop,
343
+ // titled app bar on tablet/phone). Wide viewports get two columns (form +
344
+ // a fixed live preview); narrow stacks the preview on top. The send button
345
+ // flows at the end of the form and scrolls with it (not pinned).
346
+ return LayoutBuilder(
347
+ builder: (context, constraints) {
348
+ final bool wide = constraints.maxWidth >= 820;
349
+ // Clear the home indicator / nav bar below the last control.
350
+ final double bottomInset =
351
+ MediaQuery.paddingOf(context).bottom + KasySpacing.xl;
352
+
353
+ SingleChildScrollView scroll({
354
+ required EdgeInsets padding,
355
+ required Widget child,
356
+ }) => SingleChildScrollView(
357
+ // Drag down to dismiss keyboard — the professional standard.
358
+ keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
359
+ padding: padding,
360
+ child: child,
361
+ );
362
+
363
+ // Wide: only the form scrolls; the live preview stays FIXED beside it.
364
+ // Narrow: the preview sits on top and the whole stack scrolls.
365
+ final Widget bodyContent = wide
366
+ ? Center(
367
+ child: ConstrainedBox(
368
+ constraints: const BoxConstraints(maxWidth: 1100),
369
+ child: Row(
370
+ crossAxisAlignment: CrossAxisAlignment.start,
371
+ children: [
372
+ Expanded(
373
+ child: scroll(
374
+ padding: EdgeInsets.fromLTRB(
375
+ gutter,
376
+ KasySpacing.belowChromeContentGap,
377
+ 0,
378
+ bottomInset,
379
+ ),
380
+ child: formColumn,
381
+ ),
382
+ ),
383
+ const SizedBox(width: KasySpacing.xl),
384
+ Padding(
385
+ padding: const EdgeInsets.fromLTRB(
386
+ 0,
387
+ KasySpacing.belowChromeContentGap,
388
+ gutter,
389
+ KasySpacing.xl,
390
+ ),
391
+ child: SizedBox(width: 340, child: previewPanel),
392
+ ),
393
+ ],
172
394
  ),
173
- slivers: [
174
- SliverToBoxAdapter(
395
+ ),
396
+ )
397
+ : Stack(
398
+ children: [
399
+ // The form scrolls; the live preview stays PINNED at the top so
400
+ // the admin watches the notification update while filling the
401
+ // fields. An invisible copy of the preview at the head of the
402
+ // scroll reserves exactly its height, so the first section
403
+ // starts just below the banner and slides BEHIND it on scroll.
404
+ Positioned.fill(
405
+ child: scroll(
406
+ padding: EdgeInsets.fromLTRB(
407
+ gutter,
408
+ KasySpacing.belowChromeContentGap,
409
+ gutter,
410
+ bottomInset,
411
+ ),
175
412
  child: Column(
176
413
  crossAxisAlignment: CrossAxisAlignment.stretch,
177
414
  children: [
178
- Padding(
179
- padding: const EdgeInsets.only(
180
- left: KasySpacing.xs,
181
- bottom: KasySpacing.sm,
182
- ),
183
- child: Text(
184
- tr.send_push_preview_label.toUpperCase(),
185
- style: context.kasyTextTheme.sectionLabel.copyWith(
186
- color: context.colors.muted,
187
- ),
188
- ),
189
- ),
190
- _NotificationPreview(
191
- appName: _appName,
192
- title: _titleCtrl.text,
193
- body: _bodyCtrl.text,
194
- imageUrl: _imageCtrl.text,
415
+ Visibility(
416
+ visible: false,
417
+ maintainSize: true,
418
+ maintainAnimation: true,
419
+ maintainState: true,
420
+ child: previewPanel,
195
421
  ),
196
422
  const SizedBox(height: KasySpacing.lg),
197
- SettingsSwitchTile(
198
- icon: KasyIcons.notificationActive,
199
- title: tr.send_push_to_all,
200
- value: _sendToAll,
201
- onChanged: (v) => setState(() => _sendToAll = v),
202
- ),
203
- const SettingsDivider(),
204
- const SizedBox(height: KasySpacing.lg),
205
- if (!_sendToAll) ...[
206
- _EmailsSection(
207
- emails: _emails,
208
- controller: _emailCtrl,
209
- focusNode: _emailFocus,
210
- onAdd: _addEmail,
211
- onRemove: _removeEmail,
212
- label: tr.send_push_email_label,
213
- hint: tr.send_push_email_hint,
423
+ formColumn,
424
+ ],
425
+ ),
426
+ ),
427
+ ),
428
+ // Pinned banner: an opaque page-colored band (so the scrolling
429
+ // form disappears behind it) with a short fade at its bottom
430
+ // edge. IgnorePointer lets drags over the preview still scroll
431
+ // the form — the preview itself has no interaction.
432
+ Positioned(
433
+ top: 0,
434
+ left: 0,
435
+ right: 0,
436
+ child: IgnorePointer(
437
+ child: Column(
438
+ mainAxisSize: MainAxisSize.min,
439
+ children: [
440
+ Container(
441
+ color: context.colors.background,
442
+ padding: const EdgeInsets.fromLTRB(
443
+ gutter,
444
+ KasySpacing.belowChromeContentGap,
445
+ gutter,
446
+ 0,
214
447
  ),
215
- const SizedBox(height: KasySpacing.lg),
216
- ],
217
- KasyTextField(
218
- label: tr.send_push_title_label,
219
- hint: tr.send_push_title_hint,
220
- controller: _titleCtrl,
221
- focusNode: _titleFocus,
222
- maxLength: 64,
223
- showRequiredIndicator: true,
224
- textInputAction: TextInputAction.next,
225
- onChanged: (_) => setState(() {}),
226
- onEditingComplete: () => _bodyFocus.requestFocus(),
227
- ),
228
- const SizedBox(height: KasySpacing.md),
229
- KasyTextArea(
230
- label: tr.send_push_body_label,
231
- hint: tr.send_push_body_hint,
232
- controller: _bodyCtrl,
233
- focusNode: _bodyFocus,
234
- maxLength: 250,
235
- showRequiredIndicator: true,
236
- minLines: 3,
237
- maxLines: 5,
238
- onChanged: (_) => setState(() {}),
239
- ),
240
- const SizedBox(height: KasySpacing.md),
241
- KasyTextField(
242
- label: tr.send_push_image_label,
243
- hint: tr.send_push_image_hint,
244
- controller: _imageCtrl,
245
- focusNode: _imageFocus,
246
- onChanged: (_) => setState(() {}),
247
- textInputAction: TextInputAction.next,
248
- onEditingComplete: () => _routeFocus.requestFocus(),
448
+ child: previewPanel,
249
449
  ),
250
- const SizedBox(height: KasySpacing.md),
251
- KasyTextField(
252
- label: tr.send_push_route_label,
253
- hint: tr.send_push_route_hint,
254
- controller: _routeCtrl,
255
- focusNode: _routeFocus,
256
- textInputAction: TextInputAction.done,
257
- onEditingComplete: () => _routeFocus.unfocus(),
450
+ Container(
451
+ height: KasySpacing.md,
452
+ decoration: BoxDecoration(
453
+ gradient: LinearGradient(
454
+ begin: Alignment.topCenter,
455
+ end: Alignment.bottomCenter,
456
+ colors: [
457
+ context.colors.background,
458
+ context.colors.background.withValues(alpha: 0),
459
+ ],
460
+ ),
461
+ ),
258
462
  ),
259
463
  ],
260
464
  ),
261
465
  ),
262
- ],
263
- ),
264
- ),
466
+ ),
467
+ ],
468
+ );
469
+
470
+ return ScrollConfiguration(
471
+ // Hide the scrollbar: on the two-column layout it lands between the
472
+ // form and the live preview and reads as a stray mark next to the
473
+ // cards. The form is short enough not to need it.
474
+ behavior: const KasyKitScrollBehavior().copyWith(scrollbars: false),
475
+ child: bodyContent,
476
+ );
477
+ },
478
+ );
479
+ }
480
+ }
481
+
482
+ /// A labelled card grouping form fields — the console's clean section look
483
+ /// (uppercase label + surface card with a hairline border and the component
484
+ /// shadow), so Send push reads as the same product as the rest of the admin.
485
+ class _FormSection extends StatelessWidget {
486
+ final String label;
487
+ final Widget child;
488
+ const _FormSection({required this.label, required this.child});
489
+
490
+ @override
491
+ Widget build(BuildContext context) {
492
+ return Column(
493
+ crossAxisAlignment: CrossAxisAlignment.stretch,
494
+ mainAxisSize: MainAxisSize.min,
495
+ children: [
496
+ Padding(
497
+ padding: const EdgeInsets.only(
498
+ left: KasySpacing.xs,
499
+ bottom: KasySpacing.smd,
500
+ ),
501
+ child: Text(
502
+ label.toUpperCase(),
503
+ style: context.kasyTextTheme.sectionLabel.copyWith(
504
+ color: context.colors.muted,
265
505
  ),
266
506
  ),
267
- Positioned(
268
- top: 0,
269
- left: 0,
270
- right: 0,
271
- child: KasyAppBar(
272
- title: tr.send_push_title,
273
- onBack: () => context.pop(),
507
+ ),
508
+ DecoratedBox(
509
+ decoration: BoxDecoration(
510
+ color: context.colors.surface,
511
+ borderRadius: BorderRadius.circular(18),
512
+ border: Border.all(
513
+ color: context.colors.outline.withValues(
514
+ alpha: context.isDark ? 0.45 : 0.6,
515
+ ),
274
516
  ),
517
+ boxShadow: [KasyShadows.component(context)],
275
518
  ),
276
- Positioned(
277
- left: KasySpacing.pageHorizontalGutter,
278
- right: KasySpacing.pageHorizontalGutter,
279
- bottom: MediaQuery.paddingOf(context).bottom + KasySpacing.sm,
280
- child: submitButton,
519
+ child: Padding(
520
+ padding: const EdgeInsets.all(KasySpacing.md),
521
+ child: child,
281
522
  ),
282
- ],
283
- ),
523
+ ),
524
+ ],
284
525
  );
285
526
  }
286
527
  }
@@ -291,6 +532,8 @@ class _EmailsSection extends StatelessWidget {
291
532
  final FocusNode focusNode;
292
533
  final VoidCallback onAdd;
293
534
  final void Function(String) onRemove;
535
+ final VoidCallback onChanged;
536
+ final bool canAdd;
294
537
  final String label;
295
538
  final String hint;
296
539
 
@@ -300,6 +543,8 @@ class _EmailsSection extends StatelessWidget {
300
543
  required this.focusNode,
301
544
  required this.onAdd,
302
545
  required this.onRemove,
546
+ required this.onChanged,
547
+ required this.canAdd,
303
548
  required this.label,
304
549
  required this.hint,
305
550
  });
@@ -319,6 +564,9 @@ class _EmailsSection extends StatelessWidget {
319
564
  controller: controller,
320
565
  focusNode: focusNode,
321
566
  showRequiredIndicator: true,
567
+ variant: KasyTextFieldVariant.secondary,
568
+ contentType: KasyTextFieldContentType.email,
569
+ onChanged: (_) => onChanged(),
322
570
  // "next" keeps keyboard open when pressing the action key.
323
571
  textInputAction: TextInputAction.next,
324
572
  onEditingComplete: onAdd,
@@ -328,10 +576,12 @@ class _EmailsSection extends StatelessWidget {
328
576
  Padding(
329
577
  padding: const EdgeInsets.only(bottom: 2),
330
578
  child: KasyButton.iconOnly(
331
- icon: Icons.add,
332
- onPressed: onAdd,
579
+ icon: Icons.add_rounded,
580
+ // Disabled (greyed, non-tappable) until the field holds a valid,
581
+ // non-duplicate e-mail.
582
+ onPressed: canAdd ? onAdd : null,
333
583
  size: KasyButtonSize.small,
334
- variant: KasyButtonVariant.soft,
584
+ iconGlyphSize: 20,
335
585
  ),
336
586
  ),
337
587
  ],
@@ -364,11 +614,16 @@ class _EmailChip extends StatelessWidget {
364
614
 
365
615
  @override
366
616
  Widget build(BuildContext context) {
617
+ // An e-mail is plain data the admin listed, not an action or a status — so
618
+ // it reads as a neutral pill (clean, color used in moderation), not an
619
+ // accent-blue tag. surfaceSecondary contrasts against the section card's
620
+ // surface in both modes; the text uses the normal foreground tone.
367
621
  return Container(
368
622
  padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
369
623
  decoration: BoxDecoration(
370
- color: context.colors.primary.withValues(alpha: 0.10),
624
+ color: context.colors.surfaceSecondary,
371
625
  borderRadius: BorderRadius.circular(20),
626
+ border: Border.all(color: context.colors.border),
372
627
  ),
373
628
  child: Row(
374
629
  mainAxisSize: MainAxisSize.min,
@@ -376,7 +631,7 @@ class _EmailChip extends StatelessWidget {
376
631
  Text(
377
632
  email,
378
633
  style: context.textTheme.bodySmall?.copyWith(
379
- color: context.colors.primary,
634
+ color: context.colors.onSurface,
380
635
  fontWeight: FontWeight.w500,
381
636
  ),
382
637
  ),
@@ -386,7 +641,7 @@ class _EmailChip extends StatelessWidget {
386
641
  child: Icon(
387
642
  Icons.close,
388
643
  size: 13,
389
- color: context.colors.primary.withValues(alpha: 0.7),
644
+ color: context.colors.muted,
390
645
  ),
391
646
  ),
392
647
  ],