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.
- package/README.md +1 -1
- package/bin/kasy.js +24 -2
- package/docs/cli-reference.md +7 -7
- package/lib/commands/new.js +11 -9
- package/lib/commands/release-version.js +234 -0
- package/lib/commands/update.js +27 -0
- package/lib/scaffold/CHANGELOG.json +9 -0
- package/lib/scaffold/backends/api/patch/lib/features/authentication/api/authentication_api.dart +24 -0
- package/lib/scaffold/backends/api/patch/lib/features/authentication/api/authentication_api_interface.dart +15 -0
- package/lib/scaffold/backends/api/patch/lib/features/authentication/repositories/authentication_repository.dart +27 -0
- package/lib/scaffold/backends/api/patch/lib/features/subscriptions/api/entities/subscription_entity.dart +1 -0
- package/lib/scaffold/backends/firebase/setup-from-scratch.js +35 -21
- package/lib/scaffold/backends/patch-base-hashes.json +66 -0
- package/lib/scaffold/backends/supabase/edge-functions/stripe-webhook/index.ts +2 -0
- package/lib/scaffold/backends/supabase/migrations/20240101000007_welcome_notification.sql +36 -23
- package/lib/scaffold/backends/supabase/migrations/20240101000014_subscription_trial_end.sql +6 -0
- package/lib/scaffold/backends/supabase/patch/lib/features/authentication/api/authentication_api.dart +82 -3
- package/lib/scaffold/backends/supabase/patch/lib/features/authentication/repositories/authentication_repository.dart +36 -0
- package/lib/scaffold/backends/supabase/patch/lib/features/subscriptions/api/entities/subscription_entity.dart +1 -0
- package/lib/scaffold/generate.js +53 -4
- package/lib/utils/i18n/messages-en.js +23 -0
- package/lib/utils/i18n/messages-es.js +23 -0
- package/lib/utils/i18n/messages-pt.js +23 -0
- package/package.json +5 -2
- package/templates/firebase/.firebase/hosting.YnVpbGQvd2Vi.cache +73 -0
- package/templates/firebase/AGENTS.md +83 -0
- package/templates/firebase/DESIGN_SYSTEM.md +37 -2
- package/templates/firebase/docs/auth-setup.en.md +2 -0
- package/templates/firebase/docs/auth-setup.es.md +2 -0
- package/templates/firebase/docs/auth-setup.pt.md +2 -0
- package/templates/firebase/firebase.json +56 -1
- package/templates/firebase/functions/src/authentication/triggers.ts +10 -6
- package/templates/firebase/functions/src/core/data/entities/subscription_entity.ts +5 -0
- package/templates/firebase/functions/src/core/data/entities/user_entity.ts +9 -1
- package/templates/firebase/functions/src/core/data/repositories/user_repository.ts +27 -0
- package/templates/firebase/functions/src/subscriptions/models/subscriptions.ts +3 -0
- package/templates/firebase/functions/src/subscriptions/stripe_functions.ts +4 -0
- package/templates/firebase/lib/components/kasy_alert.dart +0 -1
- package/templates/firebase/lib/components/kasy_app_bar.dart +31 -16
- package/templates/firebase/lib/components/kasy_bottom_sheet.dart +0 -1
- package/templates/firebase/lib/components/kasy_date_picker.dart +0 -5
- package/templates/firebase/lib/components/kasy_dialog.dart +0 -1
- package/templates/firebase/lib/components/kasy_otp_verification_bottom_sheet.dart +1 -1
- package/templates/firebase/lib/components/kasy_sidebar.dart +189 -120
- package/templates/firebase/lib/components/kasy_text_area.dart +0 -1
- package/templates/firebase/lib/components/kasy_text_field.dart +0 -1
- package/templates/firebase/lib/components/kasy_text_field_otp.dart +0 -1
- package/templates/firebase/lib/components/kasy_toast.dart +107 -41
- package/templates/firebase/lib/core/app_update/app_update_repository.dart +54 -0
- package/templates/firebase/lib/core/app_update/app_update_status.dart +14 -0
- package/templates/firebase/lib/core/app_update/update_available_sheet.dart +70 -0
- package/templates/firebase/lib/core/bottom_menu/active_tab_notifier.dart +40 -3
- package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +82 -34
- package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +6 -6
- package/templates/firebase/lib/core/bottom_menu/web_url.dart +20 -0
- package/templates/firebase/lib/core/data/api/remote_config_api.dart +38 -1
- package/templates/firebase/lib/core/guards/guard.dart +16 -2
- package/templates/firebase/lib/core/icons/kasy_icons.dart +3 -0
- package/templates/firebase/lib/core/rating/widgets/rate_banner.dart +2 -5
- package/templates/firebase/lib/core/rating/widgets/review_popup.dart +5 -3
- package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +14 -0
- package/templates/firebase/lib/core/states/components/maybe_show_update_available.dart +32 -0
- package/templates/firebase/lib/core/states/logout_action.dart +5 -1
- package/templates/firebase/lib/core/states/user_state_notifier.dart +85 -14
- package/templates/firebase/lib/core/theme/responsive_text_theme.dart +69 -0
- package/templates/firebase/lib/core/theme/texts.dart +90 -57
- package/templates/firebase/lib/core/theme/type_scale.dart +77 -0
- package/templates/firebase/lib/core/theme/web_background_sync_web.dart +20 -6
- package/templates/firebase/lib/core/utils/image_bytes_loader.dart +12 -0
- package/templates/firebase/lib/core/utils/image_bytes_loader_io.dart +28 -0
- package/templates/firebase/lib/core/utils/image_bytes_loader_web.dart +56 -0
- package/templates/firebase/lib/core/web_screen_width.dart +15 -0
- package/templates/firebase/lib/core/web_screen_width_io.dart +3 -0
- package/templates/firebase/lib/core/web_screen_width_web.dart +10 -0
- package/templates/firebase/lib/core/web_viewport_scale.dart +61 -25
- package/templates/firebase/lib/core/widgets/focus_visibility.dart +89 -0
- package/templates/firebase/lib/features/ai_chat/ai_chat_page.dart +1 -2
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_conversation_view.dart +1 -2
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_list.dart +2 -3
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +1 -2
- package/templates/firebase/lib/features/authentication/api/auth_web_support.dart +12 -0
- package/templates/firebase/lib/features/authentication/api/auth_web_support_web.dart +25 -0
- package/templates/firebase/lib/features/authentication/api/authentication_api.dart +205 -0
- package/templates/firebase/lib/features/authentication/api/authentication_api_interface.dart +22 -0
- package/templates/firebase/lib/features/authentication/navigation/post_login_navigation.dart +32 -0
- package/templates/firebase/lib/features/authentication/providers/phone_auth_notifier.dart +7 -7
- package/templates/firebase/lib/features/authentication/providers/signin_state_provider.dart +34 -10
- package/templates/firebase/lib/features/authentication/providers/signup_state_provider.dart +2 -2
- package/templates/firebase/lib/features/authentication/repositories/authentication_repository.dart +37 -0
- package/templates/firebase/lib/features/authentication/repositories/exceptions/authentication_exceptions.dart +8 -0
- package/templates/firebase/lib/features/authentication/ui/components/otp_verification.dart +2 -2
- package/templates/firebase/lib/features/authentication/ui/components/phone_input.dart +2 -2
- package/templates/firebase/lib/features/authentication/ui/signin_page.dart +59 -0
- package/templates/firebase/lib/features/authentication/ui/widgets/auth_card_scaffold.dart +2 -2
- package/templates/firebase/lib/features/authentication/ui/widgets/recover_password_result.dart +2 -2
- package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +0 -1
- package/templates/firebase/lib/features/home/design_system_page.dart +134 -67
- package/templates/firebase/lib/features/home/home_components_page.dart +4 -3
- package/templates/firebase/lib/features/home/home_components_preview_page.dart +1 -3
- package/templates/firebase/lib/features/home/home_components_preview_registry.dart +154 -56
- package/templates/firebase/lib/features/home/home_page.dart +4 -0
- package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +8 -3
- package/templates/firebase/lib/features/notifications/providers/notifications_provider.dart +10 -0
- package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +3 -1
- package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +8 -3
- package/templates/firebase/lib/features/onboarding/providers/onboarding_model.dart +11 -0
- package/templates/firebase/lib/features/onboarding/providers/onboarding_provider.dart +30 -3
- package/templates/firebase/lib/features/onboarding/ui/components/onboarding_loader.dart +13 -4
- package/templates/firebase/lib/features/settings/settings_page.dart +152 -11
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +43 -15
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_tab.dart +12 -12
- package/templates/firebase/lib/features/settings/ui/components/admin/send_push_notification_page.dart +1 -4
- package/templates/firebase/lib/features/settings/ui/components/create_password_sheet.dart +141 -0
- package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +0 -1
- package/templates/firebase/lib/features/settings/ui/widgets/admin_card.dart +42 -38
- package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +17 -2
- package/templates/firebase/lib/features/subscriptions/api/entities/subscription_entity.dart +3 -0
- package/templates/firebase/lib/features/subscriptions/api/stripe_product.dart +12 -11
- package/templates/firebase/lib/features/subscriptions/repositories/subscription_repository.dart +25 -2
- package/templates/firebase/lib/features/subscriptions/ui/component/active_premium_content.dart +319 -143
- package/templates/firebase/lib/features/subscriptions/ui/component/paywall_minimal.dart +1 -5
- package/templates/firebase/lib/features/subscriptions/ui/component/paywall_row.dart +1 -5
- package/templates/firebase/lib/features/subscriptions/ui/component/paywall_with_switch.dart +2 -5
- package/templates/firebase/lib/features/subscriptions/ui/component/premium_content.dart +1 -4
- package/templates/firebase/lib/features/subscriptions/ui/widgets/feature_line.dart +1 -2
- package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_banner.dart +2 -7
- package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_feature.dart +2 -4
- package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_col.dart +1 -4
- package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_row.dart +0 -3
- package/templates/firebase/lib/i18n/en.i18n.json +49 -3
- package/templates/firebase/lib/i18n/es.i18n.json +49 -3
- package/templates/firebase/lib/i18n/pt.i18n.json +49 -3
- package/templates/firebase/lib/main.dart +11 -2
- package/templates/firebase/lib/router.dart +92 -13
- package/templates/firebase/pubspec.yaml +1 -1
- package/templates/firebase/test/core/data/api/fake_remote_config_api.dart +14 -0
- package/templates/firebase/test/core/states/user_state_notifier_test.dart +47 -3
- package/templates/firebase/test/core/web_viewport_scale_test.dart +68 -0
- package/templates/firebase/test/features/authentication/data/api/auth_api_fake.dart +15 -0
- package/templates/firebase/web/index.html +162 -14
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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 (
|
|
95
|
+
if (shownMessage != null) ...[
|
|
98
96
|
const SizedBox(height: 2),
|
|
99
97
|
Text(
|
|
100
|
-
|
|
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.
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
712
|
-
|
|
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
|
-
|
|
761
|
+
_ToastStyle _resolveStyle(KasyColors c, KasyToastTone tone) {
|
|
719
762
|
switch (tone) {
|
|
720
763
|
case KasyToastTone.neutral:
|
|
721
|
-
return
|
|
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
|
|
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
|
|
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
|
|
733
|
-
|
|
734
|
-
|
|
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
|
|
10
|
-
/// flow can touch it without an import cycle.
|
|
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"
|
|
286
|
-
//
|
|
287
|
-
|
|
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:
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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:
|
|
32
|
-
//
|
|
33
|
-
//
|
|
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":
|
|
87
|
-
//
|
|
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
|
+
}
|