kasy-cli 1.32.0 → 1.35.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 (169) hide show
  1. package/README.md +1 -1
  2. package/bin/kasy.js +66 -2
  3. package/docs/cli-reference.md +7 -7
  4. package/lib/commands/apple-web.js +222 -0
  5. package/lib/commands/configure.js +3 -91
  6. package/lib/commands/doctor.js +20 -0
  7. package/lib/commands/facebook.js +189 -0
  8. package/lib/commands/new.js +61 -11
  9. package/lib/commands/release-version.js +234 -0
  10. package/lib/commands/update.js +27 -0
  11. package/lib/scaffold/CHANGELOG.json +27 -0
  12. package/lib/scaffold/backends/api/patch/lib/features/authentication/api/authentication_api.dart +24 -0
  13. package/lib/scaffold/backends/api/patch/lib/features/authentication/api/authentication_api_interface.dart +15 -0
  14. package/lib/scaffold/backends/api/patch/lib/features/authentication/repositories/authentication_repository.dart +27 -0
  15. package/lib/scaffold/backends/api/patch/lib/features/subscriptions/api/entities/subscription_entity.dart +1 -0
  16. package/lib/scaffold/backends/firebase/setup-from-scratch.js +199 -21
  17. package/lib/scaffold/backends/patch-base-hashes.json +66 -0
  18. package/lib/scaffold/backends/supabase/deploy.js +92 -0
  19. package/lib/scaffold/backends/supabase/edge-functions/stripe-webhook/index.ts +2 -0
  20. package/lib/scaffold/backends/supabase/migrations/20240101000007_welcome_notification.sql +36 -23
  21. package/lib/scaffold/backends/supabase/migrations/20240101000014_subscription_trial_end.sql +6 -0
  22. package/lib/scaffold/backends/supabase/patch/lib/features/authentication/api/authentication_api.dart +92 -3
  23. package/lib/scaffold/backends/supabase/patch/lib/features/authentication/repositories/authentication_repository.dart +36 -0
  24. package/lib/scaffold/backends/supabase/patch/lib/features/subscriptions/api/entities/subscription_entity.dart +1 -0
  25. package/lib/scaffold/generate.js +53 -4
  26. package/lib/scaffold/shared/generator-utils.js +18 -6
  27. package/lib/utils/apple-web.js +147 -0
  28. package/lib/utils/facebook.js +162 -0
  29. package/lib/utils/i18n/messages-en.js +85 -0
  30. package/lib/utils/i18n/messages-es.js +85 -0
  31. package/lib/utils/i18n/messages-pt.js +85 -0
  32. package/package.json +5 -2
  33. package/templates/firebase/.firebase/hosting.YnVpbGQvd2Vi.cache +73 -0
  34. package/templates/firebase/AGENTS.md +170 -0
  35. package/templates/firebase/CLAUDE.md +16 -0
  36. package/templates/firebase/DESIGN_SYSTEM.md +269 -0
  37. package/templates/firebase/docs/auth-setup.en.md +4 -2
  38. package/templates/firebase/docs/auth-setup.es.md +4 -2
  39. package/templates/firebase/docs/auth-setup.pt.md +4 -2
  40. package/templates/firebase/firebase.json +56 -1
  41. package/templates/firebase/functions/src/authentication/triggers.ts +10 -6
  42. package/templates/firebase/functions/src/core/data/entities/subscription_entity.ts +5 -0
  43. package/templates/firebase/functions/src/core/data/entities/user_entity.ts +9 -1
  44. package/templates/firebase/functions/src/core/data/repositories/user_repository.ts +27 -0
  45. package/templates/firebase/functions/src/subscriptions/models/subscriptions.ts +3 -0
  46. package/templates/firebase/functions/src/subscriptions/stripe_functions.ts +4 -0
  47. package/templates/firebase/lib/components/components.dart +1 -0
  48. package/templates/firebase/lib/components/kasy_alert.dart +0 -1
  49. package/templates/firebase/lib/components/kasy_app_bar.dart +35 -17
  50. package/templates/firebase/lib/components/kasy_bottom_sheet.dart +0 -1
  51. package/templates/firebase/lib/components/kasy_date_picker.dart +0 -5
  52. package/templates/firebase/lib/components/kasy_dialog.dart +0 -1
  53. package/templates/firebase/lib/components/kasy_otp_verification_bottom_sheet.dart +1 -1
  54. package/templates/firebase/lib/components/kasy_screen.dart +114 -0
  55. package/templates/firebase/lib/components/kasy_sidebar.dart +189 -120
  56. package/templates/firebase/lib/components/kasy_text_area.dart +0 -1
  57. package/templates/firebase/lib/components/kasy_text_field.dart +0 -1
  58. package/templates/firebase/lib/components/kasy_text_field_otp.dart +0 -1
  59. package/templates/firebase/lib/components/kasy_toast.dart +108 -73
  60. package/templates/firebase/lib/core/app_update/app_update_repository.dart +54 -0
  61. package/templates/firebase/lib/core/app_update/app_update_status.dart +14 -0
  62. package/templates/firebase/lib/core/app_update/update_available_sheet.dart +70 -0
  63. package/templates/firebase/lib/core/bottom_menu/active_tab_notifier.dart +40 -3
  64. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +82 -34
  65. package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +6 -6
  66. package/templates/firebase/lib/core/bottom_menu/web_url.dart +20 -0
  67. package/templates/firebase/lib/core/chrome/chrome_visibility.dart +22 -0
  68. package/templates/firebase/lib/core/config/features.dart +5 -0
  69. package/templates/firebase/lib/core/data/api/remote_config_api.dart +38 -1
  70. package/templates/firebase/lib/core/guards/guard.dart +16 -2
  71. package/templates/firebase/lib/core/icons/kasy_icons.dart +3 -0
  72. package/templates/firebase/lib/core/rating/widgets/rate_banner.dart +2 -5
  73. package/templates/firebase/lib/core/rating/widgets/review_popup.dart +48 -124
  74. package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +14 -0
  75. package/templates/firebase/lib/core/states/components/maybe_show_update_available.dart +32 -0
  76. package/templates/firebase/lib/core/states/logout_action.dart +5 -1
  77. package/templates/firebase/lib/core/states/user_state_notifier.dart +85 -14
  78. package/templates/firebase/lib/core/theme/responsive_text_theme.dart +69 -0
  79. package/templates/firebase/lib/core/theme/texts.dart +90 -57
  80. package/templates/firebase/lib/core/theme/type_scale.dart +77 -0
  81. package/templates/firebase/lib/core/theme/web_background_sync_web.dart +20 -6
  82. package/templates/firebase/lib/core/utils/image_bytes_loader.dart +12 -0
  83. package/templates/firebase/lib/core/utils/image_bytes_loader_io.dart +28 -0
  84. package/templates/firebase/lib/core/utils/image_bytes_loader_web.dart +56 -0
  85. package/templates/firebase/lib/core/web_screen_width.dart +15 -0
  86. package/templates/firebase/lib/core/web_screen_width_io.dart +3 -0
  87. package/templates/firebase/lib/core/web_screen_width_web.dart +10 -0
  88. package/templates/firebase/lib/core/web_viewport_scale.dart +61 -25
  89. package/templates/firebase/lib/core/widgets/focus_visibility.dart +89 -0
  90. package/templates/firebase/lib/core/widgets/update_bottom_sheet.dart +29 -126
  91. package/templates/firebase/lib/features/ai_chat/ai_chat_page.dart +11 -8
  92. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_composer.dart +21 -0
  93. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_conversation_view.dart +1 -2
  94. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_list.dart +2 -3
  95. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +1 -2
  96. package/templates/firebase/lib/features/authentication/api/auth_web_support.dart +12 -0
  97. package/templates/firebase/lib/features/authentication/api/auth_web_support_web.dart +25 -0
  98. package/templates/firebase/lib/features/authentication/api/authentication_api.dart +266 -0
  99. package/templates/firebase/lib/features/authentication/api/authentication_api_interface.dart +22 -0
  100. package/templates/firebase/lib/features/authentication/navigation/post_login_navigation.dart +32 -0
  101. package/templates/firebase/lib/features/authentication/providers/phone_auth_notifier.dart +7 -7
  102. package/templates/firebase/lib/features/authentication/providers/signin_state_provider.dart +34 -10
  103. package/templates/firebase/lib/features/authentication/providers/signup_state_provider.dart +2 -2
  104. package/templates/firebase/lib/features/authentication/repositories/authentication_repository.dart +37 -0
  105. package/templates/firebase/lib/features/authentication/repositories/exceptions/authentication_exceptions.dart +8 -0
  106. package/templates/firebase/lib/features/authentication/ui/components/otp_verification.dart +2 -2
  107. package/templates/firebase/lib/features/authentication/ui/components/phone_input.dart +2 -2
  108. package/templates/firebase/lib/features/authentication/ui/signin_page.dart +80 -15
  109. package/templates/firebase/lib/features/authentication/ui/signup_page.dart +20 -14
  110. package/templates/firebase/lib/features/authentication/ui/widgets/auth_card_scaffold.dart +2 -2
  111. package/templates/firebase/lib/features/authentication/ui/widgets/recover_password_result.dart +2 -2
  112. package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +0 -1
  113. package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +1 -2
  114. package/templates/firebase/lib/features/home/design_system_page.dart +134 -67
  115. package/templates/firebase/lib/features/home/home_components_page.dart +8 -1
  116. package/templates/firebase/lib/features/home/home_components_preview_page.dart +1 -3
  117. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +186 -56
  118. package/templates/firebase/lib/features/home/home_page.dart +4 -0
  119. package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +169 -208
  120. package/templates/firebase/lib/features/notifications/providers/notifications_provider.dart +10 -0
  121. package/templates/firebase/lib/features/notifications/ui/components/notification_settings_sheet.dart +2 -2
  122. package/templates/firebase/lib/features/notifications/ui/components/notification_tile.dart +21 -8
  123. package/templates/firebase/lib/features/notifications/ui/components/push_notification_switcher.dart +2 -2
  124. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +3 -4
  125. package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +84 -128
  126. package/templates/firebase/lib/features/onboarding/providers/onboarding_model.dart +11 -0
  127. package/templates/firebase/lib/features/onboarding/providers/onboarding_provider.dart +30 -3
  128. package/templates/firebase/lib/features/onboarding/ui/components/onboarding_loader.dart +13 -4
  129. package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_illustration_scaffold.dart +3 -4
  130. package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +2 -4
  131. package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +2 -1
  132. package/templates/firebase/lib/features/settings/settings_page.dart +152 -11
  133. package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +58 -21
  134. package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_tab.dart +12 -12
  135. package/templates/firebase/lib/features/settings/ui/components/admin/send_push_notification_page.dart +1 -4
  136. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +2 -2
  137. package/templates/firebase/lib/features/settings/ui/components/create_password_sheet.dart +141 -0
  138. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +0 -1
  139. package/templates/firebase/lib/features/settings/ui/widgets/admin_card.dart +42 -38
  140. package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +17 -2
  141. package/templates/firebase/lib/features/subscriptions/api/entities/subscription_entity.dart +3 -0
  142. package/templates/firebase/lib/features/subscriptions/api/stripe_product.dart +12 -11
  143. package/templates/firebase/lib/features/subscriptions/repositories/subscription_repository.dart +25 -2
  144. package/templates/firebase/lib/features/subscriptions/ui/component/active_premium_content.dart +319 -143
  145. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_minimal.dart +1 -5
  146. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_row.dart +1 -5
  147. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_with_switch.dart +2 -5
  148. package/templates/firebase/lib/features/subscriptions/ui/component/premium_content.dart +1 -4
  149. package/templates/firebase/lib/features/subscriptions/ui/widgets/feature_line.dart +1 -2
  150. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_banner.dart +2 -7
  151. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_feature.dart +2 -4
  152. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_col.dart +1 -4
  153. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_row.dart +0 -3
  154. package/templates/firebase/lib/i18n/en.i18n.json +54 -7
  155. package/templates/firebase/lib/i18n/es.i18n.json +54 -7
  156. package/templates/firebase/lib/i18n/pt.i18n.json +54 -7
  157. package/templates/firebase/lib/main.dart +11 -2
  158. package/templates/firebase/lib/router.dart +94 -13
  159. package/templates/firebase/pubspec.yaml +1 -1
  160. package/templates/firebase/test/core/data/api/fake_remote_config_api.dart +14 -0
  161. package/templates/firebase/test/core/states/user_state_notifier_test.dart +47 -3
  162. package/templates/firebase/test/core/web_viewport_scale_test.dart +68 -0
  163. package/templates/firebase/test/features/authentication/data/api/auth_api_fake.dart +15 -0
  164. package/templates/firebase/tool/design_check.dart +152 -0
  165. package/templates/firebase/web/index.html +162 -14
  166. package/templates/firebase/assets/images/review.png +0 -0
  167. package/templates/firebase/assets/images/update.png +0 -0
  168. package/templates/firebase/lib/core/guards/user_info_guard.dart +0 -61
  169. package/templates/firebase/lib/features/notifications/ui/components/notifications_header.dart +0 -32
@@ -3,6 +3,8 @@ import 'dart:async';
3
3
  import 'package:flutter/material.dart';
4
4
  import 'package:kasy_kit/components/kasy_button.dart';
5
5
  import 'package:kasy_kit/core/theme/theme.dart';
6
+ import 'package:kasy_kit/core/widgets/kasy_brand_logo.dart';
7
+ import 'package:kasy_kit/i18n/translations.g.dart';
6
8
  import 'package:universal_io/io.dart';
7
9
 
8
10
  // ---------------------------------------------------------------------------
@@ -45,35 +47,38 @@ class KasyToast extends StatelessWidget {
45
47
  Widget build(BuildContext context) {
46
48
  final KasyColors c = context.colors;
47
49
  final bool isDark = context.isDark;
48
- final _Palette pal = _palette(c, tone);
50
+ final _ToastStyle style = _resolveStyle(c, tone);
51
+
52
+ // A toast must never render an empty title line. If a caller passes a blank
53
+ // title (e.g. a short confirmation that only has a body), promote the
54
+ // message to the title so it always reads as a clean single-line toast.
55
+ final bool hasTitle = title.trim().isNotEmpty;
56
+ final String shownTitle = hasTitle ? title : (message ?? '');
57
+ final String? shownMessage = hasTitle ? message : null;
49
58
 
50
59
  return DecoratedBox(
51
60
  decoration: BoxDecoration(
52
61
  color: c.surface,
53
62
  borderRadius: KasyRadius.lgBorderRadius,
54
- border: Border.all(
55
- color: c.outline.withValues(alpha: isDark ? 0.22 : 0.28),
56
- ),
63
+ border: Border.all(color: c.outline.withValues(alpha: 0.20)),
57
64
  boxShadow: [
58
65
  BoxShadow(
59
- color: Colors.black.withValues(alpha: isDark ? 0.12 : 0.04),
60
- blurRadius: 10,
61
- offset: const Offset(0, 4),
66
+ color: Colors.black.withValues(alpha: isDark ? 0.22 : 0.06),
67
+ blurRadius: 18,
68
+ offset: const Offset(0, 6),
62
69
  ),
63
70
  ],
64
71
  ),
65
72
  child: Padding(
66
- padding: const EdgeInsets.symmetric(
67
- horizontal: KasySpacing.md,
68
- vertical: 14,
69
- ),
73
+ padding: const EdgeInsets.all(KasySpacing.smd),
70
74
  child: Row(
71
- crossAxisAlignment: CrossAxisAlignment.start,
75
+ // Center everything for a single-line toast (no message) so nothing
76
+ // sits low; top-align when a longer message wraps.
77
+ crossAxisAlignment: shownMessage != null
78
+ ? CrossAxisAlignment.start
79
+ : CrossAxisAlignment.center,
72
80
  children: [
73
- Padding(
74
- padding: const EdgeInsets.only(top: 2),
75
- child: icon ?? Icon(pal.icon, size: KasyIconSize.xl, color: pal.accent),
76
- ),
81
+ _buildLeading(style),
77
82
  const SizedBox(width: KasySpacing.smd),
78
83
  Expanded(
79
84
  child: Column(
@@ -81,45 +86,68 @@ class KasyToast extends StatelessWidget {
81
86
  mainAxisSize: MainAxisSize.min,
82
87
  children: [
83
88
  Text(
84
- title,
85
- style: context.textTheme.titleMedium?.copyWith(
86
- color: pal.accent,
87
- fontWeight: FontWeight.w700,
89
+ shownTitle,
90
+ style: context.textTheme.titleSmall?.copyWith(
91
+ color: c.onSurface,
92
+ fontWeight: FontWeight.w600,
88
93
  ),
89
94
  ),
90
- if (message != null) ...[
91
- const SizedBox(height: 4),
95
+ if (shownMessage != null) ...[
96
+ const SizedBox(height: 2),
92
97
  Text(
93
- message!,
98
+ shownMessage,
94
99
  style: context.textTheme.bodyMedium?.copyWith(
95
- // Slightly darker than muted in light, slightly lighter in dark.
96
- color: Color.lerp(
97
- c.muted,
98
- isDark ? Colors.white : Colors.black,
99
- 0.18,
100
- ),
100
+ color: c.muted,
101
+ height: 1.35,
101
102
  ),
102
103
  ),
103
104
  ],
104
105
  ],
105
106
  ),
106
107
  ),
107
- const SizedBox(width: KasySpacing.smd),
108
- Padding(
109
- padding: const EdgeInsets.only(top: 1),
110
- child: KasyButton(
111
- label: 'Close',
112
- size: KasyButtonSize.small,
113
- backgroundColor: pal.buttonBg,
114
- foregroundColor: pal.buttonFg,
115
- onPressed: onClose,
116
- ),
108
+ const SizedBox(width: KasySpacing.sm),
109
+ // A real "Close" text button (HeroUI-style), not a bare X. Always
110
+ // neutral (black in light / white in dark), since the bar itself is
111
+ // never coloured. Fully rounded (pill). Localised in every language.
112
+ KasyButton(
113
+ label: Translations.of(context).common.close,
114
+ variant: KasyButtonVariant.ghost,
115
+ size: KasyButtonSize.small,
116
+ backgroundColor:
117
+ c.onSurface.withValues(alpha: isDark ? 0.14 : 0.06),
118
+ foregroundColor: c.onSurface,
119
+ fontWeight: FontWeight.w600,
120
+ borderRadius: BorderRadius.circular(KasyRadius.full),
121
+ onPressed: onClose,
117
122
  ),
118
123
  ],
119
124
  ),
120
125
  ),
121
126
  );
122
127
  }
128
+
129
+ /// The leading slot. A fully custom [icon] wins; otherwise the neutral tone
130
+ /// shows the app brand logo (so it always matches the splash / current brand)
131
+ /// and the semantic tones show a tone-coloured icon bubble.
132
+ Widget _buildLeading(_ToastStyle style) {
133
+ if (icon != null) return icon!;
134
+ if (style.useBrandLogo) {
135
+ return const SizedBox(
136
+ height: 32,
137
+ child: Center(child: KasyBrandLogo(height: 24)),
138
+ );
139
+ }
140
+ return Container(
141
+ width: 32,
142
+ height: 32,
143
+ alignment: Alignment.center,
144
+ decoration: BoxDecoration(
145
+ color: style.iconBubbleColor,
146
+ shape: BoxShape.circle,
147
+ ),
148
+ child: Icon(style.icon, size: KasyIconSize.sm, color: style.iconColor),
149
+ );
150
+ }
123
151
  }
124
152
 
125
153
  // ---------------------------------------------------------------------------
@@ -705,63 +733,70 @@ class _AnimatedToastState extends State<_AnimatedToast>
705
733
  // Palette resolution
706
734
  // ---------------------------------------------------------------------------
707
735
 
708
- class _Palette {
709
- const _Palette({
710
- required this.accent,
736
+ /// Fully-resolved visual style for a toast tone.
737
+ ///
738
+ /// Every toast shares the same plain surface bar, neutral title/message and a
739
+ /// neutral "Close" button — the toast is never flooded with colour. The tone is
740
+ /// carried *only by the leading icon bubble*:
741
+ /// - **neutral** shows the app brand logo (no bubble).
742
+ /// - **accent / danger** get a solid coloured bubble with a white icon inside
743
+ /// (the "white inside the blue / red").
744
+ /// - **success / warning** get a soft tinted bubble with a coloured icon.
745
+ class _ToastStyle {
746
+ const _ToastStyle({
711
747
  required this.icon,
712
- required this.buttonBg,
713
- required this.buttonFg,
748
+ required this.iconColor,
749
+ required this.iconBubbleColor,
750
+ this.useBrandLogo = false,
714
751
  });
715
752
 
716
- final Color accent;
717
753
  final IconData icon;
718
- final Color buttonBg;
719
- final Color buttonFg;
754
+ final Color iconColor;
755
+ final Color iconBubbleColor;
756
+
757
+ /// When true the leading slot shows the app brand logo (neutral tone).
758
+ final bool useBrandLogo;
720
759
  }
721
760
 
722
- _Palette _palette(KasyColors c, KasyToastTone tone) {
761
+ _ToastStyle _resolveStyle(KasyColors c, KasyToastTone tone) {
723
762
  switch (tone) {
724
763
  case KasyToastTone.neutral:
725
- return _Palette(
726
- accent: c.onSurface,
764
+ return _ToastStyle(
727
765
  icon: KasyIcons.notification,
728
- buttonBg: c.surfaceNeutralSoft,
729
- buttonFg: c.onSurface,
766
+ iconColor: c.onSurface,
767
+ iconBubbleColor: Colors.transparent,
768
+ useBrandLogo: true,
730
769
  );
770
+ // Solid bubble, white icon inside.
731
771
  case KasyToastTone.accent:
732
- return _Palette(
733
- accent: c.primary,
772
+ return _ToastStyle(
734
773
  icon: KasyIcons.info,
735
- buttonBg: c.primary,
736
- // The Close button sits on a solid, vivid tone color — keep its label
737
- // white for reliable contrast regardless of the brand "on" token.
738
- buttonFg: Colors.white,
774
+ iconColor: c.onPrimary,
775
+ iconBubbleColor: c.primary,
776
+ );
777
+ case KasyToastTone.danger:
778
+ return _ToastStyle(
779
+ icon: KasyIcons.error,
780
+ iconColor: c.onError,
781
+ iconBubbleColor: c.error,
739
782
  );
783
+ // Soft tinted bubble, coloured icon.
740
784
  case KasyToastTone.success:
741
785
  final Color successDark = HSLColor.fromColor(c.success)
742
786
  .withLightness(
743
787
  (HSLColor.fromColor(c.success).lightness - 0.09).clamp(0.0, 1.0),
744
788
  )
745
789
  .toColor();
746
- return _Palette(
747
- accent: successDark,
790
+ return _ToastStyle(
748
791
  icon: KasyIcons.checkCircle,
749
- buttonBg: successDark,
750
- buttonFg: Colors.white,
792
+ iconColor: successDark,
793
+ iconBubbleColor: c.success.withValues(alpha: 0.14),
751
794
  );
752
795
  case KasyToastTone.warning:
753
- return _Palette(
754
- accent: c.warning,
796
+ return _ToastStyle(
755
797
  icon: KasyIcons.privacy,
756
- buttonBg: c.warning,
757
- buttonFg: Colors.white,
758
- );
759
- case KasyToastTone.danger:
760
- return _Palette(
761
- accent: c.error,
762
- icon: KasyIcons.error,
763
- buttonBg: c.error,
764
- buttonFg: Colors.white,
798
+ iconColor: c.warning,
799
+ iconBubbleColor: c.warning.withValues(alpha: 0.16),
765
800
  );
766
801
  }
767
802
  }
@@ -0,0 +1,54 @@
1
+ import 'package:flutter/foundation.dart' show kIsWeb;
2
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+ import 'package:kasy_kit/core/app_update/app_update_status.dart';
4
+ import 'package:kasy_kit/core/data/api/remote_config_api.dart';
5
+ import 'package:logger/logger.dart';
6
+ import 'package:package_info_plus/package_info_plus.dart';
7
+ import 'package:pub_semver/pub_semver.dart';
8
+
9
+ final appUpdateRepositoryProvider = Provider<AppUpdateRepository>(
10
+ (ref) => AppUpdateRepository(
11
+ remoteConfig: ref.read(remoteConfigApiProvider),
12
+ ),
13
+ );
14
+
15
+ /// Decides whether the user should be prompted to update, by comparing the
16
+ /// installed version ([PackageInfo]) against the remote `app_latest_version` /
17
+ /// `app_min_version` keys.
18
+ ///
19
+ /// Backend-agnostic: the config is read from Firebase Remote Config, which is
20
+ /// available on every backend (Firebase / Supabase / API all initialize
21
+ /// Firebase Core for push). It is also native-only — the web build is never
22
+ /// considered "behind a store".
23
+ class AppUpdateRepository {
24
+ final RemoteConfigApi _remoteConfig;
25
+
26
+ AppUpdateRepository({required RemoteConfigApi remoteConfig})
27
+ : _remoteConfig = remoteConfig;
28
+
29
+ Future<AppUpdateStatus> check() async {
30
+ if (kIsWeb) return AppUpdateStatus.upToDate;
31
+ try {
32
+ final info = await PackageInfo.fromPlatform();
33
+ final Version installed = Version.parse(info.version);
34
+ final Version? latest = _tryParse(_remoteConfig.appUpdate.latestVersion.value);
35
+ final Version? min = _tryParse(_remoteConfig.appUpdate.minVersion.value);
36
+
37
+ if (min != null && installed < min) return AppUpdateStatus.forced;
38
+ if (latest != null && installed < latest) return AppUpdateStatus.optional;
39
+ return AppUpdateStatus.upToDate;
40
+ } catch (e) {
41
+ // Never block the app because of a missing/bad config or a parse error.
42
+ Logger().e('AppUpdateRepository.check failed: $e');
43
+ return AppUpdateStatus.upToDate;
44
+ }
45
+ }
46
+
47
+ Version? _tryParse(String raw) {
48
+ try {
49
+ return Version.parse(raw.trim());
50
+ } catch (_) {
51
+ return null;
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,14 @@
1
+ /// Result of comparing the installed app version against the remote config
2
+ /// (`app_latest_version` / `app_min_version`).
3
+ enum AppUpdateStatus {
4
+ /// Installed version is current — nothing to show.
5
+ upToDate,
6
+
7
+ /// A newer version exists; the update sheet is dismissible ("update now"
8
+ /// vs "not now").
9
+ optional,
10
+
11
+ /// Installed version is below the minimum allowed; the sheet blocks the app
12
+ /// until the user updates.
13
+ forced,
14
+ }
@@ -0,0 +1,70 @@
1
+ import 'package:flutter/material.dart';
2
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+ import 'package:kasy_kit/components/components.dart';
4
+ import 'package:kasy_kit/core/rating/api/rating_api.dart';
5
+ import 'package:kasy_kit/core/theme/theme.dart';
6
+ import 'package:kasy_kit/i18n/translations.g.dart';
7
+ import 'package:logger/logger.dart';
8
+
9
+ /// Shows the "update available" bottom sheet. Native-only: the caller already
10
+ /// guards `kIsWeb`.
11
+ ///
12
+ /// [forced] makes the sheet blocking — it cannot be dismissed (no drag, no
13
+ /// barrier tap, back is blocked), so the user must update. When false the sheet
14
+ /// is dismissible with a "not now" action.
15
+ Future<void> showUpdateAvailableSheet(
16
+ BuildContext context, {
17
+ required bool forced,
18
+ }) async {
19
+ await showKasyBottomSheet<void>(
20
+ context: context,
21
+ isDismissible: !forced,
22
+ enableDrag: !forced,
23
+ isScrollControlled: true,
24
+ builder: (_) => _UpdateAvailableSheet(forced: forced),
25
+ );
26
+ }
27
+
28
+ class _UpdateAvailableSheet extends ConsumerWidget {
29
+ final bool forced;
30
+
31
+ const _UpdateAvailableSheet({required this.forced});
32
+
33
+ Future<void> _openStore(WidgetRef ref) async {
34
+ try {
35
+ await ref.read(ratingApiProvider).openStoreListing();
36
+ } catch (e) {
37
+ // iOS needs a configured App Store ID; never crash the prompt.
38
+ Logger().e('openStoreListing failed: $e');
39
+ }
40
+ }
41
+
42
+ @override
43
+ Widget build(BuildContext context, WidgetRef ref) {
44
+ final tr = context.t.update_available;
45
+
46
+ return PopScope(
47
+ canPop: !forced,
48
+ child: KasyBottomSheet(
49
+ icon: KasyIcons.download,
50
+ title: forced ? tr.forced_title : tr.title,
51
+ message: forced ? tr.forced_description : tr.description,
52
+ showDragHandle: !forced,
53
+ actions: [
54
+ KasyButton(
55
+ label: tr.update_button,
56
+ expand: true,
57
+ onPressed: () => _openStore(ref),
58
+ ),
59
+ if (!forced)
60
+ KasyButton(
61
+ label: tr.later_button,
62
+ variant: KasyButtonVariant.soft,
63
+ expand: true,
64
+ onPressed: () => Navigator.of(context).pop(),
65
+ ),
66
+ ],
67
+ ),
68
+ );
69
+ }
70
+ }
@@ -6,9 +6,46 @@ import 'package:flutter/foundation.dart';
6
6
  /// phone-width frame). Persisting it lets the bottom bar restore the tab instead
7
7
  /// of snapping back to the first one on remount or hard reload (F5).
8
8
  ///
9
- /// It lives in its own dependency-free file so both [BottomMenu] and the logout
10
- /// flow can touch it without an import cycle. Cleared on logout so a fresh login
11
- /// always lands on the default tab. Null until the user opens a tab.
9
+ /// It lives in its own dependency-free file so both [BottomMenu] and the auth
10
+ /// flow can touch it without an import cycle. Null until the user opens a tab.
12
11
  final ValueNotifier<String?> activeTabRouteNotifier = ValueNotifier<String?>(
13
12
  null,
14
13
  );
14
+
15
+ /// Forgets the remembered tab so the next [BottomMenu] mount starts on the
16
+ /// default tab (Home). Call this on any session boundary — sign-in and logout —
17
+ /// via the single helpers that own those transitions ([goHomeAfterLogin] and
18
+ /// [UserStateNotifier.onLogout]); never poke [activeTabRouteNotifier] directly
19
+ /// from feature code, so the reset stays in one obvious place.
20
+ void forgetActiveTab() => activeTabRouteNotifier.value = null;
21
+
22
+ /// One-shot flag: the next [BottomMenu] mount must land on Home, ignoring BOTH
23
+ /// the remembered tab and the (possibly stale) web URL.
24
+ ///
25
+ /// A fresh login navigates to `/`, but on web the previous session's URL may
26
+ /// still read e.g. `/settings`, and [BottomMenu] falls back to that URL when no
27
+ /// tab is remembered — so clearing the notifier alone isn't enough to guarantee
28
+ /// Home (the URL fallback re-opens the old tab, then Bart rewrites the URL back
29
+ /// to it). This flag forces Home for exactly one mount, regardless of timing
30
+ /// between the login navigation and the auth page's own redirect. Set by
31
+ /// [goHomeAfterLogin]; consumed once by [BottomMenu].
32
+ bool _forceHomeOnNextMount = false;
33
+
34
+ /// Forgets the tab AND forces the next [BottomMenu] mount to Home. Use on a
35
+ /// fresh login (see [goHomeAfterLogin]) so the user never lands on whatever tab
36
+ /// or URL the previous session left behind.
37
+ void requestHomeOnNextMount() {
38
+ activeTabRouteNotifier.value = null;
39
+ _forceHomeOnNextMount = true;
40
+ }
41
+
42
+ /// Reads and clears the [requestHomeOnNextMount] flag. Returns true only for the
43
+ /// first [BottomMenu] mount after a login; false otherwise (so web reload / the
44
+ /// responsive remount keep restoring the tab normally).
45
+ bool consumeForceHomeOnNextMount() {
46
+ if (!_forceHomeOnNextMount) {
47
+ return false;
48
+ }
49
+ _forceHomeOnNextMount = false;
50
+ return true;
51
+ }
@@ -8,6 +8,7 @@ import 'package:kasy_kit/core/bottom_menu/active_tab_notifier.dart';
8
8
  import 'package:kasy_kit/core/bottom_menu/bottom_router.dart';
9
9
  import 'package:kasy_kit/core/bottom_menu/kasy_bottom_bar_factory.dart';
10
10
  import 'package:kasy_kit/core/bottom_menu/web_content_wrapper.dart';
11
+ import 'package:kasy_kit/core/bottom_menu/web_url.dart';
11
12
  import 'package:kasy_kit/core/chrome/chrome_visibility.dart';
12
13
  import 'package:kasy_kit/core/data/models/user.dart';
13
14
  import 'package:kasy_kit/core/states/logout_action.dart';
@@ -25,6 +26,13 @@ void _rememberActiveTab(bart.BartMenuRoute route) {
25
26
  // A fresh tab always starts with the chrome shown (it may have been hidden by
26
27
  // scrolling on the previous tab).
27
28
  KasyChromeVisibility.instance.resetShown();
29
+ // Home is the app root: GoRouter serves it at '/', but Bart writes the bare
30
+ // tab path ('/home') like every other tab. Normalize so Home always reads as
31
+ // '/' — matching the post-login URL and the real GoRouter route (reload-safe)
32
+ // — instead of leaving two URLs for the same screen.
33
+ if (route.path == subRoutes().first.path) {
34
+ syncBrowserUrl('/');
35
+ }
28
36
  }
29
37
 
30
38
  /// Bottom navigation host powered by Bart (https://pub.dev/packages/bart).
@@ -149,6 +157,13 @@ class BottomMenu extends StatelessWidget {
149
157
  if (route != null) {
150
158
  return route;
151
159
  }
160
+ // A fresh login forced Home: ignore the remembered tab AND the (possibly
161
+ // stale) web URL for this one mount, so signing in never reopens whatever
162
+ // the previous session left behind. Reload (F5) and the responsive remount
163
+ // don't set this flag, so they keep restoring the tab below.
164
+ if (consumeForceHomeOnNextMount()) {
165
+ return null;
166
+ }
152
167
  // Restore the last tab across a remount. This is the reliable source: the
153
168
  // browser URL is contended by both GoRouter and Bart, but this notifier is
154
169
  // owned solely by the bottom bar and lives above the rebuilt subtree.
@@ -282,9 +297,16 @@ class _FocusableSidebarState extends State<_FocusableSidebar> {
282
297
  super.dispose();
283
298
  }
284
299
 
285
- // "Skip to content" moves the primary focus into the routed content, so the
286
- // next Tab steps to the first real control there instead of the sidebar.
287
- void _skipToContent() => kasyContentFocusTarget?.requestFocus();
300
+ // "Skip to content" jumps focus straight to the FIRST real control in the
301
+ // routed content. The content target is a skipTraversal region (tabindex=-1
302
+ // style), so stepping once past it lands on a visible control immediately,
303
+ // instead of focusing the invisible region and needing a second Tab. Falls
304
+ // back to the region itself if the page has no focusable control.
305
+ void _skipToContent() {
306
+ final FocusNode? target = kasyContentFocusTarget;
307
+ if (target == null) return;
308
+ if (!target.nextFocus()) target.requestFocus();
309
+ }
288
310
 
289
311
  @override
290
312
  Widget build(BuildContext context) {
@@ -329,6 +351,8 @@ class _SkipToContentLink extends StatefulWidget {
329
351
 
330
352
  class _SkipToContentLinkState extends State<_SkipToContentLink> {
331
353
  bool _show = false;
354
+ final OverlayPortalController _overlay = OverlayPortalController();
355
+ final LayerLink _link = LayerLink();
332
356
 
333
357
  static const Map<ShortcutActivator, Intent> _shortcuts =
334
358
  <ShortcutActivator, Intent>{
@@ -337,9 +361,31 @@ class _SkipToContentLinkState extends State<_SkipToContentLink> {
337
361
  SingleActivator(LogicalKeyboardKey.space): ActivateIntent(),
338
362
  };
339
363
 
364
+ void _setShown(bool show) {
365
+ if (!mounted || show == _show) return;
366
+ setState(() => _show = show);
367
+ show ? _overlay.show() : _overlay.hide();
368
+ }
369
+
340
370
  @override
341
371
  Widget build(BuildContext context) {
372
+ // Colours/text are resolved here, in the sidebar's context, and passed into
373
+ // the overlay below — an overlay context doesn't reliably inherit the app
374
+ // theme (same reason the collapsed-rail tooltip in this file does it).
342
375
  final KasyColors c = context.colors;
376
+ final String label = context.t.navigation.skip_to_content;
377
+ final TextStyle? labelStyle = context.textTheme.bodyMedium?.copyWith(
378
+ color: c.onSurface,
379
+ fontWeight: FontWeight.w600,
380
+ );
381
+
382
+ // The focusable node lives here so the link stays the first Tab stop, but
383
+ // the visible card is painted in the root Overlay, anchored to this spot via
384
+ // [_link]. The collapsed sidebar is narrower than the card, so an inline
385
+ // card would be clipped at the rail's edge; the overlay floats above
386
+ // everything and is never clipped. The inline child is zero-size, so the
387
+ // detector also no longer overflows onto the panel toggle (dismiss-on-click
388
+ // is handled globally by FocusVisibility).
343
389
  return FocusableActionDetector(
344
390
  shortcuts: _shortcuts,
345
391
  actions: <Type, Action<Intent>>{
@@ -350,42 +396,44 @@ class _SkipToContentLinkState extends State<_SkipToContentLink> {
350
396
  },
351
397
  ),
352
398
  },
353
- onShowFocusHighlight: (bool show) {
354
- if (mounted && show != _show) setState(() => _show = show);
355
- },
356
- child: IgnorePointer(
357
- ignoring: !_show,
358
- child: AnimatedOpacity(
359
- opacity: _show ? 1 : 0,
360
- duration: const Duration(milliseconds: 120),
361
- child: GestureDetector(
362
- onTap: widget.onSkip,
363
- child: Container(
364
- padding: const EdgeInsets.symmetric(
365
- horizontal: KasySpacing.md,
366
- vertical: KasySpacing.sm,
367
- ),
368
- decoration: BoxDecoration(
369
- color: c.surface,
370
- borderRadius: BorderRadius.circular(KasyRadius.md),
371
- border: Border.all(color: c.primary, width: 1.5),
372
- boxShadow: <BoxShadow>[
373
- BoxShadow(
374
- color: c.onSurface.withValues(alpha: 0.18),
375
- blurRadius: 16,
376
- offset: const Offset(0, 4),
399
+ onShowFocusHighlight: _setShown,
400
+ child: CompositedTransformTarget(
401
+ link: _link,
402
+ child: OverlayPortal(
403
+ controller: _overlay,
404
+ overlayChildBuilder: (_) => CompositedTransformFollower(
405
+ link: _link,
406
+ showWhenUnlinked: false,
407
+ child: Align(
408
+ alignment: Alignment.topLeft,
409
+ child: Material(
410
+ color: Colors.transparent,
411
+ child: GestureDetector(
412
+ onTap: widget.onSkip,
413
+ child: Container(
414
+ padding: const EdgeInsets.symmetric(
415
+ horizontal: KasySpacing.md,
416
+ vertical: KasySpacing.sm,
417
+ ),
418
+ decoration: BoxDecoration(
419
+ color: c.surface,
420
+ borderRadius: BorderRadius.circular(KasyRadius.md),
421
+ border: Border.all(color: c.primary, width: 1.5),
422
+ boxShadow: <BoxShadow>[
423
+ BoxShadow(
424
+ color: c.onSurface.withValues(alpha: 0.18),
425
+ blurRadius: 16,
426
+ offset: const Offset(0, 4),
427
+ ),
428
+ ],
429
+ ),
430
+ child: Text(label, style: labelStyle),
377
431
  ),
378
- ],
379
- ),
380
- child: Text(
381
- context.t.navigation.skip_to_content,
382
- style: context.textTheme.bodyMedium?.copyWith(
383
- color: c.onSurface,
384
- fontWeight: FontWeight.w600,
385
432
  ),
386
433
  ),
387
434
  ),
388
435
  ),
436
+ child: const SizedBox.shrink(),
389
437
  ),
390
438
  ),
391
439
  );
@@ -28,9 +28,10 @@ class WebContentWrapper extends StatefulWidget {
28
28
  }
29
29
 
30
30
  class _WebContentWrapperState extends State<WebContentWrapper> {
31
- // skipTraversal: the "skip to content" link focuses this node directly, but it
32
- // stays out of the Tab order (like a tabindex="-1" skip target on the web) so
33
- // the next Tab lands on the first real control inside the content.
31
+ // skipTraversal: a tabindex="-1" style region. The "skip to content" link
32
+ // steps past it to the first real control in the content (see
33
+ // BottomMenu._skipToContent), and normal Tab order skips it too so it never
34
+ // becomes a focus stop of its own.
34
35
  final FocusNode _contentFocus = FocusNode(
35
36
  debugLabel: 'skipToContentTarget',
36
37
  skipTraversal: true,
@@ -83,9 +84,8 @@ class _WebContentWrapperState extends State<WebContentWrapper> {
83
84
  child: FocusTraversalOrder(
84
85
  order: const NumericFocusOrder(3),
85
86
  child: FocusTraversalGroup(
86
- // Focus target for "skip to content": focusing it moves the
87
- // primary focus into the content; the next Tab then steps to the
88
- // first real control here.
87
+ // Focus target for "skip to content": the link steps past this
88
+ // region straight to the first real control inside it.
89
89
  child: Focus(focusNode: _contentFocus, child: child),
90
90
  ),
91
91
  ),