kasy-cli 1.34.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 (141) hide show
  1. package/README.md +1 -1
  2. package/bin/kasy.js +24 -2
  3. package/docs/cli-reference.md +7 -7
  4. package/lib/commands/new.js +11 -9
  5. package/lib/commands/release-version.js +234 -0
  6. package/lib/commands/update.js +27 -0
  7. package/lib/scaffold/CHANGELOG.json +9 -0
  8. package/lib/scaffold/backends/api/patch/lib/features/authentication/api/authentication_api.dart +24 -0
  9. package/lib/scaffold/backends/api/patch/lib/features/authentication/api/authentication_api_interface.dart +15 -0
  10. package/lib/scaffold/backends/api/patch/lib/features/authentication/repositories/authentication_repository.dart +27 -0
  11. package/lib/scaffold/backends/api/patch/lib/features/subscriptions/api/entities/subscription_entity.dart +1 -0
  12. package/lib/scaffold/backends/firebase/setup-from-scratch.js +35 -21
  13. package/lib/scaffold/backends/patch-base-hashes.json +66 -0
  14. package/lib/scaffold/backends/supabase/edge-functions/stripe-webhook/index.ts +2 -0
  15. package/lib/scaffold/backends/supabase/migrations/20240101000007_welcome_notification.sql +36 -23
  16. package/lib/scaffold/backends/supabase/migrations/20240101000014_subscription_trial_end.sql +6 -0
  17. package/lib/scaffold/backends/supabase/patch/lib/features/authentication/api/authentication_api.dart +82 -3
  18. package/lib/scaffold/backends/supabase/patch/lib/features/authentication/repositories/authentication_repository.dart +36 -0
  19. package/lib/scaffold/backends/supabase/patch/lib/features/subscriptions/api/entities/subscription_entity.dart +1 -0
  20. package/lib/scaffold/generate.js +53 -4
  21. package/lib/utils/i18n/messages-en.js +23 -0
  22. package/lib/utils/i18n/messages-es.js +23 -0
  23. package/lib/utils/i18n/messages-pt.js +23 -0
  24. package/package.json +5 -2
  25. package/templates/firebase/.firebase/hosting.YnVpbGQvd2Vi.cache +73 -0
  26. package/templates/firebase/AGENTS.md +83 -0
  27. package/templates/firebase/DESIGN_SYSTEM.md +37 -2
  28. package/templates/firebase/docs/auth-setup.en.md +2 -0
  29. package/templates/firebase/docs/auth-setup.es.md +2 -0
  30. package/templates/firebase/docs/auth-setup.pt.md +2 -0
  31. package/templates/firebase/firebase.json +56 -1
  32. package/templates/firebase/functions/src/authentication/triggers.ts +10 -6
  33. package/templates/firebase/functions/src/core/data/entities/subscription_entity.ts +5 -0
  34. package/templates/firebase/functions/src/core/data/entities/user_entity.ts +9 -1
  35. package/templates/firebase/functions/src/core/data/repositories/user_repository.ts +27 -0
  36. package/templates/firebase/functions/src/subscriptions/models/subscriptions.ts +3 -0
  37. package/templates/firebase/functions/src/subscriptions/stripe_functions.ts +4 -0
  38. package/templates/firebase/lib/components/kasy_alert.dart +0 -1
  39. package/templates/firebase/lib/components/kasy_app_bar.dart +31 -16
  40. package/templates/firebase/lib/components/kasy_bottom_sheet.dart +0 -1
  41. package/templates/firebase/lib/components/kasy_date_picker.dart +0 -5
  42. package/templates/firebase/lib/components/kasy_dialog.dart +0 -1
  43. package/templates/firebase/lib/components/kasy_otp_verification_bottom_sheet.dart +1 -1
  44. package/templates/firebase/lib/components/kasy_sidebar.dart +189 -120
  45. package/templates/firebase/lib/components/kasy_text_area.dart +0 -1
  46. package/templates/firebase/lib/components/kasy_text_field.dart +0 -1
  47. package/templates/firebase/lib/components/kasy_text_field_otp.dart +0 -1
  48. package/templates/firebase/lib/components/kasy_toast.dart +107 -41
  49. package/templates/firebase/lib/core/app_update/app_update_repository.dart +54 -0
  50. package/templates/firebase/lib/core/app_update/app_update_status.dart +14 -0
  51. package/templates/firebase/lib/core/app_update/update_available_sheet.dart +70 -0
  52. package/templates/firebase/lib/core/bottom_menu/active_tab_notifier.dart +40 -3
  53. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +82 -34
  54. package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +6 -6
  55. package/templates/firebase/lib/core/bottom_menu/web_url.dart +20 -0
  56. package/templates/firebase/lib/core/data/api/remote_config_api.dart +38 -1
  57. package/templates/firebase/lib/core/guards/guard.dart +16 -2
  58. package/templates/firebase/lib/core/icons/kasy_icons.dart +3 -0
  59. package/templates/firebase/lib/core/rating/widgets/rate_banner.dart +2 -5
  60. package/templates/firebase/lib/core/rating/widgets/review_popup.dart +5 -3
  61. package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +14 -0
  62. package/templates/firebase/lib/core/states/components/maybe_show_update_available.dart +32 -0
  63. package/templates/firebase/lib/core/states/logout_action.dart +5 -1
  64. package/templates/firebase/lib/core/states/user_state_notifier.dart +85 -14
  65. package/templates/firebase/lib/core/theme/responsive_text_theme.dart +69 -0
  66. package/templates/firebase/lib/core/theme/texts.dart +90 -57
  67. package/templates/firebase/lib/core/theme/type_scale.dart +77 -0
  68. package/templates/firebase/lib/core/theme/web_background_sync_web.dart +20 -6
  69. package/templates/firebase/lib/core/utils/image_bytes_loader.dart +12 -0
  70. package/templates/firebase/lib/core/utils/image_bytes_loader_io.dart +28 -0
  71. package/templates/firebase/lib/core/utils/image_bytes_loader_web.dart +56 -0
  72. package/templates/firebase/lib/core/web_screen_width.dart +15 -0
  73. package/templates/firebase/lib/core/web_screen_width_io.dart +3 -0
  74. package/templates/firebase/lib/core/web_screen_width_web.dart +10 -0
  75. package/templates/firebase/lib/core/web_viewport_scale.dart +61 -25
  76. package/templates/firebase/lib/core/widgets/focus_visibility.dart +89 -0
  77. package/templates/firebase/lib/features/ai_chat/ai_chat_page.dart +1 -2
  78. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_conversation_view.dart +1 -2
  79. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_list.dart +2 -3
  80. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +1 -2
  81. package/templates/firebase/lib/features/authentication/api/auth_web_support.dart +12 -0
  82. package/templates/firebase/lib/features/authentication/api/auth_web_support_web.dart +25 -0
  83. package/templates/firebase/lib/features/authentication/api/authentication_api.dart +205 -0
  84. package/templates/firebase/lib/features/authentication/api/authentication_api_interface.dart +22 -0
  85. package/templates/firebase/lib/features/authentication/navigation/post_login_navigation.dart +32 -0
  86. package/templates/firebase/lib/features/authentication/providers/phone_auth_notifier.dart +7 -7
  87. package/templates/firebase/lib/features/authentication/providers/signin_state_provider.dart +34 -10
  88. package/templates/firebase/lib/features/authentication/providers/signup_state_provider.dart +2 -2
  89. package/templates/firebase/lib/features/authentication/repositories/authentication_repository.dart +37 -0
  90. package/templates/firebase/lib/features/authentication/repositories/exceptions/authentication_exceptions.dart +8 -0
  91. package/templates/firebase/lib/features/authentication/ui/components/otp_verification.dart +2 -2
  92. package/templates/firebase/lib/features/authentication/ui/components/phone_input.dart +2 -2
  93. package/templates/firebase/lib/features/authentication/ui/signin_page.dart +59 -0
  94. package/templates/firebase/lib/features/authentication/ui/widgets/auth_card_scaffold.dart +2 -2
  95. package/templates/firebase/lib/features/authentication/ui/widgets/recover_password_result.dart +2 -2
  96. package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +0 -1
  97. package/templates/firebase/lib/features/home/design_system_page.dart +134 -67
  98. package/templates/firebase/lib/features/home/home_components_page.dart +4 -3
  99. package/templates/firebase/lib/features/home/home_components_preview_page.dart +1 -3
  100. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +154 -56
  101. package/templates/firebase/lib/features/home/home_page.dart +4 -0
  102. package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +8 -3
  103. package/templates/firebase/lib/features/notifications/providers/notifications_provider.dart +10 -0
  104. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +3 -1
  105. package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +8 -3
  106. package/templates/firebase/lib/features/onboarding/providers/onboarding_model.dart +11 -0
  107. package/templates/firebase/lib/features/onboarding/providers/onboarding_provider.dart +30 -3
  108. package/templates/firebase/lib/features/onboarding/ui/components/onboarding_loader.dart +13 -4
  109. package/templates/firebase/lib/features/settings/settings_page.dart +152 -11
  110. package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +43 -15
  111. package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_tab.dart +12 -12
  112. package/templates/firebase/lib/features/settings/ui/components/admin/send_push_notification_page.dart +1 -4
  113. package/templates/firebase/lib/features/settings/ui/components/create_password_sheet.dart +141 -0
  114. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +0 -1
  115. package/templates/firebase/lib/features/settings/ui/widgets/admin_card.dart +42 -38
  116. package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +17 -2
  117. package/templates/firebase/lib/features/subscriptions/api/entities/subscription_entity.dart +3 -0
  118. package/templates/firebase/lib/features/subscriptions/api/stripe_product.dart +12 -11
  119. package/templates/firebase/lib/features/subscriptions/repositories/subscription_repository.dart +25 -2
  120. package/templates/firebase/lib/features/subscriptions/ui/component/active_premium_content.dart +319 -143
  121. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_minimal.dart +1 -5
  122. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_row.dart +1 -5
  123. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_with_switch.dart +2 -5
  124. package/templates/firebase/lib/features/subscriptions/ui/component/premium_content.dart +1 -4
  125. package/templates/firebase/lib/features/subscriptions/ui/widgets/feature_line.dart +1 -2
  126. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_banner.dart +2 -7
  127. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_feature.dart +2 -4
  128. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_col.dart +1 -4
  129. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_row.dart +0 -3
  130. package/templates/firebase/lib/i18n/en.i18n.json +49 -3
  131. package/templates/firebase/lib/i18n/es.i18n.json +49 -3
  132. package/templates/firebase/lib/i18n/pt.i18n.json +49 -3
  133. package/templates/firebase/lib/main.dart +11 -2
  134. package/templates/firebase/lib/router.dart +92 -13
  135. package/templates/firebase/pubspec.yaml +1 -1
  136. package/templates/firebase/test/core/data/api/fake_remote_config_api.dart +14 -0
  137. package/templates/firebase/test/core/states/user_state_notifier_test.dart +47 -3
  138. package/templates/firebase/test/core/web_viewport_scale_test.dart +68 -0
  139. package/templates/firebase/test/features/authentication/data/api/auth_api_fake.dart +15 -0
  140. package/templates/firebase/web/index.html +162 -14
  141. package/templates/firebase/lib/core/guards/user_info_guard.dart +0 -61
@@ -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,15 +47,20 @@ 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.20 : 0.20),
56
- ),
63
+ border: Border.all(color: c.outline.withValues(alpha: 0.20)),
57
64
  boxShadow: [
58
65
  BoxShadow(
59
66
  color: Colors.black.withValues(alpha: isDark ? 0.22 : 0.06),
@@ -63,41 +70,32 @@ class KasyToast extends StatelessWidget {
63
70
  ],
64
71
  ),
65
72
  child: Padding(
66
- // Tighter vertical rhythm and less right padding (the close affordance
67
- // is a light icon, not a filled button) for a slimmer, refined bar.
68
- padding: const EdgeInsets.fromLTRB(
69
- KasySpacing.md,
70
- KasySpacing.smd,
71
- KasySpacing.sm,
72
- KasySpacing.smd,
73
- ),
73
+ padding: const EdgeInsets.all(KasySpacing.smd),
74
74
  child: Row(
75
- 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,
76
80
  children: [
77
- Padding(
78
- padding: const EdgeInsets.only(top: 1),
79
- // Only the icon carries the tone colour; the title stays neutral
80
- // so the toast reads calm instead of shouting.
81
- child: icon ??
82
- Icon(pal.icon, size: KasyIconSize.lg, color: pal.accent),
83
- ),
84
- const SizedBox(width: KasySpacing.sm),
81
+ _buildLeading(style),
82
+ const SizedBox(width: KasySpacing.smd),
85
83
  Expanded(
86
84
  child: Column(
87
85
  crossAxisAlignment: CrossAxisAlignment.start,
88
86
  mainAxisSize: MainAxisSize.min,
89
87
  children: [
90
88
  Text(
91
- title,
89
+ shownTitle,
92
90
  style: context.textTheme.titleSmall?.copyWith(
93
91
  color: c.onSurface,
94
92
  fontWeight: FontWeight.w600,
95
93
  ),
96
94
  ),
97
- if (message != null) ...[
95
+ if (shownMessage != null) ...[
98
96
  const SizedBox(height: 2),
99
97
  Text(
100
- message!,
98
+ shownMessage,
101
99
  style: context.textTheme.bodyMedium?.copyWith(
102
100
  color: c.muted,
103
101
  height: 1.35,
@@ -107,22 +105,49 @@ class KasyToast extends StatelessWidget {
107
105
  ],
108
106
  ),
109
107
  ),
110
- const SizedBox(width: KasySpacing.xs),
111
- KasyButton.iconOnly(
112
- icon: KasyIcons.close,
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,
113
114
  variant: KasyButtonVariant.ghost,
114
- foregroundColor: c.muted,
115
115
  size: KasyButtonSize.small,
116
- iconOnlyLayoutExtent: 28,
117
- iconGlyphSize: 16,
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),
118
121
  onPressed: onClose,
119
- semanticLabel: MaterialLocalizations.of(context).closeButtonTooltip,
120
122
  ),
121
123
  ],
122
124
  ),
123
125
  ),
124
126
  );
125
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
+ }
126
151
  }
127
152
 
128
153
  // ---------------------------------------------------------------------------
@@ -708,30 +733,71 @@ class _AnimatedToastState extends State<_AnimatedToast>
708
733
  // Palette resolution
709
734
  // ---------------------------------------------------------------------------
710
735
 
711
- class _Palette {
712
- const _Palette({required this.accent, required this.icon});
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({
747
+ required this.icon,
748
+ required this.iconColor,
749
+ required this.iconBubbleColor,
750
+ this.useBrandLogo = false,
751
+ });
713
752
 
714
- final Color accent;
715
753
  final IconData icon;
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;
716
759
  }
717
760
 
718
- _Palette _palette(KasyColors c, KasyToastTone tone) {
761
+ _ToastStyle _resolveStyle(KasyColors c, KasyToastTone tone) {
719
762
  switch (tone) {
720
763
  case KasyToastTone.neutral:
721
- return _Palette(accent: c.onSurface, icon: KasyIcons.notification);
764
+ return _ToastStyle(
765
+ icon: KasyIcons.notification,
766
+ iconColor: c.onSurface,
767
+ iconBubbleColor: Colors.transparent,
768
+ useBrandLogo: true,
769
+ );
770
+ // Solid bubble, white icon inside.
722
771
  case KasyToastTone.accent:
723
- return _Palette(accent: c.primary, icon: KasyIcons.info);
772
+ return _ToastStyle(
773
+ icon: KasyIcons.info,
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,
782
+ );
783
+ // Soft tinted bubble, coloured icon.
724
784
  case KasyToastTone.success:
725
785
  final Color successDark = HSLColor.fromColor(c.success)
726
786
  .withLightness(
727
787
  (HSLColor.fromColor(c.success).lightness - 0.09).clamp(0.0, 1.0),
728
788
  )
729
789
  .toColor();
730
- return _Palette(accent: successDark, icon: KasyIcons.checkCircle);
790
+ return _ToastStyle(
791
+ icon: KasyIcons.checkCircle,
792
+ iconColor: successDark,
793
+ iconBubbleColor: c.success.withValues(alpha: 0.14),
794
+ );
731
795
  case KasyToastTone.warning:
732
- return _Palette(accent: c.warning, icon: KasyIcons.privacy);
733
- case KasyToastTone.danger:
734
- return _Palette(accent: c.error, icon: KasyIcons.error);
796
+ return _ToastStyle(
797
+ icon: KasyIcons.privacy,
798
+ iconColor: c.warning,
799
+ iconBubbleColor: c.warning.withValues(alpha: 0.16),
800
+ );
735
801
  }
736
802
  }
737
803
 
@@ -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
  ),
@@ -0,0 +1,20 @@
1
+ import 'package:flutter/foundation.dart' show kIsWeb;
2
+ import 'package:universal_html/html.dart' as html;
3
+
4
+ /// Forces the browser address bar to [path] on web (no-op on native).
5
+ ///
6
+ /// Why this exists: the bottom bar (Bart) writes each tab's URL directly via
7
+ /// `history.pushState`, bypassing GoRouter — and it never writes the Home tab's
8
+ /// URL (Bart short-circuits when the tab index doesn't change, and Home is the
9
+ /// default index). So after a fresh login forces Home, GoRouter is at `/` but
10
+ /// the address bar still shows the previous session's tab (e.g. `/settings`),
11
+ /// and `go('/')` is a no-op because GoRouter already considers itself at `/`.
12
+ ///
13
+ /// `replaceState` (not `pushState`) so the stale entry is corrected in place,
14
+ /// without adding a bogus history step the user could "back" into.
15
+ void syncBrowserUrl(String path) {
16
+ if (!kIsWeb) {
17
+ return;
18
+ }
19
+ html.window.history.replaceState(null, '', path);
20
+ }