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.
- package/lib/scaffold/CHANGELOG.json +23 -0
- package/lib/scaffold/backends/api/patch/README.md +15 -0
- package/lib/scaffold/backends/api/patch/lib/core/data/api/user_api.dart +18 -0
- package/lib/scaffold/backends/api/patch/lib/features/notifications/api/device_api.dart +11 -6
- package/lib/scaffold/backends/api/patch/lib/features/settings/ui/components/admin/admin_users_api.dart +9 -1
- package/lib/scaffold/backends/api/pubspec.yaml.tpl +1 -0
- package/lib/scaffold/backends/patch-base-hashes.json +6 -6
- package/lib/scaffold/backends/supabase/edge-functions/admin-list-users/index.ts +3 -1
- package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/index.ts +3 -0
- package/lib/scaffold/backends/supabase/migrations/20240101000012_welcome_decouple_from_push.sql +62 -0
- package/lib/scaffold/backends/supabase/patch/lib/core/data/api/user_api.dart +8 -0
- package/lib/scaffold/backends/supabase/patch/lib/features/notifications/api/device_api.dart +11 -6
- package/lib/scaffold/backends/supabase/patch/lib/features/settings/ui/components/admin/admin_users_api.dart +7 -0
- package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +1 -0
- package/lib/scaffold/shared/generator-utils.js +12 -6
- package/package.json +1 -1
- package/templates/firebase/.firebase/hosting.YnVpbGQvd2Vi.cache +23 -23
- package/templates/firebase/AGENTS.md +7 -1
- package/templates/firebase/DESIGN_SYSTEM.md +35 -8
- package/templates/firebase/assets/icons/apple_black.svg +3 -0
- package/templates/firebase/assets/icons/apple_white.svg +4 -0
- package/templates/firebase/assets/icons/facebook.svg +49 -0
- package/templates/firebase/assets/icons/google.svg +1 -0
- package/templates/firebase/functions/src/admin/functions.ts +2 -0
- package/templates/firebase/functions/src/authentication/functions.ts +13 -7
- package/templates/firebase/functions/src/notifications/triggers.ts +6 -2
- package/templates/firebase/lib/components/components.dart +1 -1
- package/templates/firebase/lib/components/kasy_app_bar.dart +361 -20
- package/templates/firebase/lib/components/kasy_bottom_sheet.dart +283 -66
- package/templates/firebase/lib/components/kasy_card.dart +4 -0
- package/templates/firebase/lib/components/kasy_date_picker.dart +61 -46
- package/templates/firebase/lib/components/kasy_drop_down.dart +584 -0
- package/templates/firebase/lib/components/kasy_sidebar.dart +412 -31
- package/templates/firebase/lib/components/kasy_tabs.dart +31 -10
- package/templates/firebase/lib/components/kasy_text_field.dart +29 -7
- package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +29 -231
- package/templates/firebase/lib/core/bottom_menu/sidebar_focus.dart +224 -0
- package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +19 -9
- package/templates/firebase/lib/core/chrome/app_bar_config.dart +214 -0
- package/templates/firebase/lib/core/chrome/app_bar_scope.dart +102 -0
- package/templates/firebase/lib/core/data/api/user_api.dart +15 -0
- package/templates/firebase/lib/core/data/repositories/user_repository.dart +5 -0
- package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +525 -65
- package/templates/firebase/lib/core/dev_inspector/dev_inspector_info.dart +47 -0
- package/templates/firebase/lib/core/dev_inspector/dev_inspector_service.dart +55 -15
- package/templates/firebase/lib/core/icons/kasy_icons.dart +16 -1
- package/templates/firebase/lib/core/rating/widgets/review_popup.dart +18 -35
- package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +11 -0
- package/templates/firebase/lib/core/states/logout_action.dart +11 -1
- package/templates/firebase/lib/core/states/user_state_notifier.dart +69 -1
- package/templates/firebase/lib/core/theme/texts.dart +21 -6
- package/templates/firebase/lib/core/theme/type_scale.dart +34 -15
- package/templates/firebase/lib/core/theme/universal_theme.dart +9 -0
- package/templates/firebase/lib/core/web_device_preview/png_clipboard.dart +14 -0
- package/templates/firebase/lib/core/web_device_preview/png_clipboard_io.dart +9 -0
- package/templates/firebase/lib/core/web_device_preview/png_clipboard_web.dart +36 -0
- package/templates/firebase/lib/core/web_device_preview/png_export_result.dart +2 -0
- package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +547 -483
- package/templates/firebase/lib/core/web_viewport_scale.dart +64 -35
- package/templates/firebase/lib/core/widgets/kasy_pressable_depth.dart +14 -3
- package/templates/firebase/lib/core/widgets/responsive_layout.dart +8 -0
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_composer.dart +1 -1
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_conversation_view.dart +52 -35
- 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 +18 -8
- package/templates/firebase/lib/features/authentication/ui/signin_page.dart +11 -61
- package/templates/firebase/lib/features/authentication/ui/signup_page.dart +11 -61
- package/templates/firebase/lib/features/authentication/ui/widgets/auth_card_scaffold.dart +7 -5
- package/templates/firebase/lib/features/authentication/ui/widgets/social_auth_tile.dart +83 -0
- package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +4 -4
- package/templates/firebase/lib/features/home/home_components_page.dart +264 -126
- package/templates/firebase/lib/features/home/home_components_preview_registry.dart +231 -57
- package/templates/firebase/lib/features/home/home_feed.dart +2 -2
- package/templates/firebase/lib/features/home/home_image_grid.dart +3 -3
- package/templates/firebase/lib/features/local_reminders/providers/reminder_notifier.dart +8 -1
- package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +118 -57
- package/templates/firebase/lib/features/notifications/api/device_api.dart +11 -3
- package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +19 -4
- package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +7 -56
- package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +5 -5
- package/templates/firebase/lib/features/notifications/ui/widgets/push_permission_banner.dart +163 -0
- package/templates/firebase/lib/features/notifications/ui/widgets/web_notifications_bell.dart +262 -0
- package/templates/firebase/lib/features/onboarding/providers/onboarding_model.dart +9 -0
- package/templates/firebase/lib/features/onboarding/providers/onboarding_provider.dart +28 -0
- package/templates/firebase/lib/features/onboarding/ui/onboarding_page.dart +51 -12
- package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +90 -32
- package/templates/firebase/lib/features/settings/settings_page.dart +99 -65
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +1379 -422
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_routes.dart +14 -4
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_api.dart +7 -0
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_tab.dart +445 -233
- package/templates/firebase/lib/features/settings/ui/components/admin/send_push_notification_page.dart +404 -149
- package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +24 -31
- package/templates/firebase/lib/features/settings/ui/components/delete_user_component.dart +9 -1
- package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +77 -95
- package/templates/firebase/lib/features/settings/ui/widgets/settings_bottom_sheet_option_tile.dart +21 -18
- package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +25 -10
- package/templates/firebase/lib/i18n/en.i18n.json +749 -698
- package/templates/firebase/lib/i18n/es.i18n.json +749 -698
- package/templates/firebase/lib/i18n/pt.i18n.json +749 -698
- package/templates/firebase/lib/main.dart +20 -7
- package/templates/firebase/lib/router.dart +70 -46
- package/templates/firebase/pubspec.yaml +2 -1
- package/templates/firebase/test/admin_shell_chrome_test.dart +110 -0
- package/templates/firebase/test/components/kasy_text_field_height_test.dart +77 -0
- package/templates/firebase/test/core/web_viewport_scale_test.dart +23 -16
- package/templates/firebase/test/features/authentication/data/api/user_api_fake.dart +3 -0
- package/templates/firebase/tool/design_check.dart +9 -0
- package/templates/firebase/assets/icons/apple.png +0 -0
- package/templates/firebase/assets/icons/facebook.png +0 -0
- package/templates/firebase/assets/icons/google.png +0 -0
- package/templates/firebase/assets/icons/google_play_games.png +0 -0
- package/templates/firebase/lib/components/kasy_web_header.dart +0 -210
- package/templates/firebase/lib/features/authentication/ui/components/apple_signin.dart +0 -19
- package/templates/firebase/lib/features/authentication/ui/components/google_signin.dart +0 -32
- package/templates/firebase/lib/features/authentication/ui/widgets/round_signin.dart +0 -73
- package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +0 -66
- package/templates/firebase/lib/features/notifications/ui/components/notification_settings_sheet.dart +0 -169
- package/templates/firebase/lib/features/notifications/ui/components/push_notification_switcher.dart +0 -106
- 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
|
-
|
|
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
|
-
|
|
85
|
+
return _isValidEmail(email) && !_emails.contains(email);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
void _addEmail() {
|
|
89
|
+
if (!_canAddEmail) return;
|
|
70
90
|
setState(() {
|
|
71
|
-
_emails.add(
|
|
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:
|
|
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
|
-
|
|
164
|
+
_resetForm();
|
|
132
165
|
}
|
|
133
166
|
});
|
|
134
167
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
child:
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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.
|
|
332
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
644
|
+
color: context.colors.muted,
|
|
390
645
|
),
|
|
391
646
|
),
|
|
392
647
|
],
|