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.
- package/lib/scaffold/backends/api/patch/lib/features/subscriptions/api/stripe_backend_api.dart +2 -0
- package/lib/scaffold/backends/supabase/deploy.js +5 -0
- package/lib/scaffold/backends/supabase/edge-functions/delete-user-account/index.ts +23 -0
- package/lib/scaffold/backends/supabase/edge-functions/stripe-create-checkout-session/index.ts +8 -1
- package/lib/scaffold/backends/supabase/patch/lib/features/authentication/api/authentication_api.dart +62 -0
- package/lib/scaffold/backends/supabase/patch/lib/features/subscriptions/api/stripe_backend_api.dart +2 -0
- package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +5 -0
- package/package.json +2 -2
- package/templates/firebase/functions/src/subscriptions/stripe_functions.ts +37 -0
- package/templates/firebase/lib/components/kasy_app_bar.dart +4 -21
- package/templates/firebase/lib/components/kasy_dialog.dart +48 -30
- package/templates/firebase/lib/components/kasy_sidebar.dart +60 -9
- package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +6 -0
- package/templates/firebase/lib/core/bottom_menu/notification_bottom_item.dart +16 -37
- package/templates/firebase/lib/features/notifications/providers/unread_notifications_count_provider.dart +17 -0
- package/templates/firebase/lib/features/settings/ui/components/delete_user_component.dart +26 -7
- package/templates/firebase/lib/i18n/en.i18n.json +4 -2
- package/templates/firebase/lib/i18n/es.i18n.json +4 -2
- package/templates/firebase/lib/i18n/pt.i18n.json +4 -2
package/lib/scaffold/backends/api/patch/lib/features/subscriptions/api/stripe_backend_api.dart
CHANGED
|
@@ -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
|
|
package/lib/scaffold/backends/supabase/edge-functions/stripe-create-checkout-session/index.ts
CHANGED
|
@@ -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"] });
|
package/lib/scaffold/backends/supabase/patch/lib/features/authentication/api/authentication_api.dart
CHANGED
|
@@ -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
|
|
package/lib/scaffold/backends/supabase/patch/lib/features/subscriptions/api/stripe_backend_api.dart
CHANGED
|
@@ -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.
|
|
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
|
-
///
|
|
115
|
-
|
|
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:
|
|
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)
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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:
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
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
|
-
|
|
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/
|
|
4
|
+
import 'package:kasy_kit/features/notifications/providers/unread_notifications_count_provider.dart';
|
|
6
5
|
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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: ()
|
|
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:
|
|
20
|
-
message:
|
|
21
|
-
cancelLabel:
|
|
22
|
-
confirmLabel:
|
|
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
|
-
|
|
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
|
|
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
|
|
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 é
|
|
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",
|