kasy-cli 1.38.0 → 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 +14 -0
- package/lib/scaffold/backends/api/patch/README.md +15 -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/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/DESIGN_SYSTEM.md +22 -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 +314 -14
- package/templates/firebase/lib/components/kasy_card.dart +4 -0
- package/templates/firebase/lib/components/kasy_drop_down.dart +584 -0
- package/templates/firebase/lib/components/kasy_sidebar.dart +18 -6
- 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 +27 -18
- package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +12 -10
- 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 +11 -0
- package/templates/firebase/lib/core/dev_inspector/dev_inspector_service.dart +55 -15
- 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 +28 -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/web_device_preview/web_device_preview.dart +51 -19
- 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/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 +253 -125
- 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 +111 -57
- package/templates/firebase/lib/features/notifications/api/device_api.dart +11 -3
- package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +16 -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 +2 -2
- 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 +53 -32
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +895 -111
- 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 +171 -41
- package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +1 -1
- 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 +48 -47
- 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 -712
- package/templates/firebase/lib/i18n/es.i18n.json +749 -712
- package/templates/firebase/lib/i18n/pt.i18n.json +749 -712
- package/templates/firebase/lib/main.dart +20 -7
- package/templates/firebase/lib/router.dart +32 -26
- package/templates/firebase/pubspec.yaml +2 -1
- package/templates/firebase/test/admin_shell_chrome_test.dart +11 -5
- 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/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 -218
- package/templates/firebase/lib/core/chrome/web_header_scope.dart +0 -20
- 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 -179
- package/templates/firebase/lib/features/notifications/ui/components/push_notification_switcher.dart +0 -106
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
2
2
|
import 'package:flutter/material.dart';
|
|
3
3
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
4
|
-
import 'package:go_router/go_router.dart';
|
|
5
4
|
import 'package:kasy_kit/components/components.dart';
|
|
6
5
|
import 'package:kasy_kit/core/data/api/analytics_api.dart';
|
|
7
6
|
import 'package:kasy_kit/core/rating/providers/rating_repository.dart';
|
|
@@ -13,9 +12,10 @@ import 'package:logger/logger.dart';
|
|
|
13
12
|
/// Shows the in-app review dialog. Prefer a stable [context] (not a sheet that
|
|
14
13
|
/// will be popped before the async work finishes).
|
|
15
14
|
///
|
|
16
|
-
/// A clean [KasyDialog]
|
|
17
|
-
///
|
|
18
|
-
///
|
|
15
|
+
/// A clean [KasyDialog] focused on a single goal: a positive store rating. A
|
|
16
|
+
/// gold star, a warm title/message and one action (write a review on the
|
|
17
|
+
/// Android/Play or iOS App Store). Dismissing via the dialog's own close button
|
|
18
|
+
/// just defers the next ask and keeps the user where they are.
|
|
19
19
|
Future<bool> showReviewDialog(
|
|
20
20
|
BuildContext context,
|
|
21
21
|
WidgetRef ref, {
|
|
@@ -46,15 +46,16 @@ Future<bool> showReviewDialog(
|
|
|
46
46
|
return false;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
await showKasyDialog<void>(
|
|
50
50
|
context: context,
|
|
51
|
-
barrierDismissible: false,
|
|
52
51
|
builder: (dialogContext) {
|
|
53
52
|
ratingRepository.delay();
|
|
54
53
|
final translations = Translations.of(dialogContext).review_popup;
|
|
55
54
|
return KasyDialog(
|
|
56
55
|
leadingIcon: KasyIcons.star,
|
|
57
|
-
|
|
56
|
+
// Gold/amber star via KasyDialog's default `warning` tone — the design
|
|
57
|
+
// system's amber (#F5A524 light / #F7B750 dark), the natural colour for
|
|
58
|
+
// a rating star.
|
|
58
59
|
title: translations.title,
|
|
59
60
|
titleCentered: true,
|
|
60
61
|
message: translations.description,
|
|
@@ -63,37 +64,19 @@ Future<bool> showReviewDialog(
|
|
|
63
64
|
rating.delay();
|
|
64
65
|
Navigator.of(dialogContext).pop();
|
|
65
66
|
},
|
|
66
|
-
footer:
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (!dialogContext.mounted) return;
|
|
77
|
-
Navigator.of(dialogContext).pop();
|
|
78
|
-
},
|
|
79
|
-
);
|
|
80
|
-
},
|
|
81
|
-
),
|
|
82
|
-
const SizedBox(height: KasySpacing.sm),
|
|
83
|
-
KasyButton(
|
|
84
|
-
label: translations.cancel_button,
|
|
85
|
-
variant: KasyButtonVariant.soft,
|
|
86
|
-
expand: true,
|
|
87
|
-
onPressed: () => Navigator.of(dialogContext).pop(true),
|
|
88
|
-
),
|
|
89
|
-
],
|
|
67
|
+
footer: KasyButton(
|
|
68
|
+
label: translations.rate_button,
|
|
69
|
+
expand: true,
|
|
70
|
+
onPressed: () {
|
|
71
|
+
analytics.logEvent('rating_popup_show', {});
|
|
72
|
+
ratingRepository.rate().then((_) => rating.review()).then((_) {
|
|
73
|
+
if (!dialogContext.mounted) return;
|
|
74
|
+
Navigator.of(dialogContext).pop();
|
|
75
|
+
});
|
|
76
|
+
},
|
|
90
77
|
),
|
|
91
78
|
);
|
|
92
79
|
},
|
|
93
80
|
);
|
|
94
|
-
|
|
95
|
-
if (openFeedback == true && context.mounted) {
|
|
96
|
-
await context.push('/feedback');
|
|
97
|
-
}
|
|
98
81
|
return true;
|
|
99
82
|
}
|
|
@@ -105,4 +105,15 @@ class SharedPreferencesBuilder implements OnStartService {
|
|
|
105
105
|
Future<void> setAttSoftLastAskedAt(DateTime when) async {
|
|
106
106
|
await prefs.setInt('att_soft_last_asked_at', when.millisecondsSinceEpoch);
|
|
107
107
|
}
|
|
108
|
+
|
|
109
|
+
/// Whether we've already fired the one-time native push-permission prompt the
|
|
110
|
+
/// first time the user opened the notifications screen. iOS only ever shows
|
|
111
|
+
/// the native prompt once, so we must never auto-fire it more than once.
|
|
112
|
+
bool getPushAutoRequested() {
|
|
113
|
+
return prefs.getBool('push_auto_requested') ?? false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
Future<void> setPushAutoRequested(bool value) async {
|
|
117
|
+
await prefs.setBool('push_auto_requested', value);
|
|
118
|
+
}
|
|
108
119
|
}
|
|
@@ -4,6 +4,7 @@ import 'package:kasy_kit/components/components.dart';
|
|
|
4
4
|
import 'package:kasy_kit/core/states/user_state_notifier.dart';
|
|
5
5
|
import 'package:kasy_kit/core/theme/theme.dart';
|
|
6
6
|
import 'package:kasy_kit/i18n/translations.g.dart';
|
|
7
|
+
import 'package:kasy_kit/router.dart';
|
|
7
8
|
|
|
8
9
|
/// Standardized logout flow — the single source of truth for signing out.
|
|
9
10
|
///
|
|
@@ -25,5 +26,14 @@ Future<void> confirmLogout(BuildContext context, WidgetRef ref) {
|
|
|
25
26
|
// forget: that closed the dialog instantly and left the old screen frozen.)
|
|
26
27
|
onConfirmAsync: () =>
|
|
27
28
|
ref.read(userStateNotifierProvider.notifier).onLogout(),
|
|
28
|
-
)
|
|
29
|
+
).whenComplete(() {
|
|
30
|
+
// The confirm dialog is a pageless route on the ROOT navigator, so the
|
|
31
|
+
// redirect that fires when onLogout flips the state to anonymous runs while
|
|
32
|
+
// the dialog still sits on top — go_router keeps the current page (Settings)
|
|
33
|
+
// mounted underneath and the move to /signin never lands. Now that the
|
|
34
|
+
// dialog has popped, re-run the redirect so the (now unauthenticated) user
|
|
35
|
+
// is sent to sign-in. Harmless on cancel: the redirect keeps an
|
|
36
|
+
// authenticated user exactly where they are.
|
|
37
|
+
if (context.mounted) ref.read(goRouterProvider).refresh();
|
|
38
|
+
});
|
|
29
39
|
}
|
|
@@ -187,6 +187,7 @@ class UserStateNotifier extends _$UserStateNotifier implements OnStartService {
|
|
|
187
187
|
// Biometric lock is a per-account preference, not a device-wide one.
|
|
188
188
|
// The next user signing in on this install should start without it set.
|
|
189
189
|
await ref.read(sharedPreferencesProvider).setBiometricEnabled(false);
|
|
190
|
+
await _clearWebGuestPass();
|
|
190
191
|
// Forget the last bottom-bar tab so the next login lands on the default tab
|
|
191
192
|
// (Home) instead of wherever the previous account left off.
|
|
192
193
|
forgetActiveTab();
|
|
@@ -285,7 +286,18 @@ class UserStateNotifier extends _$UserStateNotifier implements OnStartService {
|
|
|
285
286
|
/// It will delete the user account and logout the user
|
|
286
287
|
Future<void> deleteAccount() async {
|
|
287
288
|
_stopRoleListener();
|
|
288
|
-
final userId = state.user.
|
|
289
|
+
final userId = state.user.idOrNull;
|
|
290
|
+
// No backend identity (a guest with no account): there is nothing to delete
|
|
291
|
+
// server-side. Just clear the local session so we never throw a generic
|
|
292
|
+
// error that strands the user on Settings — the UI hides the delete button
|
|
293
|
+
// in this case, this is only a safety net.
|
|
294
|
+
if (userId == null) {
|
|
295
|
+
await _authenticationRepository.logout();
|
|
296
|
+
await _clearWebGuestPass();
|
|
297
|
+
forgetActiveTab();
|
|
298
|
+
state = const UserState(user: User.anonymous());
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
289
301
|
_deviceRepository.removeTokenUpdateListener();
|
|
290
302
|
try {
|
|
291
303
|
await _deviceRepository.unregister(userId);
|
|
@@ -294,6 +306,7 @@ class UserStateNotifier extends _$UserStateNotifier implements OnStartService {
|
|
|
294
306
|
}
|
|
295
307
|
await _userRepository.delete();
|
|
296
308
|
await _authenticationRepository.logout();
|
|
309
|
+
await _clearWebGuestPass();
|
|
297
310
|
// Same as onLogout: forget the last bottom-bar tab so the next account that
|
|
298
311
|
// signs in lands on Home, not wherever the deleted account left off (the
|
|
299
312
|
// user deletes from Settings, so without this the next login reopens it).
|
|
@@ -304,6 +317,20 @@ class UserStateNotifier extends _$UserStateNotifier implements OnStartService {
|
|
|
304
317
|
// continue as a guest.
|
|
305
318
|
}
|
|
306
319
|
|
|
320
|
+
/// On WEB the auth redirect treats "onboarding done" as "this guest is allowed
|
|
321
|
+
/// to stay" (`ready = hasIdentity || isOnboarded || onboardingDone`). After a
|
|
322
|
+
/// logout / account deletion the flag is still true, so the redirect would
|
|
323
|
+
/// keep the just-signed-out user on the current screen instead of sending them
|
|
324
|
+
/// to /signin — and whether it happened to work depended on leftover browser
|
|
325
|
+
/// state. Clearing it on web makes the session boundary land on /signin every
|
|
326
|
+
/// time. No-op on native, where this flag only skips the (native-only)
|
|
327
|
+
/// onboarding screen and logout already routes correctly via hasIdentity, so
|
|
328
|
+
/// clearing it would wrongly show onboarding again.
|
|
329
|
+
Future<void> _clearWebGuestPass() async {
|
|
330
|
+
if (!kIsWeb) return;
|
|
331
|
+
await ref.read(sharedPreferencesProvider).setOnboardingCompleted(false);
|
|
332
|
+
}
|
|
333
|
+
|
|
307
334
|
// -------------------------------
|
|
308
335
|
// ROLE LISTENER
|
|
309
336
|
// -------------------------------
|
|
@@ -147,19 +147,34 @@ class KasyTextTheme extends ThemeExtension<KasyTextTheme> {
|
|
|
147
147
|
/// Top-level page title (auth, paywall, full-screen flows). 24 / w700.
|
|
148
148
|
TextStyle get pageTitle => headlineMedium;
|
|
149
149
|
|
|
150
|
-
/// Section or master-detail pane title (Settings detail, grouped areas).
|
|
150
|
+
/// Section or master-detail pane title (Settings detail, grouped areas).
|
|
151
|
+
/// 18 / w600 — a visible step above the content rows it heads.
|
|
151
152
|
TextStyle get sectionTitle => titleMedium;
|
|
152
153
|
|
|
153
154
|
/// Small header above a grouped list (e.g. "PREFERENCES"). 12 / w600, lightly tracked.
|
|
154
155
|
TextStyle get sectionLabel =>
|
|
155
156
|
bodySmall.copyWith(fontWeight: FontWeight.w600, letterSpacing: 0.5);
|
|
156
157
|
|
|
157
|
-
/// Primary text of a
|
|
158
|
+
/// Primary text of a DENSE navigation / list row — sidebar, tabs, admin
|
|
159
|
+
/// tables. 14 / w500. For full-width CONTENT rows (Settings, a messaging /
|
|
160
|
+
/// conversation inbox) use [listRowTitle] (16) instead.
|
|
158
161
|
TextStyle get rowTitle => titleSmall;
|
|
159
162
|
|
|
160
|
-
/// Secondary / value text in a row (apply a muted colour at the call
|
|
163
|
+
/// Secondary / value text in a dense row (apply a muted colour at the call
|
|
164
|
+
/// site). 14 / w400.
|
|
161
165
|
TextStyle get rowValue => bodyMedium;
|
|
162
166
|
|
|
167
|
+
/// EMPHASIS text of a CONTENT list row — the row's label/title (Settings, and
|
|
168
|
+
/// other iOS-style lists where each row is primary content). 16 / w500. Pairs
|
|
169
|
+
/// with [listRowValue] (14) so the label clearly outranks its value.
|
|
170
|
+
TextStyle get listRowTitle =>
|
|
171
|
+
bodyLarge.copyWith(fontWeight: FontWeight.w500);
|
|
172
|
+
|
|
173
|
+
/// INFO text of a content list row — the value beside the label (apply a muted
|
|
174
|
+
/// colour at the call site). 14 / w400, deliberately one step below
|
|
175
|
+
/// [listRowTitle] so label vs value reads as a hierarchy, not a flat pair.
|
|
176
|
+
TextStyle get listRowValue => bodyMedium;
|
|
177
|
+
|
|
163
178
|
/// Card title. 14 / w500.
|
|
164
179
|
TextStyle get cardTitle => titleSmall;
|
|
165
180
|
|
|
@@ -188,17 +203,17 @@ class KasyTextTheme extends ThemeExtension<KasyTextTheme> {
|
|
|
188
203
|
headlineMedium: _fromRamp(FontWeight.w700, KasyTypeScale.heading2, device),
|
|
189
204
|
headlineSmall: _fromRamp(FontWeight.w600, KasyTypeScale.heading3, device),
|
|
190
205
|
|
|
191
|
-
// Title → Heading 3 / Heading 4 / Body sm
|
|
206
|
+
// Title → Heading 3 / Heading 4 / Body sm (dense title 14, w500).
|
|
192
207
|
titleLarge: _fromRamp(FontWeight.w600, KasyTypeScale.heading3, device),
|
|
193
208
|
titleMedium: _fromRamp(FontWeight.w600, KasyTypeScale.heading4, device),
|
|
194
209
|
titleSmall: _fromRamp(FontWeight.w500, KasyTypeScale.bodySm, device),
|
|
195
210
|
|
|
196
|
-
// Body → Body base / Body sm / Body xs.
|
|
211
|
+
// Body → Body base (16) / Body sm (14) / Body xs (12).
|
|
197
212
|
bodyLarge: _fromRamp(FontWeight.w400, KasyTypeScale.bodyBase, device),
|
|
198
213
|
bodyMedium: _fromRamp(FontWeight.w400, KasyTypeScale.bodySm, device),
|
|
199
214
|
bodySmall: _fromRamp(FontWeight.w400, KasyTypeScale.bodyXs, device),
|
|
200
215
|
|
|
201
|
-
// Label →
|
|
216
|
+
// Label → Body sm (14, w500) / Body xs (12, w500).
|
|
202
217
|
labelLarge: _fromRamp(FontWeight.w500, KasyTypeScale.bodySm, device),
|
|
203
218
|
labelMedium: _fromRamp(FontWeight.w500, KasyTypeScale.bodyXs, device),
|
|
204
219
|
labelSmall: _fromRamp(FontWeight.w500, KasyTypeScale.bodyXs, device),
|
|
@@ -2,15 +2,25 @@ import 'package:kasy_kit/core/widgets/responsive_layout.dart';
|
|
|
2
2
|
|
|
3
3
|
/// Responsive type scale — explicit font size per role, per breakpoint.
|
|
4
4
|
///
|
|
5
|
-
/// Single source of truth for typography sizes
|
|
6
|
-
///
|
|
7
|
-
///
|
|
5
|
+
/// Single source of truth for typography sizes, and the ONLY place breakpoints
|
|
6
|
+
/// touch type. There is no global multiplier and no per-component scaling: every
|
|
7
|
+
/// role declares its own size at each breakpoint here, exactly like design-tool
|
|
8
|
+
/// modes (Figma variables) or platform text styles. Components only read the
|
|
9
|
+
/// resolved theme role (`context.textTheme.*`); they never scale text themselves
|
|
10
|
+
/// by width. (The `WebViewportScale` zoom is a separate, web-only UI alignment
|
|
11
|
+
/// fix — it scales the whole canvas, not these type sizes.)
|
|
8
12
|
///
|
|
9
|
-
/// The
|
|
10
|
-
///
|
|
11
|
-
///
|
|
12
|
-
///
|
|
13
|
-
///
|
|
13
|
+
/// The ladder is harmonious and SEMANTIC — each role one clear step from the
|
|
14
|
+
/// next, and each maps to a PURPOSE, not a guessed size:
|
|
15
|
+
/// - **Headings & display SCALE with the viewport** (this is where real
|
|
16
|
+
/// responsiveness lives): big on desktop, stepping down on mobile so a hero or
|
|
17
|
+
/// page title never wraps badly on a phone.
|
|
18
|
+
/// - **Body & labels are STABLE across breakpoints** — one comfortable reading
|
|
19
|
+
/// size per role, the Material/Apple model. The body ladder uses MARKED 2px
|
|
20
|
+
/// steps so hierarchy is actually visible: section/card title 18, content-row
|
|
21
|
+
/// title / body 16, secondary / value / nav 14, caption 12. No 1px in-between
|
|
22
|
+
/// levels — a 15-vs-16 difference reads as identical, so it isn't a hierarchy.
|
|
23
|
+
/// A row's LABEL (16) and its VALUE (14) are deliberately different sizes.
|
|
14
24
|
///
|
|
15
25
|
/// To re-tune the whole app's typography, edit the numbers here — nothing else.
|
|
16
26
|
/// The live ramp is visible under Design System -> Typography (tabs per
|
|
@@ -51,27 +61,36 @@ class RampSize {
|
|
|
51
61
|
class KasyTypeScale {
|
|
52
62
|
const KasyTypeScale._();
|
|
53
63
|
|
|
54
|
-
// Hero / display — large, compress hard
|
|
64
|
+
// Hero / display — scale with space: large on desktop, compress hard toward
|
|
65
|
+
// mobile so a hero headline never wraps awkwardly on a phone.
|
|
55
66
|
static const displayLarge =
|
|
56
|
-
RampSize(mobile:
|
|
67
|
+
RampSize(mobile: 36, tablet: 46, desktop: 57, lineHeightRatio: 64 / 57);
|
|
57
68
|
static const displayMedium =
|
|
58
|
-
RampSize(mobile:
|
|
69
|
+
RampSize(mobile: 32, tablet: 38, desktop: 45, lineHeightRatio: 52 / 45);
|
|
59
70
|
|
|
60
|
-
// Headings —
|
|
71
|
+
// Headings — scale with space. Mobile anchors on the iOS title ladder
|
|
72
|
+
// (Title1 28 / Title2 22 / Title3 20); desktop grows for the wider canvas.
|
|
61
73
|
static const heading1 =
|
|
62
74
|
RampSize(mobile: 28, tablet: 32, desktop: 36, lineHeightRatio: 40 / 36);
|
|
63
75
|
static const heading2 =
|
|
64
76
|
RampSize(mobile: 22, tablet: 23, desktop: 24, lineHeightRatio: 32 / 24);
|
|
65
77
|
static const heading3 =
|
|
66
|
-
RampSize(mobile:
|
|
78
|
+
RampSize(mobile: 20, tablet: 20, desktop: 20, lineHeightRatio: 28 / 20);
|
|
79
|
+
// Section / card title — 18 (Tailwind text-lg): a visible step above body.
|
|
67
80
|
static const heading4 =
|
|
68
|
-
RampSize(mobile:
|
|
81
|
+
RampSize(mobile: 18, tablet: 18, desktop: 18, lineHeightRatio: 26 / 18);
|
|
69
82
|
|
|
70
|
-
// Body & labels —
|
|
83
|
+
// Body & labels — STABLE, with MARKED 2px steps so hierarchy is visible.
|
|
84
|
+
// Mirrors Material's body/label tiers (16 / 14 / 12); no 1px in-between levels
|
|
85
|
+
// (a 15-vs-16 difference reads as identical, so it isn't a hierarchy). Weight
|
|
86
|
+
// and purpose differentiate roles that share a size (Material does the same).
|
|
87
|
+
// Content-row title, body paragraphs, chat message:
|
|
71
88
|
static const bodyBase =
|
|
72
89
|
RampSize(mobile: 16, tablet: 16, desktop: 16, lineHeightRatio: 24 / 16);
|
|
90
|
+
// Secondary / value, dense nav row, card title, labels:
|
|
73
91
|
static const bodySm =
|
|
74
92
|
RampSize(mobile: 14, tablet: 14, desktop: 14, lineHeightRatio: 20 / 14);
|
|
93
|
+
// Caption, timestamp, fine print:
|
|
75
94
|
static const bodyXs =
|
|
76
95
|
RampSize(mobile: 12, tablet: 12, desktop: 12, lineHeightRatio: 16 / 12);
|
|
77
96
|
}
|
|
@@ -32,6 +32,15 @@ final ValueNotifier<bool> webDevicePreviewEnabledNotifier = ValueNotifier<bool>(
|
|
|
32
32
|
false,
|
|
33
33
|
);
|
|
34
34
|
|
|
35
|
+
/// True only once the device frame is actually ON SCREEN (not the instant the
|
|
36
|
+
/// toggle flips — the frame takes a moment to build). The web viewport scale
|
|
37
|
+
/// reads THIS, not [webDevicePreviewEnabledNotifier], so the scale drops to
|
|
38
|
+
/// native 1.0 only when the frame is up — otherwise the toggle would flash a
|
|
39
|
+
/// big, unframed, unscaled app while the frame builds.
|
|
40
|
+
final ValueNotifier<bool> webDevicePreviewActiveNotifier = ValueNotifier<bool>(
|
|
41
|
+
false,
|
|
42
|
+
);
|
|
43
|
+
|
|
35
44
|
// Kit primary/accent (HeroUI blue). The chrome lives above the MaterialApp, so
|
|
36
45
|
// it can't read context.colors — mirror the design-system value here. Used to
|
|
37
46
|
// highlight active toggles so it's obvious at a glance what's turned on.
|
|
@@ -241,14 +250,55 @@ class _WebDevicePreviewState extends State<WebDevicePreview>
|
|
|
241
250
|
if (mounted) setState(() => _controlsVisible = true);
|
|
242
251
|
});
|
|
243
252
|
setState(() {}); // rebuild DevicePreview with enabled: true
|
|
253
|
+
// [webDevicePreviewActiveNotifier] flips to true only once the frame is
|
|
254
|
+
// actually built — see [_buildFramedContent] — so the web scale stays put
|
|
255
|
+
// until then (no big-unframed-app flash during the build).
|
|
244
256
|
} else {
|
|
245
257
|
_controlsTimer?.cancel();
|
|
258
|
+
// Leaving the frame: restore the web scale immediately.
|
|
259
|
+
webDevicePreviewActiveNotifier.value = false;
|
|
246
260
|
setState(() => _controlsVisible = false);
|
|
247
261
|
}
|
|
248
262
|
}
|
|
249
263
|
|
|
250
264
|
void _onBgChanged() => setState(() {});
|
|
251
265
|
|
|
266
|
+
/// Builds the app content placed INSIDE the device frame. DevicePreview calls
|
|
267
|
+
/// this as it brings the framed environment up, so it doubles as the "frame is
|
|
268
|
+
/// on screen now" signal: after this paints we flip
|
|
269
|
+
/// [webDevicePreviewActiveNotifier], which is what lets the web viewport scale
|
|
270
|
+
/// drop to native 1.0 — and only then, so the toggle never shows a flash of
|
|
271
|
+
/// big, unframed, unscaled app while the frame is still building.
|
|
272
|
+
Widget _buildFramedContent(BuildContext context) {
|
|
273
|
+
if (webDevicePreviewEnabledNotifier.value &&
|
|
274
|
+
!webDevicePreviewActiveNotifier.value) {
|
|
275
|
+
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
276
|
+
if (mounted && webDevicePreviewEnabledNotifier.value) {
|
|
277
|
+
webDevicePreviewActiveNotifier.value = true;
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
return ListenableBuilder(
|
|
282
|
+
listenable: _textScaleNotifier,
|
|
283
|
+
builder: (ctx, _) => MediaQuery(
|
|
284
|
+
data: MediaQuery.of(ctx).copyWith(
|
|
285
|
+
textScaler: TextScaler.linear(_textScaleNotifier.value),
|
|
286
|
+
// DevicePreview hard-codes platformBrightness to light inside the
|
|
287
|
+
// simulated frame, which breaks MaterialApp's ThemeMode.system.
|
|
288
|
+
// Forward the real host brightness so "system" tracks the OS.
|
|
289
|
+
platformBrightness:
|
|
290
|
+
WidgetsBinding.instance.platformDispatcher.platformBrightness,
|
|
291
|
+
),
|
|
292
|
+
child: _DeviceSwitchBridge(
|
|
293
|
+
deviceNotifier: _deviceNotifier,
|
|
294
|
+
frameVisibleNotifier: _frameVisibleNotifier,
|
|
295
|
+
landscapeNotifier: _landscapeNotifier,
|
|
296
|
+
child: widget.child,
|
|
297
|
+
),
|
|
298
|
+
),
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
252
302
|
Future<void> _bootstrap() async {
|
|
253
303
|
final prefs = await SharedPreferences.getInstance();
|
|
254
304
|
|
|
@@ -449,25 +499,7 @@ class _WebDevicePreviewState extends State<WebDevicePreview>
|
|
|
449
499
|
storage: DevicePreviewStorage.none(),
|
|
450
500
|
defaultDevice: _currentDevice,
|
|
451
501
|
devices: [..._iosDevices, ..._androidDevices, ..._iPadDevices],
|
|
452
|
-
builder:
|
|
453
|
-
listenable: _textScaleNotifier,
|
|
454
|
-
builder: (ctx, _) => MediaQuery(
|
|
455
|
-
data: MediaQuery.of(ctx).copyWith(
|
|
456
|
-
textScaler: TextScaler.linear(_textScaleNotifier.value),
|
|
457
|
-
// DevicePreview hard-codes platformBrightness to light inside the
|
|
458
|
-
// simulated frame, which breaks MaterialApp's ThemeMode.system.
|
|
459
|
-
// Forward the real host brightness so "system" tracks the OS.
|
|
460
|
-
platformBrightness:
|
|
461
|
-
WidgetsBinding.instance.platformDispatcher.platformBrightness,
|
|
462
|
-
),
|
|
463
|
-
child: _DeviceSwitchBridge(
|
|
464
|
-
deviceNotifier: _deviceNotifier,
|
|
465
|
-
frameVisibleNotifier: _frameVisibleNotifier,
|
|
466
|
-
landscapeNotifier: _landscapeNotifier,
|
|
467
|
-
child: widget.child,
|
|
468
|
-
),
|
|
469
|
-
),
|
|
470
|
-
),
|
|
502
|
+
builder: _buildFramedContent,
|
|
471
503
|
);
|
|
472
504
|
|
|
473
505
|
if (!enabled) return preview;
|
|
@@ -2,27 +2,49 @@ import 'package:flutter/foundation.dart' show kIsWeb;
|
|
|
2
2
|
import 'package:flutter/widgets.dart';
|
|
3
3
|
import 'package:kasy_kit/core/web_screen_width.dart';
|
|
4
4
|
|
|
5
|
-
///
|
|
5
|
+
/// Render scale applied to the app on web, on EVERY breakpoint (phone, tablet,
|
|
6
|
+
/// desktop).
|
|
6
7
|
///
|
|
7
|
-
/// Flutter web
|
|
8
|
-
/// browser's 100% zoom, so the whole UI feels oversized
|
|
9
|
-
///
|
|
10
|
-
///
|
|
11
|
-
///
|
|
12
|
-
///
|
|
8
|
+
/// Flutter web renders ~10% larger than an equivalent native/HTML app at the
|
|
9
|
+
/// browser's 100% zoom, at any width — so the whole web UI feels oversized.
|
|
10
|
+
/// `0.95` walks back ~5% so the web app reads close to the native baseline
|
|
11
|
+
/// without the user touching browser zoom — a gentle correction (the exact
|
|
12
|
+
/// midpoint between full size and a total undo) that takes the "oversized" edge
|
|
13
|
+
/// off Flutter web without making things feel small (0.90 fully undoes the 10%
|
|
14
|
+
/// but reads small on a monitor — one number to nudge). On desktop it also acts
|
|
15
|
+
/// as a cap: a
|
|
16
|
+
/// screen below the design target (high OS scale) reduces it further to pin the
|
|
17
|
+
/// layout (see [kWebViewportScaleTargetWidth]); phone/tablet take the flat cap
|
|
18
|
+
/// because those layouts reflow.
|
|
13
19
|
///
|
|
14
|
-
///
|
|
15
|
-
///
|
|
16
|
-
///
|
|
17
|
-
///
|
|
18
|
-
///
|
|
20
|
+
/// Two things are deliberately NOT scaled, both rendering at native 1.0:
|
|
21
|
+
/// - NATIVE itself — the mechanism is gated on [kIsWeb], so iOS/Android/macOS/
|
|
22
|
+
/// Windows render at 1.0 and keep respecting the user's system text-size
|
|
23
|
+
/// (accessibility).
|
|
24
|
+
/// - The in-app DEVICE PREVIEW — it simulates a native device, so it must show
|
|
25
|
+
/// the native 1.0 truth; the scale is skipped once the preview frame is up
|
|
26
|
+
/// (see main.dart, gated on `webDevicePreviewActiveNotifier`).
|
|
19
27
|
const double kWebViewportScale = 0.95;
|
|
20
28
|
|
|
29
|
+
/// Master on/off for the web render scale — the single knob.
|
|
30
|
+
///
|
|
31
|
+
/// `true` (default): the web app is rendered ~5% smaller than native via
|
|
32
|
+
/// [kWebViewportScale], correcting Flutter web's oversized feel on desktop while
|
|
33
|
+
/// native stays at true 1.0. This is a deliberate, web-only density correction
|
|
34
|
+
/// (the same technique `responsive_framework`'s autoScale productizes), applied
|
|
35
|
+
/// ON TOP OF an already-adaptive layout — not a substitute for it. We verified
|
|
36
|
+
/// nothing overflows/crops without it, so it is a density choice, not a crutch.
|
|
37
|
+
///
|
|
38
|
+
/// `false`: [WebViewportScale.wrap] becomes a true no-op everywhere and the web
|
|
39
|
+
/// app renders at native 1.0. There is no hidden coupling, so this one flag is
|
|
40
|
+
/// the entire on/off — flip it (or set [kWebViewportScale] to `1.0`) to opt out.
|
|
41
|
+
const bool kWebViewportScaleEnabled = true;
|
|
42
|
+
|
|
21
43
|
/// Design target width (logical px) the desktop shell is laid out against.
|
|
22
44
|
///
|
|
23
45
|
/// A display with high OS scaling (Windows at 125/150/175%, or a Mac in a scaled
|
|
24
46
|
/// "more space"/"larger text" mode) reports a smaller logical SCREEN width, so the
|
|
25
|
-
///
|
|
47
|
+
/// flat scale alone left the shell cramped/cropped (the user had to
|
|
26
48
|
/// Ctrl-minus). When the screen is below this target the scale drops further
|
|
27
49
|
/// (`screenWidth / kWebViewportScaleTargetWidth`) so the full design still fits.
|
|
28
50
|
/// Compared against the SCREEN width, not the window width — see
|
|
@@ -39,34 +61,37 @@ const double kWebViewportScaleDesktopBreakpoint = 1024; // DeviceType.large.brea
|
|
|
39
61
|
/// Effective web render scale (pure math, unit-testable — see
|
|
40
62
|
/// web_viewport_scale_test.dart).
|
|
41
63
|
///
|
|
42
|
-
/// [windowWidth] is the browser window width
|
|
43
|
-
///
|
|
44
|
-
/// logical px (null = unknown/native).
|
|
64
|
+
/// [windowWidth] is the browser window width. [screenWidth] is the physical
|
|
65
|
+
/// screen width in logical px (null = unknown/native).
|
|
45
66
|
///
|
|
46
|
-
///
|
|
47
|
-
///
|
|
48
|
-
///
|
|
67
|
+
/// On tablet/phone web ([windowWidth] below [kWebViewportScaleDesktopBreakpoint])
|
|
68
|
+
/// it returns the flat [maxScale]: those layouts reflow, so they just take the
|
|
69
|
+
/// cap that undoes the ~10% web oversize.
|
|
49
70
|
///
|
|
50
|
-
/// On desktop it returns the flat [maxScale] cap
|
|
51
|
-
///
|
|
52
|
-
///
|
|
53
|
-
///
|
|
54
|
-
///
|
|
55
|
-
///
|
|
56
|
-
/// [screenWidth] null (native, or web before the screen is known) it stays at the
|
|
57
|
-
/// flat cap.
|
|
71
|
+
/// On desktop it returns the flat [maxScale] cap and only drops BELOW it when the
|
|
72
|
+
/// SCREEN is small (high OS scale), via `screenWidth / kWebViewportScaleTargetWidth`.
|
|
73
|
+
/// Keying the compensation off the screen — not the window — is the whole point:
|
|
74
|
+
/// merely resizing the browser window narrower must not shrink the UI further (the
|
|
75
|
+
/// layout just reflows); the extra shrink happens only when the screen itself is
|
|
76
|
+
/// cramped. With [screenWidth] null it stays at the flat cap.
|
|
58
77
|
///
|
|
59
78
|
/// This is web-only semantics: native never reaches the scaling path (the
|
|
60
|
-
/// [WebViewportScale] widget short-circuits when not on web, via [kIsWeb]),
|
|
61
|
-
///
|
|
79
|
+
/// [WebViewportScale] widget short-circuits when not on web, via [kIsWeb]), and
|
|
80
|
+
/// the device preview is skipped too — both render at native 1.0.
|
|
62
81
|
double webViewportEffectiveScale(
|
|
63
82
|
double windowWidth, {
|
|
64
83
|
double? screenWidth,
|
|
65
84
|
double maxScale = kWebViewportScale,
|
|
66
85
|
}) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
86
|
+
// Desktop: keep the high-OS-scale compensation (drop below the cap only when
|
|
87
|
+
// the SCREEN is small, so the full wide desktop design still fits).
|
|
88
|
+
if (windowWidth >= kWebViewportScaleDesktopBreakpoint) {
|
|
89
|
+
final double basis = screenWidth ?? double.infinity;
|
|
90
|
+
return (basis / kWebViewportScaleTargetWidth).clamp(0.5, maxScale);
|
|
91
|
+
}
|
|
92
|
+
// Tablet & phone web: the same ~10% oversize applies, but these layouts reflow,
|
|
93
|
+
// so they take the flat cap (no wide design to fit, no compensation needed).
|
|
94
|
+
return maxScale;
|
|
70
95
|
}
|
|
71
96
|
|
|
72
97
|
/// Renders [child] uniformly scaled by [scale] on web (no-op elsewhere).
|
|
@@ -85,9 +110,13 @@ class WebViewportScale extends StatelessWidget {
|
|
|
85
110
|
this.scale = kWebViewportScale,
|
|
86
111
|
});
|
|
87
112
|
|
|
88
|
-
/// Wraps [child] on web; returns it untouched
|
|
89
|
-
|
|
90
|
-
|
|
113
|
+
/// Wraps [child] on web when the scale is enabled; returns it untouched
|
|
114
|
+
/// otherwise (every non-web platform, and whenever [kWebViewportScaleEnabled]
|
|
115
|
+
/// is `false`). With the scale off this is a real pass-through — no FittedBox,
|
|
116
|
+
/// no MediaQuery rewrite — so the web app is byte-for-byte native size.
|
|
117
|
+
static Widget wrap(Widget child) => (kIsWeb && kWebViewportScaleEnabled)
|
|
118
|
+
? WebViewportScale(child: child)
|
|
119
|
+
: child;
|
|
91
120
|
|
|
92
121
|
@override
|
|
93
122
|
Widget build(BuildContext context) {
|
|
@@ -68,6 +68,7 @@ class _KasyPressableDepthState extends ConsumerState<KasyPressableDepth>
|
|
|
68
68
|
late final AnimationController _depthController;
|
|
69
69
|
Timer? _veilTimer;
|
|
70
70
|
bool _veilVisible = false;
|
|
71
|
+
bool _hovered = false;
|
|
71
72
|
|
|
72
73
|
bool get _useVeil =>
|
|
73
74
|
widget.pressOverlayColor != null && widget.clipBorderRadius != null;
|
|
@@ -134,10 +135,13 @@ class _KasyPressableDepthState extends ConsumerState<KasyPressableDepth>
|
|
|
134
135
|
return Transform.scale(scale: _depthScale, child: rawChild);
|
|
135
136
|
}
|
|
136
137
|
|
|
138
|
+
// On web the overlay doubles as a hover highlight: a subtle persistent fill
|
|
139
|
+
// while the pointer is over the control, flashing to full on press. On touch
|
|
140
|
+
// it only flashes on press (_hovered never becomes true).
|
|
137
141
|
final Widget pressVeil = AnimatedOpacity(
|
|
138
142
|
duration: const Duration(milliseconds: 90),
|
|
139
143
|
curve: Curves.easeOutCubic,
|
|
140
|
-
opacity: _veilVisible ? 1.0 : 0.0,
|
|
144
|
+
opacity: _veilVisible ? 1.0 : (_hovered ? 0.6 : 0.0),
|
|
141
145
|
child: IgnorePointer(child: ColoredBox(color: widget.pressOverlayColor!)),
|
|
142
146
|
);
|
|
143
147
|
|
|
@@ -196,7 +200,14 @@ class _KasyPressableDepthState extends ConsumerState<KasyPressableDepth>
|
|
|
196
200
|
|
|
197
201
|
if (!kIsWeb) return inner;
|
|
198
202
|
|
|
199
|
-
// On web:
|
|
200
|
-
|
|
203
|
+
// On web: pointer cursor + a subtle hover highlight (drives the overlay
|
|
204
|
+
// opacity above), so buttons feel interactive on hover as expected on the
|
|
205
|
+
// web. Only meaningful when there's an overlay to show (_useVeil).
|
|
206
|
+
return MouseRegion(
|
|
207
|
+
cursor: SystemMouseCursors.click,
|
|
208
|
+
onEnter: _useVeil ? (_) => setState(() => _hovered = true) : null,
|
|
209
|
+
onExit: _useVeil ? (_) => setState(() => _hovered = false) : null,
|
|
210
|
+
child: inner,
|
|
211
|
+
);
|
|
201
212
|
}
|
|
202
213
|
}
|
|
@@ -83,7 +83,7 @@ class _AiChatComposerState extends State<AiChatComposer> {
|
|
|
83
83
|
@override
|
|
84
84
|
Widget build(BuildContext context) {
|
|
85
85
|
final bool enabled = !widget.isReplying;
|
|
86
|
-
final BorderRadius shellRadius = BorderRadius.circular(
|
|
86
|
+
final BorderRadius shellRadius = BorderRadius.circular(KasyRadius.xl);
|
|
87
87
|
final Color borderColor = KasyShadows.inputFieldRestingBorder(context);
|
|
88
88
|
|
|
89
89
|
return DecoratedBox(
|