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
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
2
|
+
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
|
3
|
+
|
|
4
|
+
<svg
|
|
5
|
+
version="1.1"
|
|
6
|
+
id="svg9"
|
|
7
|
+
width="666.66669"
|
|
8
|
+
height="666.66718"
|
|
9
|
+
viewBox="0 0 666.66668 666.66717"
|
|
10
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
11
|
+
xmlns:svg="http://www.w3.org/2000/svg">
|
|
12
|
+
<defs
|
|
13
|
+
id="defs13">
|
|
14
|
+
<clipPath
|
|
15
|
+
clipPathUnits="userSpaceOnUse"
|
|
16
|
+
id="clipPath25">
|
|
17
|
+
<path
|
|
18
|
+
d="M 0,700 H 700 V 0 H 0 Z"
|
|
19
|
+
id="path23" />
|
|
20
|
+
</clipPath>
|
|
21
|
+
</defs>
|
|
22
|
+
<g
|
|
23
|
+
id="g17"
|
|
24
|
+
transform="matrix(1.3333333,0,0,-1.3333333,-133.33333,799.99999)">
|
|
25
|
+
<g
|
|
26
|
+
id="g19">
|
|
27
|
+
<g
|
|
28
|
+
id="g21"
|
|
29
|
+
clip-path="url(#clipPath25)">
|
|
30
|
+
<g
|
|
31
|
+
id="g27"
|
|
32
|
+
transform="translate(600,350)">
|
|
33
|
+
<path
|
|
34
|
+
d="m 0,0 c 0,138.071 -111.929,250 -250,250 -138.071,0 -250,-111.929 -250,-250 0,-117.245 80.715,-215.622 189.606,-242.638 v 166.242 h -51.552 V 0 h 51.552 v 32.919 c 0,85.092 38.508,124.532 122.048,124.532 15.838,0 43.167,-3.105 54.347,-6.211 V 81.986 c -5.901,0.621 -16.149,0.932 -28.882,0.932 -40.993,0 -56.832,-15.528 -56.832,-55.9 V 0 h 81.659 l -14.028,-76.396 h -67.631 V -248.169 C -95.927,-233.218 0,-127.818 0,0"
|
|
35
|
+
style="fill:#0866ff;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
|
36
|
+
id="path29" />
|
|
37
|
+
</g>
|
|
38
|
+
<g
|
|
39
|
+
id="g31"
|
|
40
|
+
transform="translate(447.9175,273.6036)">
|
|
41
|
+
<path
|
|
42
|
+
d="M 0,0 14.029,76.396 H -67.63 v 27.019 c 0,40.372 15.838,55.899 56.831,55.899 12.733,0 22.981,-0.31 28.882,-0.931 v 69.253 c -11.18,3.106 -38.509,6.212 -54.347,6.212 -83.539,0 -122.048,-39.441 -122.048,-124.533 V 76.396 h -51.552 V 0 h 51.552 v -166.242 c 19.343,-4.798 39.568,-7.362 60.394,-7.362 10.254,0 20.358,0.632 30.288,1.831 L -67.63,0 Z"
|
|
43
|
+
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
|
44
|
+
id="path33" />
|
|
45
|
+
</g>
|
|
46
|
+
</g>
|
|
47
|
+
</g>
|
|
48
|
+
</g>
|
|
49
|
+
</svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/><path d="M1 1h22v22H1z" fill="none"/></svg>
|
|
@@ -21,6 +21,7 @@ interface AdminUser {
|
|
|
21
21
|
email: string | null;
|
|
22
22
|
name: string | null;
|
|
23
23
|
createdAt: number | null; // epoch millis
|
|
24
|
+
avatarPath: string | null; // public photo URL, when the user has one
|
|
24
25
|
subscriber: boolean;
|
|
25
26
|
}
|
|
26
27
|
|
|
@@ -91,6 +92,7 @@ export const listUsers = onCall(async (request) => {
|
|
|
91
92
|
email: (d.get("email") as string) || null,
|
|
92
93
|
name: (d.get("name") as string) || null,
|
|
93
94
|
createdAt: createdAt ? createdAt.toMillis() : null,
|
|
95
|
+
avatarPath: (d.get("avatarPath") as string) || null,
|
|
94
96
|
subscriber: activeSubscribers.has(d.id),
|
|
95
97
|
};
|
|
96
98
|
});
|
|
@@ -14,14 +14,20 @@ export const deleteUserAccount = onCall(
|
|
|
14
14
|
const logger = new Logger("deleteUserAccount");
|
|
15
15
|
const db = admin.firestore();
|
|
16
16
|
try {
|
|
17
|
-
//
|
|
17
|
+
// Delete DATA first, IDENTITY last. Each Firestore delete is idempotent
|
|
18
|
+
// (removing a missing doc is a no-op), so if anything below fails the
|
|
19
|
+
// account is still signed-in and the whole operation can be retried
|
|
20
|
+
// safely — we never leave a half-deleted "ghost" (auth gone, data left)
|
|
21
|
+
// that would strand the client logged in against a user that no longer
|
|
22
|
+
// exists. This mirrors the Supabase backend, where deleting auth.users
|
|
23
|
+
// cascades to every related row in a single atomic step.
|
|
24
|
+
// 1. Firestore user doc + subcollections (devices, notifications, ...)
|
|
25
|
+
await db.recursiveDelete(db.collection("users").doc(uid));
|
|
26
|
+
// 2. Subscription document
|
|
27
|
+
await db.collection("subscriptions").doc(uid).delete();
|
|
28
|
+
// 3. Firebase Auth identity — the irreversible step, done only once the
|
|
29
|
+
// data is gone so a failure above leaves a clean, retriable state.
|
|
18
30
|
await admin.auth().deleteUser(uid);
|
|
19
|
-
// 2. Delete Firestore data (user doc + subcollections: devices, notifications)
|
|
20
|
-
const userRef = db.collection("users").doc(uid);
|
|
21
|
-
await db.recursiveDelete(userRef);
|
|
22
|
-
// 3. Delete subscription document
|
|
23
|
-
const subRef = db.collection("subscriptions").doc(uid);
|
|
24
|
-
await subRef.delete();
|
|
25
31
|
logger.info(`User ${uid} deleted successfully`);
|
|
26
32
|
} catch (e) {
|
|
27
33
|
logger.error(`Error deleteUserAccount users/${uid}: ${e}`);
|
|
@@ -61,10 +61,14 @@ export const onNotificationCreated = onDocumentCreated(
|
|
|
61
61
|
? { imageUrl: notificationEntity.image_url }
|
|
62
62
|
: undefined,
|
|
63
63
|
};
|
|
64
|
-
const
|
|
64
|
+
const allDevices = await userDevicesRepository.getDevices([userId]);
|
|
65
|
+
// Skip installs without a push token (notifications not enabled yet): no
|
|
66
|
+
// point sending, and — crucially — an empty token fails as "invalid" which
|
|
67
|
+
// would delete the install in the cleanup below.
|
|
68
|
+
const userDevices = allDevices.filter((userDevice) => !!userDevice.token);
|
|
65
69
|
const tokens = userDevices.map((userDevice) => userDevice.token);
|
|
66
70
|
if (tokens.length === 0) {
|
|
67
|
-
logger.info(`No device
|
|
71
|
+
logger.info(`No device with a push token for user ${userId}`);
|
|
68
72
|
return;
|
|
69
73
|
}
|
|
70
74
|
const notificationApi = NotificationsApi.create();
|
|
@@ -19,6 +19,7 @@ export 'kasy_checkbox.dart';
|
|
|
19
19
|
export 'kasy_chip.dart';
|
|
20
20
|
export 'kasy_date_picker.dart';
|
|
21
21
|
export 'kasy_dialog.dart';
|
|
22
|
+
export 'kasy_drop_down.dart';
|
|
22
23
|
export 'kasy_image_viewer.dart';
|
|
23
24
|
export 'kasy_otp_verification_bottom_sheet.dart';
|
|
24
25
|
export 'kasy_screen.dart';
|
|
@@ -31,4 +32,3 @@ export 'kasy_text_area.dart';
|
|
|
31
32
|
export 'kasy_text_field.dart';
|
|
32
33
|
export 'kasy_text_field_otp.dart';
|
|
33
34
|
export 'kasy_toast.dart';
|
|
34
|
-
export 'kasy_web_header.dart';
|
|
@@ -19,6 +19,11 @@
|
|
|
19
19
|
/// [KasyAppBarStyle.subpageSimple] (back only), [KasyAppBarStyle.subpageActions]
|
|
20
20
|
/// (custom [trailing]).
|
|
21
21
|
///
|
|
22
|
+
/// **Desktop:** [KasyAppBar.application] renders the application chrome (search,
|
|
23
|
+
/// quick-create, notifications, profile) for viewports ≥ 1024px — the responsive
|
|
24
|
+
/// other half of the same component (formerly a separate web header). The shell
|
|
25
|
+
/// places it above content; the page bar then hides on desktop.
|
|
26
|
+
///
|
|
22
27
|
/// Barrel: [components.dart].
|
|
23
28
|
|
|
24
29
|
library;
|
|
@@ -26,6 +31,12 @@ library;
|
|
|
26
31
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
27
32
|
import 'package:flutter/material.dart';
|
|
28
33
|
import 'package:flutter/services.dart' show SystemUiOverlayStyle;
|
|
34
|
+
import 'package:kasy_kit/components/kasy_avatar.dart';
|
|
35
|
+
import 'package:kasy_kit/components/kasy_avatar_presets.dart';
|
|
36
|
+
import 'package:kasy_kit/components/kasy_button.dart';
|
|
37
|
+
import 'package:kasy_kit/components/kasy_text_field.dart';
|
|
38
|
+
import 'package:kasy_kit/core/chrome/app_bar_config.dart';
|
|
39
|
+
import 'package:kasy_kit/core/chrome/app_bar_scope.dart';
|
|
29
40
|
import 'package:kasy_kit/core/chrome/chrome_visibility.dart';
|
|
30
41
|
import 'package:kasy_kit/core/theme/theme.dart';
|
|
31
42
|
import 'package:kasy_kit/core/widgets/kasy_focus_ring.dart';
|
|
@@ -34,14 +45,22 @@ import 'package:kasy_kit/core/widgets/responsive_layout.dart';
|
|
|
34
45
|
/// Inner toolbar band height (orbit hit targets, title baseline).
|
|
35
46
|
const double kasyAppBarToolbarRowHeight = 44;
|
|
36
47
|
|
|
37
|
-
/// Effective toolbar band height.
|
|
38
|
-
/// desktop
|
|
39
|
-
/// compact height across every viewport where the
|
|
48
|
+
/// Effective toolbar band height. The page chrome serves phone and tablet — on
|
|
49
|
+
/// desktop [KasyAppBar.application] takes over — so the band keeps a single
|
|
50
|
+
/// compact height across every viewport where the page bar appears.
|
|
40
51
|
double kasyAppBarToolbarRowHeightOf(BuildContext context) =>
|
|
41
52
|
kasyAppBarToolbarRowHeight;
|
|
42
53
|
|
|
43
54
|
const double kasyAppBarTitleFontScale = 0.92;
|
|
44
55
|
|
|
56
|
+
/// Height of the desktop application bar band ([KasyAppBar.application]): 36px
|
|
57
|
+
/// content + 16px top/bottom. minHeight, not fixed, so the search field never
|
|
58
|
+
/// overflows the row.
|
|
59
|
+
const double kasyAppBarApplicationHeight = 68;
|
|
60
|
+
|
|
61
|
+
/// Fixed width of the search field in the desktop application bar.
|
|
62
|
+
const double kasyAppBarApplicationSearchWidth = 220;
|
|
63
|
+
|
|
45
64
|
/// Tight vertical padding inside the frost, above/below [kasyAppBarToolbarRowHeight].
|
|
46
65
|
const double kasyAppBarChromePaddingTop = KasySpacing.xs;
|
|
47
66
|
const double kasyAppBarChromePaddingBottom = KasySpacing.sm;
|
|
@@ -56,8 +75,11 @@ enum KasyAppBarScrollTreatment { overlay, intrinsicScroll }
|
|
|
56
75
|
|
|
57
76
|
/// Vertical inset for scroll/viewport when using [KasyAppBarScrollTreatment.overlay].
|
|
58
77
|
double kasyAppBarBodyTopOverlap(BuildContext context) {
|
|
59
|
-
// On desktop the
|
|
60
|
-
|
|
78
|
+
// On desktop the page bar hides only inside the application-bar scope (the
|
|
79
|
+
// shell), so no overlap there. Outside it (a full-screen pushed route) the bar
|
|
80
|
+
// is visible, so reserve its height like on phone/tablet.
|
|
81
|
+
if (MediaQuery.sizeOf(context).width >= DeviceType.large.breakpoint &&
|
|
82
|
+
KasyAppBarScope.of(context)) {
|
|
61
83
|
return 0;
|
|
62
84
|
}
|
|
63
85
|
return MediaQuery.paddingOf(context).top +
|
|
@@ -74,23 +96,47 @@ List<Widget> kasyOverlayPaddedSlivers(
|
|
|
74
96
|
BuildContext context, {
|
|
75
97
|
required List<Widget> slivers,
|
|
76
98
|
EdgeInsetsGeometry? contentPadding,
|
|
99
|
+
double? maxContentWidth,
|
|
77
100
|
}) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
101
|
+
const double topPad = KasySpacing.belowChromeContentGap;
|
|
102
|
+
final double bottomPad =
|
|
103
|
+
MediaQuery.paddingOf(context).bottom + KasySpacing.pageVerticalGutter;
|
|
104
|
+
const double gutter = KasySpacing.pageHorizontalGutter;
|
|
105
|
+
|
|
106
|
+
final Widget body = SliverMainAxisGroup(slivers: slivers);
|
|
107
|
+
|
|
108
|
+
// An explicit [contentPadding] takes full control; otherwise use the page
|
|
109
|
+
// gutters, optionally centering content within [maxContentWidth] on wide
|
|
110
|
+
// viewports (so lists never stretch edge-to-edge on desktop). Centering is
|
|
111
|
+
// computed from the real content width (SliverLayoutBuilder) so it stays
|
|
112
|
+
// correct inside the desktop shell where the sidebar already took space.
|
|
113
|
+
final Widget paddedGroup;
|
|
114
|
+
if (contentPadding != null) {
|
|
115
|
+
paddedGroup = SliverPadding(padding: contentPadding, sliver: body);
|
|
116
|
+
} else if (maxContentWidth == null) {
|
|
117
|
+
paddedGroup = SliverPadding(
|
|
118
|
+
padding: EdgeInsets.fromLTRB(gutter, topPad, gutter, bottomPad),
|
|
119
|
+
sliver: body,
|
|
120
|
+
);
|
|
121
|
+
} else {
|
|
122
|
+
paddedGroup = SliverLayoutBuilder(
|
|
123
|
+
builder: (context, constraints) {
|
|
124
|
+
final double available = constraints.crossAxisExtent;
|
|
125
|
+
final double horizontal = available - 2 * gutter > maxContentWidth
|
|
126
|
+
? (available - maxContentWidth) / 2
|
|
127
|
+
: gutter;
|
|
128
|
+
return SliverPadding(
|
|
129
|
+
padding: EdgeInsets.fromLTRB(horizontal, topPad, horizontal, bottomPad),
|
|
130
|
+
sliver: body,
|
|
131
|
+
);
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
}
|
|
86
135
|
return <Widget>[
|
|
87
136
|
SliverToBoxAdapter(
|
|
88
137
|
child: SizedBox(height: kasyAppBarBodyTopOverlap(context)),
|
|
89
138
|
),
|
|
90
|
-
|
|
91
|
-
padding: pad,
|
|
92
|
-
sliver: SliverMainAxisGroup(slivers: slivers),
|
|
93
|
-
),
|
|
139
|
+
paddedGroup,
|
|
94
140
|
];
|
|
95
141
|
}
|
|
96
142
|
|
|
@@ -231,6 +277,10 @@ enum KasyAppBarStyle {
|
|
|
231
277
|
rootTab,
|
|
232
278
|
}
|
|
233
279
|
|
|
280
|
+
/// Internal: which chrome a [KasyAppBar] renders — the phone/tablet page bar or
|
|
281
|
+
/// the desktop application bar ([KasyAppBar.application]).
|
|
282
|
+
enum _KasyAppBarVariant { page, application }
|
|
283
|
+
|
|
234
284
|
/// Implements the frosted toolbar; usage patterns described in this file header.
|
|
235
285
|
class KasyAppBar extends StatelessWidget {
|
|
236
286
|
final String title;
|
|
@@ -269,6 +319,65 @@ class KasyAppBar extends StatelessWidget {
|
|
|
269
319
|
/// appears where the bar was.
|
|
270
320
|
final bool hideOnScroll;
|
|
271
321
|
|
|
322
|
+
// --- Application chrome (desktop, via [KasyAppBar.application]) ---
|
|
323
|
+
// These carry the desktop header (search, quick-create, notifications,
|
|
324
|
+
// profile). They are inert in the default (page) constructor.
|
|
325
|
+
|
|
326
|
+
/// Controller for the search field. Optional — omit for a display-only header.
|
|
327
|
+
final TextEditingController? searchController;
|
|
328
|
+
|
|
329
|
+
/// Placeholder shown in the search field.
|
|
330
|
+
final String searchHint;
|
|
331
|
+
|
|
332
|
+
/// Called as the user types in the search field.
|
|
333
|
+
final ValueChanged<String>? onSearchChanged;
|
|
334
|
+
|
|
335
|
+
/// Called when the search field is submitted (Enter).
|
|
336
|
+
final ValueChanged<String>? onSearchSubmitted;
|
|
337
|
+
|
|
338
|
+
/// Notifications (bell) action. Ignored when [notifications] is provided.
|
|
339
|
+
final VoidCallback? onNotifications;
|
|
340
|
+
|
|
341
|
+
/// Shows the unread dot on the bell. Ignored when [notifications] is provided.
|
|
342
|
+
final bool showNotificationBadge;
|
|
343
|
+
|
|
344
|
+
/// Custom notifications control — replaces the built-in bell with a data-aware
|
|
345
|
+
/// widget so the bar itself stays presentational.
|
|
346
|
+
final Widget? notifications;
|
|
347
|
+
|
|
348
|
+
/// Primary quick-create action. When null the button is disabled.
|
|
349
|
+
final VoidCallback? onCreate;
|
|
350
|
+
|
|
351
|
+
/// Label for the create button.
|
|
352
|
+
final String createLabel;
|
|
353
|
+
|
|
354
|
+
/// Gradient for the profile avatar fallback (when [avatar] is null).
|
|
355
|
+
final KasyAvatarGradientData avatarGradient;
|
|
356
|
+
|
|
357
|
+
/// Custom avatar widget (e.g. the signed-in user's photo). When null and
|
|
358
|
+
/// [showAvatar] is true, a gradient-fill avatar is shown.
|
|
359
|
+
final Widget? avatar;
|
|
360
|
+
|
|
361
|
+
/// Whether the profile avatar is shown (false when the sidebar owns it).
|
|
362
|
+
final bool showAvatar;
|
|
363
|
+
|
|
364
|
+
/// Profile avatar tap (open menu / profile).
|
|
365
|
+
final VoidCallback? onAvatarTap;
|
|
366
|
+
|
|
367
|
+
/// Theme toggle for the application bar (sun/moon ghost button before the bell).
|
|
368
|
+
final VoidCallback? onToggleTheme;
|
|
369
|
+
|
|
370
|
+
/// Whether the search field is shown (application bar).
|
|
371
|
+
final bool showSearch;
|
|
372
|
+
|
|
373
|
+
/// Whether the notifications control is shown (application bar).
|
|
374
|
+
final bool showNotifications;
|
|
375
|
+
|
|
376
|
+
/// Whether the quick-create button is shown (application bar).
|
|
377
|
+
final bool showCreate;
|
|
378
|
+
|
|
379
|
+
final _KasyAppBarVariant _variant;
|
|
380
|
+
|
|
272
381
|
const KasyAppBar({
|
|
273
382
|
super.key,
|
|
274
383
|
required this.title,
|
|
@@ -281,12 +390,107 @@ class KasyAppBar extends StatelessWidget {
|
|
|
281
390
|
this.toolbarHeight,
|
|
282
391
|
this.topInset,
|
|
283
392
|
this.hideOnScroll = false,
|
|
284
|
-
})
|
|
393
|
+
}) : _variant = _KasyAppBarVariant.page,
|
|
394
|
+
searchController = null,
|
|
395
|
+
searchHint = 'Search...',
|
|
396
|
+
onSearchChanged = null,
|
|
397
|
+
onSearchSubmitted = null,
|
|
398
|
+
onNotifications = null,
|
|
399
|
+
showNotificationBadge = false,
|
|
400
|
+
notifications = null,
|
|
401
|
+
onCreate = null,
|
|
402
|
+
createLabel = 'Create',
|
|
403
|
+
avatarGradient = KasyAvatarGradients.orange,
|
|
404
|
+
avatar = null,
|
|
405
|
+
showAvatar = true,
|
|
406
|
+
onAvatarTap = null,
|
|
407
|
+
onToggleTheme = null,
|
|
408
|
+
showSearch = true,
|
|
409
|
+
showNotifications = true,
|
|
410
|
+
showCreate = true;
|
|
411
|
+
|
|
412
|
+
/// Desktop application chrome (viewport ≥ 1024px): global search, quick-create,
|
|
413
|
+
/// notifications and profile, sitting to the right of the sidebar. The
|
|
414
|
+
/// responsive counterpart of the phone/tablet page chrome (the default
|
|
415
|
+
/// constructor) — same component, the other half. The shell places this above
|
|
416
|
+
/// content inside a [KasyAppBarScope]; the page bar then hides on desktop.
|
|
417
|
+
const KasyAppBar.application({
|
|
418
|
+
super.key,
|
|
419
|
+
this.searchController,
|
|
420
|
+
this.searchHint = 'Search...',
|
|
421
|
+
this.onSearchChanged,
|
|
422
|
+
this.onSearchSubmitted,
|
|
423
|
+
this.onNotifications,
|
|
424
|
+
this.showNotificationBadge = false,
|
|
425
|
+
this.notifications,
|
|
426
|
+
this.onCreate,
|
|
427
|
+
this.createLabel = 'Create',
|
|
428
|
+
this.avatarGradient = KasyAvatarGradients.orange,
|
|
429
|
+
this.avatar,
|
|
430
|
+
this.showAvatar = true,
|
|
431
|
+
this.onAvatarTap,
|
|
432
|
+
this.onToggleTheme,
|
|
433
|
+
this.showSearch = true,
|
|
434
|
+
this.showNotifications = true,
|
|
435
|
+
this.showCreate = true,
|
|
436
|
+
}) : _variant = _KasyAppBarVariant.application,
|
|
437
|
+
title = '',
|
|
438
|
+
style = KasyAppBarStyle.rootTab,
|
|
439
|
+
onBack = null,
|
|
440
|
+
trailing = null,
|
|
441
|
+
leading = null,
|
|
442
|
+
useSafeArea = true,
|
|
443
|
+
onThemeToggle = null,
|
|
444
|
+
toolbarHeight = null,
|
|
445
|
+
topInset = null,
|
|
446
|
+
hideOnScroll = false;
|
|
447
|
+
|
|
448
|
+
/// Builds the desktop application bar from a [KasyAppBarConfig] — the bridge
|
|
449
|
+
/// the shell uses to render whatever a screen published. Theme/search/create/
|
|
450
|
+
/// notifications appear per the config's `showX` flags.
|
|
451
|
+
factory KasyAppBar.fromConfig(KasyAppBarConfig config, {Key? key}) {
|
|
452
|
+
return KasyAppBar.application(
|
|
453
|
+
key: key,
|
|
454
|
+
showSearch: config.showSearch,
|
|
455
|
+
searchController: config.searchController,
|
|
456
|
+
searchHint: config.searchHint,
|
|
457
|
+
onSearchChanged: config.onSearchChanged,
|
|
458
|
+
onSearchSubmitted: config.onSearchSubmitted,
|
|
459
|
+
onToggleTheme: config.showThemeToggle ? config.onToggleTheme : null,
|
|
460
|
+
showNotifications: config.showNotifications,
|
|
461
|
+
notifications: config.notifications,
|
|
462
|
+
onNotifications: config.onNotifications,
|
|
463
|
+
showNotificationBadge: config.showNotificationBadge,
|
|
464
|
+
showCreate: config.showCreate,
|
|
465
|
+
createLabel: config.createLabel,
|
|
466
|
+
onCreate: config.onCreate,
|
|
467
|
+
showAvatar: config.showAvatar,
|
|
468
|
+
avatar: config.avatar,
|
|
469
|
+
avatarGradient: config.avatarGradient ?? KasyAvatarGradients.orange,
|
|
470
|
+
onAvatarTap: config.onAvatarTap,
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/// True for the desktop application bar ([KasyAppBar.application]); false for
|
|
475
|
+
/// the phone/tablet page bar. Lets tests assert which chrome is mounted without
|
|
476
|
+
/// reaching for a private type.
|
|
477
|
+
bool get isApplication => _variant == _KasyAppBarVariant.application;
|
|
285
478
|
|
|
286
479
|
@override
|
|
287
480
|
Widget build(BuildContext context) {
|
|
288
|
-
|
|
289
|
-
|
|
481
|
+
return switch (_variant) {
|
|
482
|
+
_KasyAppBarVariant.application => _buildApplicationChrome(context),
|
|
483
|
+
_KasyAppBarVariant.page => _buildPageChrome(context),
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
Widget _buildPageChrome(BuildContext context) {
|
|
488
|
+
// On desktop the application bar owns the top chrome, so the page bar hides —
|
|
489
|
+
// but ONLY when there actually is one above (i.e. inside the shell's
|
|
490
|
+
// KasyAppBarScope). A full-screen route pushed over the shell has none, so
|
|
491
|
+
// the bar stays visible and its back button is never lost.
|
|
492
|
+
if (MediaQuery.sizeOf(context).width >= DeviceType.large.breakpoint &&
|
|
493
|
+
KasyAppBarScope.of(context)) {
|
|
290
494
|
return const SizedBox.shrink();
|
|
291
495
|
}
|
|
292
496
|
final Color orbFg = context.colors.onSurface;
|
|
@@ -450,6 +654,134 @@ class KasyAppBar extends StatelessWidget {
|
|
|
450
654
|
);
|
|
451
655
|
}
|
|
452
656
|
}
|
|
657
|
+
|
|
658
|
+
/// Desktop application chrome: search + theme + notifications + create + avatar,
|
|
659
|
+
/// matching [KasySidebar]'s surface fill and hairline border so the two read as
|
|
660
|
+
/// one continuous chrome across the top of the shell.
|
|
661
|
+
Widget _buildApplicationChrome(BuildContext context) {
|
|
662
|
+
final KasyColors c = context.colors;
|
|
663
|
+
|
|
664
|
+
// Trailing controls, each added with a leading gap so spacing never doubles
|
|
665
|
+
// up when an element is hidden (e.g. no create button → no stray gap).
|
|
666
|
+
final List<Widget> trailing = <Widget>[];
|
|
667
|
+
void addTrailing(Widget w) {
|
|
668
|
+
if (trailing.isNotEmpty) {
|
|
669
|
+
trailing.add(const SizedBox(width: KasySpacing.md));
|
|
670
|
+
}
|
|
671
|
+
trailing.add(w);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (onToggleTheme != null) addTrailing(_buildApplicationThemeToggle(context));
|
|
675
|
+
if (showNotifications) {
|
|
676
|
+
addTrailing(notifications ?? _buildApplicationNotifications(context));
|
|
677
|
+
}
|
|
678
|
+
if (showCreate) {
|
|
679
|
+
addTrailing(KasyButton(
|
|
680
|
+
label: createLabel,
|
|
681
|
+
variant: KasyButtonVariant.neutral,
|
|
682
|
+
size: KasyButtonSize.small,
|
|
683
|
+
onPressed: onCreate,
|
|
684
|
+
));
|
|
685
|
+
}
|
|
686
|
+
if (showAvatar) {
|
|
687
|
+
addTrailing(avatar ??
|
|
688
|
+
KasyAvatar.gradientFill(
|
|
689
|
+
size: KasyAvatarSize.small,
|
|
690
|
+
diameter: 36,
|
|
691
|
+
gradient: avatarGradient,
|
|
692
|
+
showShadow: false,
|
|
693
|
+
onTap: onAvatarTap,
|
|
694
|
+
));
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
return DecoratedBox(
|
|
698
|
+
decoration: BoxDecoration(
|
|
699
|
+
color: c.surface,
|
|
700
|
+
border: Border(
|
|
701
|
+
bottom: BorderSide(color: c.border, width: 0.5),
|
|
702
|
+
),
|
|
703
|
+
),
|
|
704
|
+
child: ConstrainedBox(
|
|
705
|
+
constraints: const BoxConstraints(minHeight: kasyAppBarApplicationHeight),
|
|
706
|
+
child: Padding(
|
|
707
|
+
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
|
708
|
+
child: Row(
|
|
709
|
+
children: [
|
|
710
|
+
if (showSearch)
|
|
711
|
+
SizedBox(
|
|
712
|
+
width: kasyAppBarApplicationSearchWidth,
|
|
713
|
+
child: _buildApplicationSearch(context),
|
|
714
|
+
),
|
|
715
|
+
const Spacer(),
|
|
716
|
+
...trailing,
|
|
717
|
+
],
|
|
718
|
+
),
|
|
719
|
+
),
|
|
720
|
+
),
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
Widget _buildApplicationSearch(BuildContext context) {
|
|
725
|
+
return KasyTextField(
|
|
726
|
+
variant: KasyTextFieldVariant.flat,
|
|
727
|
+
controller: searchController,
|
|
728
|
+
hint: searchHint,
|
|
729
|
+
onChanged: onSearchChanged,
|
|
730
|
+
onSubmitted: onSearchSubmitted,
|
|
731
|
+
prefix: Icon(
|
|
732
|
+
KasyIcons.search,
|
|
733
|
+
size: KasyIconSize.md,
|
|
734
|
+
color: context.colors.muted,
|
|
735
|
+
),
|
|
736
|
+
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
Widget _buildApplicationThemeToggle(BuildContext context) {
|
|
741
|
+
final bool isDark = Theme.of(context).brightness == Brightness.dark;
|
|
742
|
+
return KasyButton.iconOnly(
|
|
743
|
+
icon: isDark ? KasyIcons.lightMode : KasyIcons.darkMode,
|
|
744
|
+
variant: KasyButtonVariant.ghost,
|
|
745
|
+
size: KasyButtonSize.small,
|
|
746
|
+
iconOnlyLayoutExtent: 36,
|
|
747
|
+
iconGlyphSize: KasyIconSize.md,
|
|
748
|
+
onPressed: onToggleTheme,
|
|
749
|
+
semanticLabel: isDark ? 'Light mode' : 'Dark mode',
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
Widget _buildApplicationNotifications(BuildContext context) {
|
|
754
|
+
final KasyColors c = context.colors;
|
|
755
|
+
final Widget bell = KasyButton.iconOnly(
|
|
756
|
+
icon: KasyIcons.notification,
|
|
757
|
+
variant: KasyButtonVariant.ghost,
|
|
758
|
+
size: KasyButtonSize.small,
|
|
759
|
+
iconOnlyLayoutExtent: 36,
|
|
760
|
+
iconGlyphSize: KasyIconSize.md,
|
|
761
|
+
onPressed: onNotifications,
|
|
762
|
+
semanticLabel: 'Notifications',
|
|
763
|
+
);
|
|
764
|
+
if (!showNotificationBadge) return bell;
|
|
765
|
+
return Stack(
|
|
766
|
+
clipBehavior: Clip.none,
|
|
767
|
+
children: [
|
|
768
|
+
bell,
|
|
769
|
+
Positioned(
|
|
770
|
+
top: 8,
|
|
771
|
+
right: 8,
|
|
772
|
+
child: Container(
|
|
773
|
+
width: 8,
|
|
774
|
+
height: 8,
|
|
775
|
+
decoration: BoxDecoration(
|
|
776
|
+
color: c.error,
|
|
777
|
+
shape: BoxShape.circle,
|
|
778
|
+
border: Border.all(color: c.background, width: 1.5),
|
|
779
|
+
),
|
|
780
|
+
),
|
|
781
|
+
),
|
|
782
|
+
],
|
|
783
|
+
);
|
|
784
|
+
}
|
|
453
785
|
}
|
|
454
786
|
|
|
455
787
|
/// Full-screen scaffold: frosted [KasyAppBar] pinned over scroll content.
|
|
@@ -461,6 +793,13 @@ class KasyOverlayScaffold extends StatelessWidget {
|
|
|
461
793
|
final Widget? trailing;
|
|
462
794
|
final List<Widget> slivers;
|
|
463
795
|
final EdgeInsetsGeometry? contentPadding;
|
|
796
|
+
|
|
797
|
+
/// When set, content is centered within this max width on wide viewports
|
|
798
|
+
/// (desktop) instead of stretching edge-to-edge. Use [kKasyContentMaxWidth]
|
|
799
|
+
/// for the standard single-column internal page. Ignored when
|
|
800
|
+
/// [contentPadding] is provided (that takes full control of the insets).
|
|
801
|
+
final double? maxContentWidth;
|
|
802
|
+
|
|
464
803
|
final ScrollController? scrollController;
|
|
465
804
|
final ScrollPhysics? physics;
|
|
466
805
|
final Color? backgroundColor;
|
|
@@ -480,6 +819,7 @@ class KasyOverlayScaffold extends StatelessWidget {
|
|
|
480
819
|
this.trailing,
|
|
481
820
|
required this.slivers,
|
|
482
821
|
this.contentPadding,
|
|
822
|
+
this.maxContentWidth,
|
|
483
823
|
this.scrollController,
|
|
484
824
|
this.physics,
|
|
485
825
|
this.backgroundColor,
|
|
@@ -504,6 +844,7 @@ class KasyOverlayScaffold extends StatelessWidget {
|
|
|
504
844
|
slivers: kasyOverlayPaddedSlivers(
|
|
505
845
|
context,
|
|
506
846
|
contentPadding: contentPadding,
|
|
847
|
+
maxContentWidth: maxContentWidth,
|
|
507
848
|
slivers: slivers,
|
|
508
849
|
),
|
|
509
850
|
);
|