kasy-cli 1.31.12 → 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.
@@ -37,6 +37,7 @@ class StripeBackendApi {
37
37
  required String priceId,
38
38
  String? successUrl,
39
39
  String? cancelUrl,
40
+ String? locale,
40
41
  }) async {
41
42
  final Response res = await _client.post(
42
43
  '/stripe/checkout-session',
@@ -44,6 +45,7 @@ class StripeBackendApi {
44
45
  'priceId': priceId,
45
46
  if (successUrl != null) 'successUrl': successUrl,
46
47
  if (cancelUrl != null) 'cancelUrl': cancelUrl,
48
+ if (locale != null) 'locale': locale,
47
49
  },
48
50
  );
49
51
  return (res.data as Map)['url'] as String;
@@ -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 };
@@ -71,6 +71,29 @@ Deno.serve(async (req: Request) => {
71
71
 
72
72
  const supabaseAdmin = createClient(supabaseUrl, serviceRoleKey);
73
73
 
74
+ // Best-effort Stripe teardown BEFORE deletion: deleting the Stripe customer
75
+ // cancels all of its subscriptions, so billing stops for the deleted account.
76
+ // The DB rows (stripe_customers, subscriptions, ...) are removed by ON DELETE
77
+ // CASCADE, but that does not reach Stripe. Guarded by the secret so a
78
+ // non-Stripe app simply skips it.
79
+ const stripeKey = Deno.env.get("STRIPE_SECRET_KEY");
80
+ if (stripeKey) {
81
+ try {
82
+ const { data: cust } = await supabaseAdmin
83
+ .from("stripe_customers")
84
+ .select("customer_id")
85
+ .eq("user_id", user.id)
86
+ .maybeSingle();
87
+ const customerId = cust?.customer_id as string | undefined;
88
+ if (customerId) {
89
+ const { default: Stripe } = await import("npm:stripe@18");
90
+ await new Stripe(stripeKey).customers.del(customerId);
91
+ }
92
+ } catch (e) {
93
+ console.error("[delete-user-account] Stripe cleanup failed:", e);
94
+ }
95
+ }
96
+
74
97
  try {
75
98
  const { error: deleteError } = await supabaseAdmin.auth.admin.deleteUser(user.id);
76
99
 
@@ -97,7 +97,7 @@ Deno.serve(async (req: Request) => {
97
97
  }
98
98
  const uid = user.id;
99
99
 
100
- let body: { priceId?: string; successUrl?: string; cancelUrl?: string };
100
+ let body: { priceId?: string; successUrl?: string; cancelUrl?: string; locale?: string };
101
101
  try {
102
102
  body = await req.json();
103
103
  } catch {
@@ -109,10 +109,17 @@ Deno.serve(async (req: Request) => {
109
109
  }
110
110
  const successUrl = body.successUrl ?? "";
111
111
  const cancelUrl = body.cancelUrl ?? successUrl;
112
+ const locale = body.locale?.substring(0, 2).toLowerCase();
112
113
 
113
114
  try {
114
115
  const stripe = new Stripe(secretKey);
115
116
  const admin = createClient(supabaseUrl, serviceRoleKey);
117
+ // Persist the app language on the user so server-side notifications are sent
118
+ // in the right language (on web there is no registered device to read it
119
+ // from). Mirrors the Firebase checkout behavior.
120
+ if (locale) {
121
+ await admin.from("users").update({ locale }).eq("id", uid);
122
+ }
116
123
  const customerId = await getOrCreateCustomer(stripe, admin, uid, user.email);
117
124
 
118
125
  const price = await stripe.prices.retrieve(priceId, { expand: ["product"] });
@@ -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';
@@ -211,6 +212,18 @@ Note: wait a minute after enabling anonymous sign-in before trying again. It tak
211
212
 
212
213
  @override
213
214
  Future<Credentials> signinWithGoogle() async {
215
+ if (kIsWeb) {
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,
224
+ );
225
+ return Credentials(id: res.user!.id, token: res.session?.accessToken ?? '');
226
+ }
214
227
  final googleSignIn = GoogleSignIn.instance;
215
228
  // Web: clientId = Web Client ID (no serverClientId needed)
216
229
  // Native iOS: clientId = iOS Client ID, serverClientId = Web Client ID
@@ -252,6 +265,33 @@ Note: wait a minute after enabling anonymous sign-in before trying again. It tak
252
265
 
253
266
  }
254
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
+
255
295
  @override
256
296
  Future<Credentials> signinWithGooglePlay() {
257
297
  // Google Play Games sign-in is not supported for Supabase backend.
@@ -310,6 +350,28 @@ Note: wait a minute after enabling anonymous sign-in before trying again. It tak
310
350
 
311
351
  @override
312
352
  Future<Credentials> signupFromAnonymousWithGoogle() async {
353
+ if (kIsWeb) {
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
+ }
374
+ }
313
375
  final scopes = ['email'];
314
376
  final googleSignIn = GoogleSignIn.instance;
315
377
 
@@ -29,6 +29,7 @@ class StripeBackendApi {
29
29
  required String priceId,
30
30
  String? successUrl,
31
31
  String? cancelUrl,
32
+ String? locale,
32
33
  }) async {
33
34
  final res = await _client.functions.invoke(
34
35
  'stripe-create-checkout-session',
@@ -36,6 +37,7 @@ class StripeBackendApi {
36
37
  'priceId': priceId,
37
38
  if (successUrl != null) 'successUrl': successUrl,
38
39
  if (cancelUrl != null) 'cancelUrl': cancelUrl,
40
+ if (locale != null) 'locale': locale,
39
41
  },
40
42
  );
41
43
  return (res.data as Map)['url'] as String;
@@ -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.12",
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"
@@ -31,7 +31,7 @@
31
31
  "access": "public"
32
32
  },
33
33
  "scripts": {
34
- "prepack": "node scripts/check-feature-patches.js && node scripts/bundle-template.js && node test/generated-matches-kit.test.js && node test/supabase-verify-jwt.test.js && node test/supabase-cors.test.js && node test/backend-pubspec-local-reminders.test.js",
34
+ "prepack": "node scripts/check-feature-patches.js && node scripts/bundle-template.js && node test/generated-matches-kit.test.js && node test/supabase-verify-jwt.test.js && node test/supabase-cors.test.js && node test/supabase-google-web.test.js && node test/backend-pubspec-local-reminders.test.js",
35
35
  "start": "node ./bin/kasy.js",
36
36
  "setup": "node ./bin/kasy.js setup",
37
37
  "doctor": "node ./bin/kasy.js doctor",
@@ -1,5 +1,6 @@
1
1
  import {error} from "firebase-functions/logger";
2
2
  import {onCall, onRequest, HttpsError} from "firebase-functions/v2/https";
3
+ import {onDocumentDeleted} from "firebase-functions/v2/firestore";
3
4
  import {defineSecret, defineString} from "firebase-functions/params";
4
5
  import * as admin from "firebase-admin";
5
6
  import {Timestamp} from "firebase-admin/firestore";
@@ -255,3 +256,39 @@ export const stripeWebhook = onRequest(
255
256
  }
256
257
  },
257
258
  );
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // onUserDeletedCleanupStripe — tears down Stripe state when an account is
262
+ // deleted. deleteUserAccount removes the users/{uid} doc; this trigger fires on
263
+ // that deletion and (a) deletes the Stripe customer, which immediately cancels
264
+ // all of its subscriptions so billing stops, and (b) removes the local
265
+ // uid -> customer mapping so no orphan stripe_customers doc is left behind.
266
+ // Lives in the Stripe module so a non-Stripe app never deploys it.
267
+ // ---------------------------------------------------------------------------
268
+ export const onUserDeletedCleanupStripe = onDocumentDeleted(
269
+ {document: "users/{userId}", secrets: [stripeSecretKey]},
270
+ async (event) => {
271
+ const uid = event.params.userId;
272
+ const custRef = admin
273
+ .firestore()
274
+ .collection(CUSTOMERS_COLLECTION)
275
+ .doc(uid);
276
+ try {
277
+ const snap = await custRef.get();
278
+ const customerId = snap.data()?.customerId as string | undefined;
279
+ if (customerId) {
280
+ // Deleting the Stripe customer cancels all of its subscriptions, so
281
+ // billing stops for the deleted account in a single call.
282
+ await stripeClient().customers.del(customerId);
283
+ }
284
+ } catch (e) {
285
+ console.log(`[stripe-cleanup] could not delete Stripe customer for ${uid}: ${e}`);
286
+ }
287
+ // Always drop the local mapping so no orphan remains.
288
+ try {
289
+ await custRef.delete();
290
+ } catch (e) {
291
+ console.log(`[stripe-cleanup] could not delete stripe_customers/${uid}: ${e}`);
292
+ }
293
+ },
294
+ );
@@ -23,8 +23,6 @@
23
23
 
24
24
  library;
25
25
 
26
- import 'dart:ui' show ImageFilter;
27
-
28
26
  import 'package:flutter/foundation.dart' show kIsWeb;
29
27
  import 'package:flutter/material.dart';
30
28
  import 'package:flutter/services.dart' show SystemUiOverlayStyle;
@@ -111,17 +109,13 @@ Color kasyChromeOrbFillColor(BuildContext context) {
111
109
  class KasyFrostedChromeBackground extends StatelessWidget {
112
110
  final Widget child;
113
111
 
114
- /// Backdrop blur strength; higher = more frosted (content still visible through tint).
115
- final double blurSigma;
116
-
117
- /// Insets content below the notch but keeps tint/blur covering the status-bar
118
- /// strip so scroll never shows cards behind clock/battery icons.
112
+ /// Insets content below the notch but keeps the solid fill covering the
113
+ /// status-bar strip so scroll never shows cards behind clock/battery icons.
119
114
  final bool padForStatusBar;
120
115
 
121
116
  const KasyFrostedChromeBackground({
122
117
  super.key,
123
118
  required this.child,
124
- this.blurSigma = 14,
125
119
  this.padForStatusBar = true,
126
120
  });
127
121
 
@@ -165,16 +159,8 @@ class KasyFrostedChromeBackground extends StatelessWidget {
165
159
  return AnnotatedRegion<SystemUiOverlayStyle>(
166
160
  value: overlayStyle,
167
161
  child: DecoratedBox(
168
- decoration: BoxDecoration(boxShadow: chromeShadow),
169
- child: ClipRect(
170
- child: BackdropFilter(
171
- filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma),
172
- child: DecoratedBox(
173
- decoration: BoxDecoration(color: tint),
174
- child: content,
175
- ),
176
- ),
177
- ),
162
+ decoration: BoxDecoration(color: tint, boxShadow: chromeShadow),
163
+ child: content,
178
164
  ),
179
165
  );
180
166
  }
@@ -253,7 +239,6 @@ class KasyAppBar extends StatelessWidget {
253
239
  /// same tap target semantics as the built-in orbs.
254
240
  final Widget? trailing;
255
241
  final bool useSafeArea;
256
- final double frostBlurSigma;
257
242
 
258
243
  /// When set, called instead of [ThemeProvider.toggle] for the theme orb
259
244
  /// ([KasyAppBarStyle.subpage], [KasyAppBarStyle.rootTab]).
@@ -282,7 +267,6 @@ class KasyAppBar extends StatelessWidget {
282
267
  this.onBack,
283
268
  this.trailing,
284
269
  this.useSafeArea = true,
285
- this.frostBlurSigma = 14,
286
270
  this.onThemeToggle,
287
271
  this.toolbarHeight,
288
272
  this.topInset,
@@ -366,7 +350,6 @@ class KasyAppBar extends StatelessWidget {
366
350
  )
367
351
  : bar;
368
352
  final Widget chrome = KasyFrostedChromeBackground(
369
- blurSigma: frostBlurSigma,
370
353
  padForStatusBar: useSafeArea,
371
354
  child: barContent,
372
355
  );
@@ -506,6 +506,7 @@ Future<void> showKasyConfirmDialog(
506
506
  required String confirmLabel,
507
507
  VoidCallback? onCancel,
508
508
  VoidCallback? onConfirm,
509
+ Future<void> Function()? onConfirmAsync,
509
510
  bool destructive = false,
510
511
  bool barrierDismissible = false,
511
512
  IconData? leadingIcon,
@@ -517,37 +518,54 @@ Future<void> showKasyConfirmDialog(
517
518
  return showKasyBlurDialog<void>(
518
519
  context: context,
519
520
  barrierDismissible: barrierDismissible,
520
- builder: (dialogCtx) => KasyDialog(
521
- leadingIcon: leadingIcon ?? (destructive ? KasyIcons.trash : null),
522
- iconTone: destructive ? KasyDialogIconTone.danger : iconTone,
523
- title: title,
524
- titleCentered: leadingIcon == null && !destructive,
525
- message: message,
526
- showCloseButton: false,
527
- actionsAxis: Axis.horizontal,
528
- actions: [
529
- KasyButton(
530
- label: cancelLabel,
531
- variant: KasyButtonVariant.outline,
532
- expand: true,
533
- onPressed: () {
534
- Navigator.of(dialogCtx).pop();
535
- onCancel?.call();
536
- },
537
- ),
538
- KasyButton(
539
- label: confirmLabel,
540
- variant: destructive
541
- ? KasyButtonVariant.destructive
542
- : KasyButtonVariant.primary,
543
- expand: true,
544
- onPressed: () {
545
- Navigator.of(dialogCtx).pop();
546
- onConfirm?.call();
547
- },
521
+ builder: (dialogCtx) {
522
+ var isLoading = false;
523
+ return StatefulBuilder(
524
+ builder: (ctx, setState) => KasyDialog(
525
+ leadingIcon: leadingIcon ?? (destructive ? KasyIcons.trash : null),
526
+ iconTone: destructive ? KasyDialogIconTone.danger : iconTone,
527
+ title: title,
528
+ titleCentered: leadingIcon == null && !destructive,
529
+ message: message,
530
+ showCloseButton: false,
531
+ actionsAxis: Axis.horizontal,
532
+ actions: [
533
+ KasyButton(
534
+ label: cancelLabel,
535
+ variant: KasyButtonVariant.outline,
536
+ expand: true,
537
+ onPressed: isLoading
538
+ ? null
539
+ : () {
540
+ Navigator.of(dialogCtx).pop();
541
+ onCancel?.call();
542
+ },
543
+ ),
544
+ KasyButton(
545
+ label: confirmLabel,
546
+ variant: destructive
547
+ ? KasyButtonVariant.destructive
548
+ : KasyButtonVariant.primary,
549
+ expand: true,
550
+ isLoading: isLoading,
551
+ onPressed: isLoading
552
+ ? null
553
+ : () {
554
+ if (onConfirmAsync != null) {
555
+ setState(() => isLoading = true);
556
+ onConfirmAsync().whenComplete(() {
557
+ if (dialogCtx.mounted) Navigator.of(dialogCtx).pop();
558
+ });
559
+ } else {
560
+ Navigator.of(dialogCtx).pop();
561
+ onConfirm?.call();
562
+ }
563
+ },
564
+ ),
565
+ ],
548
566
  ),
549
- ],
550
- ),
567
+ );
568
+ },
551
569
  );
552
570
  }
553
571
 
@@ -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,15 +14,34 @@ 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
- onConfirm: () => ref.read(userStateNotifierProvider.notifier).deleteAccount(),
25
- ),
30
+ onConfirmAsync: () async {
31
+ try {
32
+ await ref.read(userStateNotifierProvider.notifier).deleteAccount();
33
+ } catch (_) {
34
+ if (context.mounted) {
35
+ showKasyToast(
36
+ context,
37
+ title: t.settings.delete_account.error,
38
+ tone: KasyToastTone.danger,
39
+ );
40
+ }
41
+ }
42
+ },
43
+ );
44
+ },
26
45
  );
27
46
  }
28
47
  }
@@ -502,9 +502,11 @@
502
502
  "delete_account": {
503
503
  "button": "I want to delete my account",
504
504
  "title": "Delete your account?",
505
- "content": "Warning: this action is irreversible.",
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
- "confirm": "Yes, delete"
508
+ "confirm": "Yes, delete",
509
+ "error": "Something went wrong. Please try again."
508
510
  },
509
511
  "admin": {
510
512
  "update_bottom_sheet": "Update bottom sheet",
@@ -502,9 +502,11 @@
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 irreversible.",
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
- "confirm": "Sí, eliminar"
508
+ "confirm": "Sí, eliminar",
509
+ "error": "Algo salió mal. Por favor, inténtalo de nuevo."
508
510
  },
509
511
  "admin": {
510
512
  "update_bottom_sheet": "Actualizar bottom sheet",
@@ -502,9 +502,11 @@
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 é irreversível.",
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
- "confirm": "Sim, excluir"
508
+ "confirm": "Sim, excluir",
509
+ "error": "Algo deu errado. Por favor, tente novamente."
508
510
  },
509
511
  "admin": {
510
512
  "update_bottom_sheet": "Atualizar bottom sheet",