kasy-cli 1.31.13 → 1.31.14

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.
@@ -363,6 +363,11 @@ async function enableGoogleSignIn(projectRef, webClientId, clientSecret) {
363
363
  external_google_client_id: webClientId,
364
364
  external_google_secret: clientSecret,
365
365
  external_google_skip_nonce_check: true,
366
+ // Allow the web app's dev origin as a redirect target so the web OAuth flow
367
+ // (signInWithOAuth redirectTo) and email links (recovery/confirm) land back on
368
+ // the app. http://localhost:5555 is the port `kasy run --web` uses. Add your
369
+ // production web origin here when you deploy to the web.
370
+ uri_allow_list: 'http://localhost:5555,http://localhost:5555/**',
366
371
  });
367
372
  if (!result.ok) return { ok: false, error: result.error };
368
373
  if (result.data.external_google_enabled === true) return { ok: true };
@@ -1,5 +1,6 @@
1
1
  import 'dart:convert';
2
2
  import 'package:crypto/crypto.dart';
3
+ import 'package:firebase_auth/firebase_auth.dart' as fb_auth;
3
4
  import 'package:flutter/foundation.dart' show kIsWeb;
4
5
  import 'package:flutter/services.dart' show PlatformException;
5
6
  import 'package:flutter_facebook_auth/flutter_facebook_auth.dart';
@@ -212,16 +213,16 @@ Note: wait a minute after enabling anonymous sign-in before trying again. It tak
212
213
  @override
213
214
  Future<Credentials> signinWithGoogle() async {
214
215
  if (kIsWeb) {
215
- // google_sign_in's imperative authenticate() is UNSUPPORTED on web (the v7
216
- // web plugin throws UnimplementedError). Use Supabase's OAuth redirect flow:
217
- // it sends the user to Google and back; the session arrives on return and is
218
- // picked up by the auth state listener. Requires your web origin in Supabase
219
- // Auth -> URL Configuration (Site URL / Redirect URLs).
220
- await client.auth.signInWithOAuth(
221
- OAuthProvider.google,
222
- redirectTo: Uri.base.origin,
216
+ // google_sign_in v7 can't do imperative auth on web. Get the Google ID token
217
+ // via Firebase's popup (zero manual config reuses the Firebase web OAuth
218
+ // client + authorized domains, which the kit already sets up) and sign into
219
+ // Supabase with it. Supabase remains the auth backend.
220
+ final idToken = await _googleIdTokenFromWebPopup();
221
+ final res = await client.auth.signInWithIdToken(
222
+ provider: OAuthProvider.google,
223
+ idToken: idToken,
223
224
  );
224
- return Credentials(id: '', token: '');
225
+ return Credentials(id: res.user!.id, token: res.session?.accessToken ?? '');
225
226
  }
226
227
  final googleSignIn = GoogleSignIn.instance;
227
228
  // Web: clientId = Web Client ID (no serverClientId needed)
@@ -264,6 +265,33 @@ Note: wait a minute after enabling anonymous sign-in before trying again. It tak
264
265
 
265
266
  }
266
267
 
268
+ /// Web only: obtains a Google ID token via Firebase's popup. google_sign_in v7
269
+ /// can't do imperative auth on the web, but the kit already initializes Firebase
270
+ /// (for FCM), so we reuse Firebase's working web OAuth client to get the token,
271
+ /// then sign into Supabase with it — no Supabase callback / Google console step
272
+ /// needed. The token's audience is the Firebase web client ID, which is the same
273
+ /// one configured as Supabase's Google provider, so signInWithIdToken accepts it.
274
+ Future<String> _googleIdTokenFromWebPopup() async {
275
+ try {
276
+ final cred = await fb_auth.FirebaseAuth.instance.signInWithPopup(
277
+ fb_auth.GoogleAuthProvider(),
278
+ );
279
+ final idToken = (cred.credential as fb_auth.OAuthCredential?)?.idToken;
280
+ if (idToken == null) {
281
+ throw ApiError(code: 401, message: 'No Google ID token from Firebase popup.');
282
+ }
283
+ return idToken;
284
+ } on fb_auth.FirebaseAuthException catch (e) {
285
+ if (e.code == 'popup-closed-by-user' ||
286
+ e.code == 'cancelled-popup-request' ||
287
+ e.code == 'web-context-cancelled' ||
288
+ e.code == 'user-cancelled') {
289
+ throw const UserCancelledSignInException();
290
+ }
291
+ rethrow;
292
+ }
293
+ }
294
+
267
295
  @override
268
296
  Future<Credentials> signinWithGooglePlay() {
269
297
  // Google Play Games sign-in is not supported for Supabase backend.
@@ -323,14 +351,26 @@ Note: wait a minute after enabling anonymous sign-in before trying again. It tak
323
351
  @override
324
352
  Future<Credentials> signupFromAnonymousWithGoogle() async {
325
353
  if (kIsWeb) {
326
- // Imperative GoogleSignIn.authenticate() is UNSUPPORTED on web. Use Supabase's
327
- // OAuth redirect; the session arrives on return via the auth listener. (On web
328
- // this signs in with Google instead of linking the anonymous user.)
329
- await client.auth.signInWithOAuth(
330
- OAuthProvider.google,
331
- redirectTo: Uri.base.origin,
332
- );
333
- return Credentials(id: '', token: '');
354
+ // Web: get the Google ID token via Firebase's popup (see signinWithGoogle) and
355
+ // link it to the current anonymous Supabase user.
356
+ final idToken = await _googleIdTokenFromWebPopup();
357
+ try {
358
+ final response = await client.auth.linkIdentityWithIdToken(
359
+ provider: OAuthProvider.google,
360
+ idToken: idToken,
361
+ );
362
+ return Credentials(id: response.user!.id, token: response.session?.accessToken ?? '');
363
+ } on AuthException catch (e) {
364
+ if (e.code == 'identity_already_exists') {
365
+ await _deleteCurrentAnonymousUser();
366
+ final res = await client.auth.signInWithIdToken(
367
+ provider: OAuthProvider.google,
368
+ idToken: idToken,
369
+ );
370
+ return Credentials(id: res.user!.id, token: res.session?.accessToken ?? '');
371
+ }
372
+ rethrow;
373
+ }
334
374
  }
335
375
  final scopes = ['email'];
336
376
  final googleSignIn = GoogleSignIn.instance;
@@ -26,6 +26,11 @@ dependencies:
26
26
  facebook_app_events: ^0.24.0
27
27
  firebase_app_installations: ^0.4.0+7
28
28
  firebase_core: ^4.5.0
29
+ # Web-only Google sign-in: on web, google_sign_in v7 can't do imperative auth, so we
30
+ # get the Google ID token via Firebase's popup (zero manual config — reuses the
31
+ # Firebase web client + authorized domains) and hand it to Supabase signInWithIdToken.
32
+ # Supabase stays the auth backend; mobile keeps the native google_sign_in flow.
33
+ firebase_auth: ^6.1.4
29
34
  firebase_messaging: ^16.1.2
30
35
  firebase_remote_config: ^6.2.0
31
36
  flutter:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kasy-cli",
3
- "version": "1.31.13",
3
+ "version": "1.31.14",
4
4
  "description": "CLI for scaffolding production-ready Flutter SaaS apps with Firebase, Supabase, or API REST backends.",
5
5
  "bin": {
6
6
  "kasy": "./bin/kasy.js"
@@ -213,8 +213,14 @@ class KasySidebar extends StatefulWidget {
213
213
  this.profileAvatar,
214
214
  this.profileGradient = KasyAvatarGradients.indigo,
215
215
  this.onProfileTap,
216
+ this.notificationsUnread = 0,
216
217
  });
217
218
 
219
+ /// Unread notification count. When greater than zero, the Notifications nav
220
+ /// item shows an unread dot (mirrors the bottom-bar badge). Purely an unread
221
+ /// indicator — not tied to push (which is native-only).
222
+ final int notificationsUnread;
223
+
218
224
  final VoidCallback? onSettingsTap;
219
225
 
220
226
  /// Whether the profile block is shown at the bottom of the rail. Set false to
@@ -496,6 +502,9 @@ class _KasySidebarState extends State<KasySidebar> {
496
502
  : (widget.routes![i].label ?? ''),
497
503
  isActive: _activeItemId.isEmpty && currentIndex == i,
498
504
  onTap: () => _navigateTo(i),
505
+ showBadge: i < meta.length &&
506
+ meta[i].icon == KasyIcons.notification &&
507
+ widget.notificationsUnread > 0,
499
508
  ),
500
509
  // Static showcase extras (incl. the Income submenu).
501
510
  for (final item in _kMainItems.skip(1))
@@ -842,6 +851,37 @@ class _KasySidebarState extends State<KasySidebar> {
842
851
 
843
852
  // ── Generic row (expanded) / icon+tooltip (collapsed) ────────────────────────
844
853
 
854
+ /// Overlays a small unread dot on the top-right of [child] when [show] is true.
855
+ /// The dot carries a thin border in the sidebar background color so it reads
856
+ /// cleanly over the icon.
857
+ Widget _withBadgeDot({
858
+ required Widget child,
859
+ required bool show,
860
+ required Color dotColor,
861
+ required Color borderColor,
862
+ }) {
863
+ if (!show) return child;
864
+ return Stack(
865
+ clipBehavior: Clip.none,
866
+ children: [
867
+ child,
868
+ Positioned(
869
+ top: -2,
870
+ right: -2,
871
+ child: Container(
872
+ width: 8,
873
+ height: 8,
874
+ decoration: BoxDecoration(
875
+ color: dotColor,
876
+ shape: BoxShape.circle,
877
+ border: Border.all(color: borderColor, width: 1.5),
878
+ ),
879
+ ),
880
+ ),
881
+ ],
882
+ );
883
+ }
884
+
845
885
  Widget _buildItemRow(
846
886
  _SidebarColors c, {
847
887
  required IconData icon,
@@ -849,6 +889,7 @@ class _KasySidebarState extends State<KasySidebar> {
849
889
  required bool isActive,
850
890
  required VoidCallback onTap,
851
891
  bool isLogout = false,
892
+ bool showBadge = false,
852
893
  List<Widget> trailing = const [],
853
894
  double bottomGap = _kItemGap,
854
895
  }) {
@@ -861,14 +902,19 @@ class _KasySidebarState extends State<KasySidebar> {
861
902
  if (_collapsed) {
862
903
  return Padding(
863
904
  padding: EdgeInsets.only(bottom: bottomGap),
864
- child: _ProTooltipIcon(
865
- icon: icon,
866
- label: label,
867
- iconBg: fill,
868
- iconColor: iconColor,
869
- activeBg: c.activeBg,
870
- colors: c,
871
- onTap: onTap,
905
+ child: _withBadgeDot(
906
+ show: showBadge,
907
+ dotColor: c.logout,
908
+ borderColor: c.bg,
909
+ child: _ProTooltipIcon(
910
+ icon: icon,
911
+ label: label,
912
+ iconBg: fill,
913
+ iconColor: iconColor,
914
+ activeBg: c.activeBg,
915
+ colors: c,
916
+ onTap: onTap,
917
+ ),
872
918
  ),
873
919
  );
874
920
  }
@@ -894,7 +940,12 @@ class _KasySidebarState extends State<KasySidebar> {
894
940
  ),
895
941
  child: Row(
896
942
  children: [
897
- Icon(icon, size: _kIconSize, color: iconColor),
943
+ _withBadgeDot(
944
+ show: showBadge,
945
+ dotColor: c.logout,
946
+ borderColor: c.bg,
947
+ child: Icon(icon, size: _kIconSize, color: iconColor),
948
+ ),
898
949
  const SizedBox(width: _kIconGap),
899
950
  Expanded(
900
951
  child: Text(
@@ -14,6 +14,7 @@ import 'package:kasy_kit/core/states/logout_action.dart';
14
14
  import 'package:kasy_kit/core/states/user_state_notifier.dart';
15
15
  import 'package:kasy_kit/core/theme/theme.dart';
16
16
  import 'package:kasy_kit/core/widgets/responsive_layout.dart';
17
+ import 'package:kasy_kit/features/notifications/providers/unread_notifications_count_provider.dart';
17
18
  import 'package:kasy_kit/features/settings/ui/widgets/kasy_user_avatar.dart';
18
19
  import 'package:kasy_kit/i18n/translations.g.dart';
19
20
 
@@ -64,6 +65,10 @@ class BottomMenu extends StatelessWidget {
64
65
  builder: (context, ref, _) {
65
66
  final User user =
66
67
  ref.watch(userStateNotifierProvider).user;
68
+ final int unread = ref
69
+ .watch(unreadNotificationsCountProvider)
70
+ .value ??
71
+ 0;
67
72
  final (String name, String email) = switch (user) {
68
73
  final AuthenticatedUserData u => (
69
74
  (u.name?.isNotEmpty ?? false)
@@ -81,6 +86,7 @@ class BottomMenu extends StatelessWidget {
81
86
  profileName: name,
82
87
  profileEmail: email,
83
88
  profileAvatar: const KasyUserAvatar(),
89
+ notificationsUnread: unread,
84
90
  );
85
91
  },
86
92
  ),
@@ -1,48 +1,27 @@
1
1
  import 'package:flutter/material.dart';
2
2
  import 'package:flutter_riverpod/flutter_riverpod.dart';
3
- import 'package:kasy_kit/core/states/user_state_notifier.dart';
4
3
  import 'package:kasy_kit/core/theme/theme.dart';
5
- import 'package:kasy_kit/features/notifications/repositories/notifications_repository.dart';
4
+ import 'package:kasy_kit/features/notifications/providers/unread_notifications_count_provider.dart';
6
5
 
7
- class BottomItemNotification extends ConsumerStatefulWidget {
6
+ /// Bottom-bar notifications icon with an unread-count badge.
7
+ ///
8
+ /// The badge is purely an unread indicator (works on web too) — it is not tied
9
+ /// to push notifications, which are native-only. The count comes from the shared
10
+ /// [unreadNotificationsCountProvider], which re-subscribes per signed-in user.
11
+ class BottomItemNotification extends ConsumerWidget {
8
12
  const BottomItemNotification({super.key});
9
13
 
10
14
  @override
11
- ConsumerState<ConsumerStatefulWidget> createState() =>
12
- _BottomItemNotificationState();
13
- }
14
-
15
- class _BottomItemNotificationState
16
- extends ConsumerState<BottomItemNotification> {
17
- Stream<int>? _count$;
18
-
19
- @override
20
- Widget build(BuildContext context) {
21
- final String? userId =
22
- ref.watch(userStateNotifierProvider).user.idOrNull;
15
+ Widget build(BuildContext context, WidgetRef ref) {
23
16
  const icon = Icon(KasyIcons.notification);
24
-
25
- if (userId == null) {
26
- return icon;
27
- }
28
-
29
- _count$ ??= ref
30
- .read(notificationRepositoryProvider)
31
- .listenToUnreadNotificationsCount(userId);
32
-
33
- return StreamBuilder<int>(
34
- key: const ValueKey('notification-count'),
35
- stream: _count$,
36
- builder: (context, snapshot) {
37
- final count = snapshot.data ?? 0;
38
- if (count == 0) return icon;
39
- return Badge(
40
- backgroundColor: context.colors.error,
41
- textColor: context.colors.onPrimary,
42
- label: Text(count > 99 ? '99+' : '$count'),
43
- child: icon,
44
- );
45
- },
17
+ final int count =
18
+ ref.watch(unreadNotificationsCountProvider).value ?? 0;
19
+ if (count == 0) return icon;
20
+ return Badge(
21
+ backgroundColor: context.colors.error,
22
+ textColor: context.colors.onError,
23
+ label: Text(count > 99 ? '99+' : '$count'),
24
+ child: icon,
46
25
  );
47
26
  }
48
27
  }
@@ -0,0 +1,17 @@
1
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
2
+ import 'package:kasy_kit/core/states/user_state_notifier.dart';
3
+ import 'package:kasy_kit/features/notifications/repositories/notifications_repository.dart';
4
+
5
+ /// Live count of the signed-in user's unread notifications. Emits 0 when there
6
+ /// is no user. Shared source for badging the notifications item everywhere it
7
+ /// appears in the navigation (bottom bar, sidebar).
8
+ ///
9
+ /// Watches the user id, so it automatically re-subscribes to the right stream
10
+ /// when the account changes (avoids leaking the previous user's count).
11
+ final unreadNotificationsCountProvider = StreamProvider.autoDispose<int>((ref) {
12
+ final String? userId = ref.watch(userStateNotifierProvider).user.idOrNull;
13
+ if (userId == null) return Stream<int>.value(0);
14
+ return ref
15
+ .read(notificationRepositoryProvider)
16
+ .listenToUnreadNotificationsCount(userId);
17
+ });
@@ -14,12 +14,18 @@ class DeleteUserButton extends ConsumerWidget {
14
14
  label: t.settings.delete_account.button,
15
15
  variant: KasyButtonVariant.ghost,
16
16
  foregroundColor: context.colors.muted,
17
- onPressed: () => showKasyConfirmDialog(
17
+ onPressed: () {
18
+ // Only warn about losing the subscription when the user actually has an
19
+ // active one — otherwise the generic permanent-deletion warning.
20
+ final bool isSubscriber =
21
+ ref.read(userStateNotifierProvider).subscription?.isActive ?? false;
22
+ final account = t.settings.delete_account;
23
+ showKasyConfirmDialog(
18
24
  context,
19
- title: t.settings.delete_account.title,
20
- message: t.settings.delete_account.content,
21
- cancelLabel: t.settings.delete_account.cancel,
22
- confirmLabel: t.settings.delete_account.confirm,
25
+ title: account.title,
26
+ message: isSubscriber ? account.content_subscriber : account.content,
27
+ cancelLabel: account.cancel,
28
+ confirmLabel: account.confirm,
23
29
  destructive: true,
24
30
  onConfirmAsync: () async {
25
31
  try {
@@ -34,7 +40,8 @@ class DeleteUserButton extends ConsumerWidget {
34
40
  }
35
41
  }
36
42
  },
37
- ),
43
+ );
44
+ },
38
45
  );
39
46
  }
40
47
  }
@@ -502,7 +502,8 @@
502
502
  "delete_account": {
503
503
  "button": "I want to delete my account",
504
504
  "title": "Delete your account?",
505
- "content": "Warning: this is permanent. You will lose any active subscription, and creating a new account later (even with the same email) will not restore it.",
505
+ "content": "Warning: this action is permanent and cannot be undone.",
506
+ "content_subscriber": "Warning: this is permanent. You will lose your active subscription, and creating a new account later (even with the same email) will not restore it.",
506
507
  "cancel": "Cancel",
507
508
  "confirm": "Yes, delete",
508
509
  "error": "Something went wrong. Please try again."
@@ -502,7 +502,8 @@
502
502
  "delete_account": {
503
503
  "button": "Quiero eliminar mi cuenta",
504
504
  "title": "¿Quieres eliminar tu cuenta?",
505
- "content": "Advertencia: esta acción es permanente. Perderás cualquier suscripción activa, y crear una cuenta nueva más tarde (incluso con el mismo correo) no la recuperará.",
505
+ "content": "Advertencia: esta acción es permanente y no se puede deshacer.",
506
+ "content_subscriber": "Advertencia: esta acción es permanente. Perderás tu suscripción activa, y crear una cuenta nueva más tarde (incluso con el mismo correo) no la recuperará.",
506
507
  "cancel": "Cancelar",
507
508
  "confirm": "Sí, eliminar",
508
509
  "error": "Algo salió mal. Por favor, inténtalo de nuevo."
@@ -502,7 +502,8 @@
502
502
  "delete_account": {
503
503
  "button": "Quero excluir minha conta",
504
504
  "title": "Quer excluir sua conta?",
505
- "content": "Atenção: esta ação é permanente. Você perde qualquer assinatura ativa, e criar uma nova conta depois (mesmo com o mesmo e-mail) não recupera ela.",
505
+ "content": "Atenção: esta ação é permanente e não pode ser desfeita.",
506
+ "content_subscriber": "Atenção: esta ação é permanente. Você perde sua assinatura ativa, e criar uma nova conta depois (mesmo com o mesmo e-mail) não recupera ela.",
506
507
  "cancel": "Cancelar",
507
508
  "confirm": "Sim, excluir",
508
509
  "error": "Algo deu errado. Por favor, tente novamente."