kasy-cli 1.37.1 → 1.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/lib/scaffold/CHANGELOG.json +23 -0
  2. package/lib/scaffold/backends/api/patch/README.md +15 -0
  3. package/lib/scaffold/backends/api/patch/lib/core/data/api/user_api.dart +18 -0
  4. package/lib/scaffold/backends/api/patch/lib/features/notifications/api/device_api.dart +11 -6
  5. package/lib/scaffold/backends/api/patch/lib/features/settings/ui/components/admin/admin_users_api.dart +9 -1
  6. package/lib/scaffold/backends/api/pubspec.yaml.tpl +1 -0
  7. package/lib/scaffold/backends/patch-base-hashes.json +6 -6
  8. package/lib/scaffold/backends/supabase/edge-functions/admin-list-users/index.ts +3 -1
  9. package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/index.ts +3 -0
  10. package/lib/scaffold/backends/supabase/migrations/20240101000012_welcome_decouple_from_push.sql +62 -0
  11. package/lib/scaffold/backends/supabase/patch/lib/core/data/api/user_api.dart +8 -0
  12. package/lib/scaffold/backends/supabase/patch/lib/features/notifications/api/device_api.dart +11 -6
  13. package/lib/scaffold/backends/supabase/patch/lib/features/settings/ui/components/admin/admin_users_api.dart +7 -0
  14. package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +1 -0
  15. package/lib/scaffold/shared/generator-utils.js +12 -6
  16. package/package.json +1 -1
  17. package/templates/firebase/.firebase/hosting.YnVpbGQvd2Vi.cache +23 -23
  18. package/templates/firebase/AGENTS.md +7 -1
  19. package/templates/firebase/DESIGN_SYSTEM.md +35 -8
  20. package/templates/firebase/assets/icons/apple_black.svg +3 -0
  21. package/templates/firebase/assets/icons/apple_white.svg +4 -0
  22. package/templates/firebase/assets/icons/facebook.svg +49 -0
  23. package/templates/firebase/assets/icons/google.svg +1 -0
  24. package/templates/firebase/functions/src/admin/functions.ts +2 -0
  25. package/templates/firebase/functions/src/authentication/functions.ts +13 -7
  26. package/templates/firebase/functions/src/notifications/triggers.ts +6 -2
  27. package/templates/firebase/lib/components/components.dart +1 -1
  28. package/templates/firebase/lib/components/kasy_app_bar.dart +361 -20
  29. package/templates/firebase/lib/components/kasy_bottom_sheet.dart +283 -66
  30. package/templates/firebase/lib/components/kasy_card.dart +4 -0
  31. package/templates/firebase/lib/components/kasy_date_picker.dart +61 -46
  32. package/templates/firebase/lib/components/kasy_drop_down.dart +584 -0
  33. package/templates/firebase/lib/components/kasy_sidebar.dart +412 -31
  34. package/templates/firebase/lib/components/kasy_tabs.dart +31 -10
  35. package/templates/firebase/lib/components/kasy_text_field.dart +29 -7
  36. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +29 -231
  37. package/templates/firebase/lib/core/bottom_menu/sidebar_focus.dart +224 -0
  38. package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +19 -9
  39. package/templates/firebase/lib/core/chrome/app_bar_config.dart +214 -0
  40. package/templates/firebase/lib/core/chrome/app_bar_scope.dart +102 -0
  41. package/templates/firebase/lib/core/data/api/user_api.dart +15 -0
  42. package/templates/firebase/lib/core/data/repositories/user_repository.dart +5 -0
  43. package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +525 -65
  44. package/templates/firebase/lib/core/dev_inspector/dev_inspector_info.dart +47 -0
  45. package/templates/firebase/lib/core/dev_inspector/dev_inspector_service.dart +55 -15
  46. package/templates/firebase/lib/core/icons/kasy_icons.dart +16 -1
  47. package/templates/firebase/lib/core/rating/widgets/review_popup.dart +18 -35
  48. package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +11 -0
  49. package/templates/firebase/lib/core/states/logout_action.dart +11 -1
  50. package/templates/firebase/lib/core/states/user_state_notifier.dart +69 -1
  51. package/templates/firebase/lib/core/theme/texts.dart +21 -6
  52. package/templates/firebase/lib/core/theme/type_scale.dart +34 -15
  53. package/templates/firebase/lib/core/theme/universal_theme.dart +9 -0
  54. package/templates/firebase/lib/core/web_device_preview/png_clipboard.dart +14 -0
  55. package/templates/firebase/lib/core/web_device_preview/png_clipboard_io.dart +9 -0
  56. package/templates/firebase/lib/core/web_device_preview/png_clipboard_web.dart +36 -0
  57. package/templates/firebase/lib/core/web_device_preview/png_export_result.dart +2 -0
  58. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +547 -483
  59. package/templates/firebase/lib/core/web_viewport_scale.dart +64 -35
  60. package/templates/firebase/lib/core/widgets/kasy_pressable_depth.dart +14 -3
  61. package/templates/firebase/lib/core/widgets/responsive_layout.dart +8 -0
  62. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_composer.dart +1 -1
  63. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_conversation_view.dart +52 -35
  64. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_list.dart +1 -1
  65. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +18 -8
  66. package/templates/firebase/lib/features/authentication/ui/signin_page.dart +11 -61
  67. package/templates/firebase/lib/features/authentication/ui/signup_page.dart +11 -61
  68. package/templates/firebase/lib/features/authentication/ui/widgets/auth_card_scaffold.dart +7 -5
  69. package/templates/firebase/lib/features/authentication/ui/widgets/social_auth_tile.dart +83 -0
  70. package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +4 -4
  71. package/templates/firebase/lib/features/home/home_components_page.dart +264 -126
  72. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +231 -57
  73. package/templates/firebase/lib/features/home/home_feed.dart +2 -2
  74. package/templates/firebase/lib/features/home/home_image_grid.dart +3 -3
  75. package/templates/firebase/lib/features/local_reminders/providers/reminder_notifier.dart +8 -1
  76. package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +118 -57
  77. package/templates/firebase/lib/features/notifications/api/device_api.dart +11 -3
  78. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +19 -4
  79. package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +7 -56
  80. package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +5 -5
  81. package/templates/firebase/lib/features/notifications/ui/widgets/push_permission_banner.dart +163 -0
  82. package/templates/firebase/lib/features/notifications/ui/widgets/web_notifications_bell.dart +262 -0
  83. package/templates/firebase/lib/features/onboarding/providers/onboarding_model.dart +9 -0
  84. package/templates/firebase/lib/features/onboarding/providers/onboarding_provider.dart +28 -0
  85. package/templates/firebase/lib/features/onboarding/ui/onboarding_page.dart +51 -12
  86. package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +90 -32
  87. package/templates/firebase/lib/features/settings/settings_page.dart +99 -65
  88. package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +1379 -422
  89. package/templates/firebase/lib/features/settings/ui/components/admin/admin_routes.dart +14 -4
  90. package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_api.dart +7 -0
  91. package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_tab.dart +445 -233
  92. package/templates/firebase/lib/features/settings/ui/components/admin/send_push_notification_page.dart +404 -149
  93. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +24 -31
  94. package/templates/firebase/lib/features/settings/ui/components/delete_user_component.dart +9 -1
  95. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +77 -95
  96. package/templates/firebase/lib/features/settings/ui/widgets/settings_bottom_sheet_option_tile.dart +21 -18
  97. package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +25 -10
  98. package/templates/firebase/lib/i18n/en.i18n.json +749 -698
  99. package/templates/firebase/lib/i18n/es.i18n.json +749 -698
  100. package/templates/firebase/lib/i18n/pt.i18n.json +749 -698
  101. package/templates/firebase/lib/main.dart +20 -7
  102. package/templates/firebase/lib/router.dart +70 -46
  103. package/templates/firebase/pubspec.yaml +2 -1
  104. package/templates/firebase/test/admin_shell_chrome_test.dart +110 -0
  105. package/templates/firebase/test/components/kasy_text_field_height_test.dart +77 -0
  106. package/templates/firebase/test/core/web_viewport_scale_test.dart +23 -16
  107. package/templates/firebase/test/features/authentication/data/api/user_api_fake.dart +3 -0
  108. package/templates/firebase/tool/design_check.dart +9 -0
  109. package/templates/firebase/assets/icons/apple.png +0 -0
  110. package/templates/firebase/assets/icons/facebook.png +0 -0
  111. package/templates/firebase/assets/icons/google.png +0 -0
  112. package/templates/firebase/assets/icons/google_play_games.png +0 -0
  113. package/templates/firebase/lib/components/kasy_web_header.dart +0 -210
  114. package/templates/firebase/lib/features/authentication/ui/components/apple_signin.dart +0 -19
  115. package/templates/firebase/lib/features/authentication/ui/components/google_signin.dart +0 -32
  116. package/templates/firebase/lib/features/authentication/ui/widgets/round_signin.dart +0 -73
  117. package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +0 -66
  118. package/templates/firebase/lib/features/notifications/ui/components/notification_settings_sheet.dart +0 -169
  119. package/templates/firebase/lib/features/notifications/ui/components/push_notification_switcher.dart +0 -106
  120. package/templates/firebase/lib/features/settings/ui/components/admin/admin_paywalls.dart +0 -53
@@ -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
- // 1. Delete user from Firebase Auth first if this fails, data is still recoverable
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 userDevices = await userDevicesRepository.getDevices([userId]);
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 found for user ${userId}`);
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. [KasyAppBar] serves phone and tablet only — on
38
- /// desktop the richer web header takes over — so the band keeps a single
39
- /// compact height across every viewport where the app bar appears.
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 app bar hides (the web header owns the top chrome).
60
- if (MediaQuery.sizeOf(context).width >= DeviceType.large.breakpoint) {
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
- final EdgeInsetsGeometry pad =
79
- contentPadding ??
80
- EdgeInsets.fromLTRB(
81
- KasySpacing.pageHorizontalGutter,
82
- KasySpacing.belowChromeContentGap,
83
- KasySpacing.pageHorizontalGutter,
84
- MediaQuery.paddingOf(context).bottom + KasySpacing.pageVerticalGutter,
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
- SliverPadding(
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
- // On desktop the web header owns the top chrome, so the page app bar hides.
289
- if (MediaQuery.sizeOf(context).width >= DeviceType.large.breakpoint) {
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
  );