kasy-cli 1.20.0 → 1.21.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +11 -3
  2. package/lib/commands/docs.js +0 -10
  3. package/lib/commands/ios.js +3 -2
  4. package/lib/commands/new.js +98 -58
  5. package/lib/commands/run.js +7 -0
  6. package/lib/scaffold/CHANGELOG.json +14 -0
  7. package/lib/scaffold/backends/api/patch/lib/core/data/api/meta_ads_api.dart +1 -1
  8. package/lib/scaffold/backends/api/patch/lib/core/data/api/storage_api.dart +1 -1
  9. package/lib/scaffold/backends/api/patch/lib/core/data/entities/user_entity.dart +1 -1
  10. package/lib/scaffold/backends/api/patch/lib/features/authentication/api/authentication_api.dart +4 -5
  11. package/lib/scaffold/backends/api/patch/lib/features/feedbacks/api/feature_request_api.dart +1 -1
  12. package/lib/scaffold/backends/api/patch/lib/features/feedbacks/api/feature_vote_api.dart +1 -1
  13. package/lib/scaffold/backends/api/patch/lib/features/llm_chat/api/llm_chat_api.dart +1 -1
  14. package/lib/scaffold/backends/api/patch/lib/features/llm_chat/providers/llm_chat_notifier.dart +317 -0
  15. package/lib/scaffold/backends/api/patch/lib/features/notifications/api/device_api.dart +40 -1
  16. package/lib/scaffold/backends/api/patch/lib/features/notifications/api/entities/notifications_entity.dart +2 -0
  17. package/lib/scaffold/backends/api/patch/lib/features/onboarding/api/entities/user_info_entity.dart +1 -1
  18. package/lib/scaffold/backends/api/patch/lib/features/subscription/api/entities/subscription_entity.dart +1 -1
  19. package/lib/scaffold/backends/api/patch/lib/features/subscription/shared/maybeshow_premium.dart +0 -2
  20. package/lib/scaffold/backends/api/pubspec.yaml.tpl +2 -0
  21. package/lib/scaffold/backends/firebase/enable-auth-via-cli.js +11 -8
  22. package/lib/scaffold/backends/firebase/setup-from-scratch.js +10 -8
  23. package/lib/scaffold/backends/supabase/deploy.js +56 -3
  24. package/lib/scaffold/backends/supabase/patch/lib/core/data/api/storage_api.dart +5 -11
  25. package/lib/scaffold/backends/supabase/patch/lib/core/data/entities/user_entity.dart +2 -2
  26. package/lib/scaffold/backends/supabase/patch/lib/features/notifications/api/device_api.dart +31 -1
  27. package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/api/entities/user_info_entity.dart +1 -1
  28. package/lib/scaffold/backends/supabase/patch/lib/features/subscription/api/entities/subscription_entity.dart +1 -1
  29. package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +2 -0
  30. package/lib/scaffold/catalog.js +2 -2
  31. package/lib/scaffold/generate.js +19 -3
  32. package/lib/scaffold/shared/generator-utils.js +265 -55
  33. package/lib/scaffold/shared/post-build.js +22 -6
  34. package/lib/utils/apple-release.js +1 -10
  35. package/lib/utils/browser.js +61 -0
  36. package/lib/utils/checks.js +189 -69
  37. package/lib/utils/env-tools.js +101 -0
  38. package/lib/utils/i18n/messages-en.js +13 -1
  39. package/lib/utils/i18n/messages-es.js +13 -1
  40. package/lib/utils/i18n/messages-pt.js +13 -1
  41. package/package.json +1 -1
  42. package/templates/firebase/lib/components/kasy_sidebar_pro.dart +8 -14
  43. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +38 -128
  44. package/templates/firebase/lib/core/bottom_menu/bottom_router.dart +6 -125
  45. package/templates/firebase/lib/core/states/user_state_notifier.dart +8 -10
  46. package/templates/firebase/lib/core/widgets/kasy_hover.dart +9 -1
  47. package/templates/firebase/lib/features/home/home_components_page.dart +8 -14
  48. package/templates/firebase/lib/features/home/home_page.dart +7 -8
  49. package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +1 -0
  50. package/templates/firebase/lib/router.dart +60 -0
  51. package/templates/firebase/test/core/bottom_menu/detail_route_menu_test.dart +57 -0
  52. package/lib/scaffold/backends/api/patch/lib/core/rating/widgets/review_popup.dart +0 -211
  53. package/lib/scaffold/backends/api/patch/lib/features/notifications/providers/models/notification.dart +0 -185
  54. package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/components/onboarding_notifications_setup.dart +0 -73
  55. package/lib/scaffold/backends/api/patch/lib/main.dart +0 -275
  56. package/lib/scaffold/backends/api/patch/lib/router.dart +0 -133
  57. package/lib/scaffold/backends/supabase/patch/lib/core/rating/widgets/review_popup.dart +0 -211
  58. package/lib/scaffold/backends/supabase/patch/lib/features/feedbacks/ui/component/add_feature_form.dart +0 -199
  59. package/lib/scaffold/backends/supabase/patch/lib/features/notifications/providers/models/notification.dart +0 -174
  60. package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/components/onboarding_notifications_setup.dart +0 -73
  61. package/lib/scaffold/backends/supabase/patch/lib/main.dart +0 -307
  62. package/lib/scaffold/backends/supabase/patch/lib/router.dart +0 -133
@@ -99,12 +99,18 @@ class FirebaseDeviceApi implements DeviceApi {
99
99
  }
100
100
  }
101
101
  final token = await _messaging.getToken();
102
+ if (token == null) {
103
+ throw ApiError(
104
+ code: 0,
105
+ message: 'FCM token is null — check Firebase setup and notification permissions',
106
+ );
107
+ }
102
108
  final os = Platform.isAndroid
103
109
  ? OperatingSystem.android //
104
110
  : OperatingSystem.ios;
105
111
  return DeviceEntity(
106
112
  installationId: installationId,
107
- token: token!,
113
+ token: token,
108
114
  operatingSystem: os,
109
115
  creationDate: DateTime.now(),
110
116
  lastUpdateDate: DateTime.now(),
@@ -289,6 +295,7 @@ class FirebaseDeviceApi implements DeviceApi {
289
295
  }
290
296
 
291
297
  Future<String?> getIpAddress() async {
298
+ if (kIsWeb) return null;
292
299
  try {
293
300
  // First, try to find a public IP in network interfaces
294
301
  final interfaces = await io.NetworkInterface.list();
@@ -333,6 +340,29 @@ class FirebaseDeviceApi implements DeviceApi {
333
340
  /// Returns a map with all device information
334
341
  @override
335
342
  Future<Map<String, String>> fetchDeviceProperties() async {
343
+ // On web there is no native device layer (no NetworkInterface, no Platform):
344
+ // return static values so the app works as a PWA without throwing at runtime.
345
+ if (kIsWeb) {
346
+ final webLocale = PlatformDispatcher.instance.locale.toLanguageTag().replaceAll('-', '_');
347
+ return {
348
+ 'appLongVersion': '',
349
+ 'osVersion': 'web',
350
+ 'deviceModel': 'browser',
351
+ 'deviceLocale': webLocale,
352
+ 'timezone': '',
353
+ 'carrier': '',
354
+ 'screenWidth': '',
355
+ 'screenHeight': '',
356
+ 'screenDensity': '',
357
+ 'cpuCores': '',
358
+ 'storageSize': '',
359
+ 'freeStorage': '',
360
+ 'deviceTimezone': '',
361
+ 'mobileAdvertiserId': '',
362
+ 'anonymousFbId': '',
363
+ 'clientIpAddress': '',
364
+ };
365
+ }
336
366
  try {
337
367
  final deviceInfo = DeviceInfoPlugin();
338
368
  final packageInfo = await PackageInfo.fromPlatform();
@@ -18,7 +18,7 @@ sealed class UserInfoEntity with _$UserInfoEntity {
18
18
  @JsonKey(name: 'user_id') required String userId,
19
19
  @JsonKey(name: 'info_key') required String key,
20
20
  @JsonKey(name: 'info_value') required String value,
21
- }) = SubscriptionEntityData;
21
+ }) = UserInfoEntityData;
22
22
 
23
23
  factory UserInfoEntity.fromJson(Map<String, Object?> json) =>
24
24
  _$UserInfoEntityFromJson(json);
@@ -21,7 +21,7 @@ sealed class SubscriptionEntity with _$SubscriptionEntity {
21
21
  const factory SubscriptionEntity({
22
22
  @JsonKey(includeIfNull: false) String? id,
23
23
  @JsonKey(name: 'user_id') String? userId,
24
- @JsonKey(name: 'offer_id') required String offerId,
24
+ @JsonKey(name: 'offer_id') String? offerId,
25
25
  @JsonKey(name: 'sku_id') required String skuId,
26
26
  @JsonKey(name: 'creation_date') DateTime? creationDate,
27
27
  @JsonKey(name: 'last_update_date') DateTime? lastUpdateDate,
@@ -52,6 +52,7 @@ dependencies:
52
52
  intl: ^0.20.2
53
53
  jiffy: ^6.4.4
54
54
  json_annotation: ^4.9.0
55
+ local_auth: ^3.0.1
55
56
  logger: ^2.6.2
56
57
  lucide_icons_flutter: ^3.1.10
57
58
  mixpanel_flutter: ^2.5.0
@@ -73,6 +74,7 @@ dependencies:
73
74
  universal_html: ^2.3.0
74
75
  universal_io: ^2.3.1
75
76
  url_launcher: ^6.3.2
77
+ web: ^1.1.1
76
78
 
77
79
  dev_dependencies:
78
80
  build_runner: ^2.11.1
@@ -50,12 +50,12 @@ const FEATURE_CATALOG = [
50
50
  { id: 'revenuecat', displayName: 'RevenueCat', status: 'public', availableIn: ['firebase', 'supabase', 'api'], defaultInPresets: ['saas', 'full'], enhances: 'subscription' },
51
51
  // features
52
52
  { id: 'onboarding', displayName: 'Onboarding', status: 'public', availableIn: ['firebase', 'supabase', 'api'], defaultInPresets: ['starter', 'saas', 'content', 'full'] },
53
- { id: 'web', displayName: 'Web Support (PWA)', status: 'public', availableIn: ['firebase'], defaultInPresets: [], tag: 'firebaseOnly' },
53
+ { id: 'web', displayName: 'Web Support (PWA)', status: 'public', availableIn: ['firebase', 'supabase', 'api'], defaultInPresets: ['full'] },
54
54
  { id: 'widget', displayName: 'Home Widget', status: 'public', availableIn: ['firebase', 'supabase', 'api'], defaultInPresets: ['full'] },
55
55
  { id: 'llm_chat', displayName: 'AI Chat', status: 'public', availableIn: ['firebase', 'supabase', 'api'], defaultInPresets: ['content', 'full'] },
56
56
  { id: 'local_notifications', displayName: 'Local Reminders', status: 'public', availableIn: ['firebase', 'supabase', 'api'], defaultInPresets: [] },
57
57
  // feedback (Firebase/Supabase only)
58
- { id: 'feedback', displayName: 'Feature Requests', status: 'public', availableIn: ['firebase', 'supabase'], defaultInPresets: ['saas', 'full'], tag: 'requiresDb' },
58
+ { id: 'feedback', displayName: 'Feature Requests', status: 'public', availableIn: ['firebase', 'supabase', 'api'], defaultInPresets: ['saas', 'full'], tag: 'requiresDb' },
59
59
  // ci/cd
60
60
  { id: 'ci', displayName: 'CI/CD', status: 'public', availableIn: ['firebase', 'supabase', 'api'], defaultInPresets: ['full'] },
61
61
  ];
@@ -58,6 +58,7 @@ const {
58
58
  removeFacebookSigninFromAuthPages,
59
59
  removeAndroidFacebookMetadata,
60
60
  writeNoOpAdminHomeWidgets,
61
+ patchLanguageSwitcherNoWidget,
61
62
  writeNoOpFeatureRequestRepository,
62
63
  writeNoOpSubscriptionStubs,
63
64
  writeNoOpSentryUsages,
@@ -67,7 +68,7 @@ const {
67
68
  removeDevelopmentTeam,
68
69
  localizeReleaseDocs,
69
70
  } = require('./shared/generator-utils');
70
- const { pubGet, slangGenerate, buildRunner, dartFix, flutterfireConfigure, writeGoogleAuthOptions, writeGoogleIosUrlScheme, patchFirebaseServiceWorker } = require('./shared/post-build');
71
+ const { pubGet, slangGenerate, buildRunner, dartFix, flutterfireConfigure, writeGoogleAuthOptions, writeGoogleIosUrlScheme, patchFirebaseServiceWorker, deployFirestoreRules } = require('./shared/post-build');
71
72
  const { FIREBASE_SOURCE_DIR } = require('./shared/backend-config');
72
73
 
73
74
  /**
@@ -264,6 +265,9 @@ async function generateProject(targetDir, backend, options, hooks = {}) {
264
265
  // (imports home_widget_mywidget_service which only exists when widget is selected)
265
266
  if (!modules.includes('widget')) {
266
267
  await writeNoOpAdminHomeWidgets(targetDir);
268
+ // The language switcher pushes locale changes to the home widget — strip
269
+ // that hook so it doesn't reference the (now absent) widget provider.
270
+ await patchLanguageSwitcherNoWidget(targetDir);
267
271
  // Remove Android native widget files and unregister the receiver from the manifest
268
272
  // so the widget does not appear in the Android widget picker when not selected
269
273
  await removeAndroidWidgetArtifacts(targetDir, bundleId);
@@ -341,8 +345,10 @@ async function generateProject(targetDir, backend, options, hooks = {}) {
341
345
  });
342
346
  }
343
347
 
344
- // iOS: register REVERSED_CLIENT_ID as a URL scheme in ios/Runner/Info.plist
345
- if (!deferGoogleAuthPatches) {
348
+ // iOS: register REVERSED_CLIENT_ID as a URL scheme in ios/Runner/Info.plist.
349
+ // Skip for Supabase — REVERSED_CLIENT_ID isn't present until Google Sign-In is
350
+ // enabled later in new.js; that flow calls writeGoogleIosUrlSchemeFromClientId instead.
351
+ if (backend !== 'supabase' && !deferGoogleAuthPatches) {
346
352
  const iosSchemeResult = await writeGoogleIosUrlScheme(targetDir);
347
353
  steps.push({
348
354
  name: 'google-ios-url-scheme',
@@ -367,6 +373,16 @@ async function generateProject(targetDir, backend, options, hooks = {}) {
367
373
  // (the dead lib/firebase_options.dart we no longer ship).
368
374
  await cleanFirebaseJsonKitRefs(targetDir);
369
375
 
376
+ // Deploy Firestore security rules so the app works immediately after
377
+ // `kasy new` without needing `kasy deploy` first. Fast (<30s), billing-free.
378
+ // Without this the project gets Firebase's default deny-all rules, causing
379
+ // every Firestore read to throw permission-denied and the app to log the user out.
380
+ if (firebaseProjectId) {
381
+ onProgress('firestore-rules');
382
+ const rulesResult = await deployFirestoreRules(targetDir, firebaseProjectId);
383
+ steps.push({ name: 'firestore-rules', ok: rulesResult.ok, detail: rulesResult.ok ? null : rulesResult.error });
384
+ }
385
+
370
386
  // ── 3. Post-build específico do backend ────────────────────────────────────
371
387
  if (postBuild) {
372
388
  try {
@@ -318,12 +318,14 @@ async function writeRouter(projectDir, modules, packageName, defaultPaywall = 'b
318
318
  const withOnboarding = modules.includes('onboarding');
319
319
  const withRevenuecat = modules.includes('revenuecat');
320
320
  const withAnalytics = modules.includes('analytics');
321
+ const withWidget = modules.includes('widget');
321
322
 
322
323
  const fallback = withOnboarding ? '/onboarding' : '/signin';
323
324
 
324
325
  const lines = [];
325
326
 
326
327
  // ── Imports ────────────────────────────────────────────────────────────────
328
+ lines.push(`import 'package:flutter/foundation.dart';`);
327
329
  lines.push(`import 'package:flutter/material.dart';`);
328
330
  lines.push(`import 'package:flutter_riverpod/flutter_riverpod.dart';`);
329
331
  lines.push(`import 'package:go_router/go_router.dart';`);
@@ -340,6 +342,12 @@ async function writeRouter(projectDir, modules, packageName, defaultPaywall = 'b
340
342
  if (withFeedback) {
341
343
  lines.push(`import 'package:${pkg}/features/feedbacks/ui/feedback_page.dart';`);
342
344
  }
345
+ // Home showcase detail screens — always present (not module-gated).
346
+ lines.push(`import 'package:${pkg}/features/home/design_system_page.dart';`);
347
+ lines.push(`import 'package:${pkg}/features/home/home_components_page.dart';`);
348
+ lines.push(`import 'package:${pkg}/features/home/home_components_preview_page.dart';`);
349
+ lines.push(`import 'package:${pkg}/features/home/home_components_preview_registry.dart';`);
350
+ lines.push(`import 'package:${pkg}/features/home/home_features_page.dart';`);
343
351
  if (withLlmChat) {
344
352
  lines.push(`import 'package:${pkg}/features/llm_chat/llm_chat_page.dart';`);
345
353
  }
@@ -349,10 +357,22 @@ async function writeRouter(projectDir, modules, packageName, defaultPaywall = 'b
349
357
  if (withOnboarding) {
350
358
  lines.push(`import 'package:${pkg}/features/onboarding/ui/onboarding_page.dart';`);
351
359
  }
360
+ // Admin tools: send-push is always available; paywalls/home-widgets only
361
+ // when their module exists. The screens themselves are gated by kDebugMode.
362
+ // Kept in alphabetical order to satisfy directives_ordering.
363
+ if (withWidget) {
364
+ lines.push(`import 'package:${pkg}/features/settings/ui/components/admin/admin_home_widgets.dart';`);
365
+ }
366
+ if (withRevenuecat) {
367
+ lines.push(`import 'package:${pkg}/features/settings/ui/components/admin/admin_paywalls.dart';`);
368
+ }
369
+ lines.push(`import 'package:${pkg}/features/settings/ui/components/admin/admin_routes.dart';`);
370
+ lines.push(`import 'package:${pkg}/features/settings/ui/components/admin/send_push_notification_page.dart';`);
352
371
  if (withRevenuecat) {
353
372
  lines.push(`import 'package:${pkg}/features/subscription/ui/component/premium_page_factory.dart';`);
354
373
  lines.push(`import 'package:${pkg}/features/subscription/ui/premium_page.dart';`);
355
374
  }
375
+ lines.push(`import 'package:logger/logger.dart';`);
356
376
 
357
377
  // ── Provider ──────────────────────────────────────────────────────────────
358
378
  lines.push('');
@@ -374,7 +394,16 @@ async function writeRouter(projectDir, modules, packageName, defaultPaywall = 'b
374
394
  lines.push(` return GoRouter(`);
375
395
  lines.push(` initialLocation: '/',`);
376
396
  lines.push(` navigatorKey: navigatorKey,`);
377
- lines.push(` errorBuilder: (context, state) => const PageNotFound(),`);
397
+ // Unknown routes (e.g. a stale deep link) redirect home instead of dead-ending
398
+ // on a 404. The /404 GoRoute below still serves explicit navigation to it.
399
+ lines.push(` onException: (context, state, router) {`);
400
+ lines.push(` Logger().w(`);
401
+ lines.push(` 'GoRouter caught unknown route → "\${state.uri}" '`);
402
+ lines.push(` '(matched: "\${state.matchedLocation}", error: \${state.error}). '`);
403
+ lines.push(` 'Redirecting to "/".',`);
404
+ lines.push(` );`);
405
+ lines.push(` router.go('/');`);
406
+ lines.push(` },`);
378
407
  lines.push(` observers: [`);
379
408
  if (withAnalytics) {
380
409
  lines.push(` AnalyticsObserver(analyticsApi: MixpanelAnalyticsApi.instance()),`);
@@ -393,6 +422,42 @@ async function writeRouter(projectDir, modules, packageName, defaultPaywall = 'b
393
422
  lines.push(` ),`);
394
423
  lines.push(` ),`);
395
424
 
425
+ // Home showcase detail screens. TOP-LEVEL routes (siblings of '/', not children
426
+ // of the BottomMenu shell), so go_router renders them on the root navigator:
427
+ // full-screen, above the bottom bar, URL-addressable. Returning pops back to
428
+ // the tab with its menu intact.
429
+ lines.push(` GoRoute(`);
430
+ lines.push(` name: 'features',`);
431
+ lines.push(` path: '/features',`);
432
+ lines.push(` builder: (context, state) => const HomeFeaturesPage(),`);
433
+ lines.push(` ),`);
434
+ lines.push(` GoRoute(`);
435
+ lines.push(` name: 'design_system',`);
436
+ lines.push(` path: '/design-system',`);
437
+ lines.push(` builder: (context, state) => const DesignSystemPage(),`);
438
+ lines.push(` ),`);
439
+ lines.push(` GoRoute(`);
440
+ lines.push(` name: 'components',`);
441
+ lines.push(` path: '/components',`);
442
+ lines.push(` builder: (context, state) => const HomeComponentsPage(),`);
443
+ lines.push(` routes: [`);
444
+ lines.push(` GoRoute(`);
445
+ lines.push(` name: 'component_preview',`);
446
+ lines.push(` path: ':name',`);
447
+ lines.push(` builder: (context, state) {`);
448
+ lines.push(` final definition = getComponentPreviewDefinition(`);
449
+ lines.push(` state.pathParameters['name'] ?? '',`);
450
+ lines.push(` );`);
451
+ lines.push(` if (definition == null) return const PageNotFound();`);
452
+ lines.push(` return HomeComponentsPreviewPage(`);
453
+ lines.push(` title: definition.title,`);
454
+ lines.push(` variants: definition.variants,`);
455
+ lines.push(` );`);
456
+ lines.push(` },`);
457
+ lines.push(` ),`);
458
+ lines.push(` ],`);
459
+ lines.push(` ),`);
460
+
396
461
  // Onboarding
397
462
  if (withOnboarding) {
398
463
  lines.push(` GoRoute(`);
@@ -458,6 +523,64 @@ async function writeRouter(projectDir, modules, packageName, defaultPaywall = 'b
458
523
  lines.push(` ),`);
459
524
  }
460
525
 
526
+ // Notifications + Settings: open the BottomMenu shell on the right tab.
527
+ // These also back the deep links from the settings/admin UI.
528
+ lines.push(` GoRoute(`);
529
+ lines.push(` name: 'notifications',`);
530
+ lines.push(` path: '/notifications',`);
531
+ lines.push(` builder: (context, state) => const UserInfosGuard(`);
532
+ lines.push(` fallbackRoute: '${fallback}',`);
533
+ lines.push(` child: BottomMenu(initialRoute: 'notifications'),`);
534
+ lines.push(` ),`);
535
+ lines.push(` ),`);
536
+ lines.push(` GoRoute(`);
537
+ lines.push(` name: 'settings',`);
538
+ lines.push(` path: '/settings',`);
539
+ lines.push(` builder: (context, state) => const UserInfosGuard(`);
540
+ lines.push(` fallbackRoute: '${fallback}',`);
541
+ lines.push(` child: BottomMenu(initialRoute: 'settings'),`);
542
+ lines.push(` ),`);
543
+ lines.push(` ),`);
544
+
545
+ // Send push notification (admin tool — always routable; the entry point in
546
+ // the settings admin sheet is itself debug-gated).
547
+ lines.push(` GoRoute(`);
548
+ lines.push(` name: 'send_push',`);
549
+ lines.push(` path: adminRouteSendPush,`);
550
+ lines.push(` builder: (context, state) => const SendPushNotificationPage(),`);
551
+ lines.push(` ),`);
552
+
553
+ // Debug-only admin screens (paywall preview / home widgets preview).
554
+ if (withRevenuecat || withWidget) {
555
+ lines.push(` if (kDebugMode) ...[`);
556
+ if (withRevenuecat) {
557
+ lines.push(` GoRoute(`);
558
+ lines.push(` name: 'admin_paywalls',`);
559
+ lines.push(` path: adminRoutePaywalls,`);
560
+ lines.push(` builder: (context, state) => const AdminPaywalls(),`);
561
+ lines.push(` ),`);
562
+ }
563
+ if (withWidget) {
564
+ lines.push(` GoRoute(`);
565
+ lines.push(` name: 'admin_home_widgets',`);
566
+ lines.push(` path: adminRouteHomeWidgets,`);
567
+ lines.push(` builder: (context, state) => const AdminHomeWidgets(),`);
568
+ lines.push(` ),`);
569
+ }
570
+ if (withRevenuecat) {
571
+ lines.push(` GoRoute(`);
572
+ lines.push(` name: 'admin_premium_preview',`);
573
+ lines.push(` path: '/admin/premium/:variant',`);
574
+ lines.push(` builder: (context, state) {`);
575
+ lines.push(` final paywall = paywallFactoryFromAdminRoute(state.pathParameters['variant']);`);
576
+ lines.push(` if (paywall == null) return const PageNotFound();`);
577
+ lines.push(` return PremiumPage(paywall: paywall);`);
578
+ lines.push(` },`);
579
+ lines.push(` ),`);
580
+ }
581
+ lines.push(` ],`);
582
+ }
583
+
461
584
  // Recover password + 404 (always)
462
585
  lines.push(` GoRoute(`);
463
586
  lines.push(` name: 'recover_password',`);
@@ -750,19 +873,28 @@ async function writeMainDart(projectDir, backend, modules, packageName, options
750
873
  const withWidget = modules.includes('widget');
751
874
  const withRevenuecat = modules.includes('revenuecat');
752
875
  const withSupabase = backend === 'supabase';
753
- const withFirebase = backend === 'firebase' || backend === 'supabase'; // Firebase used for auth/notifications in all backends currently
876
+ // Firebase powers push notifications (FCM) and remote config in EVERY backend
877
+ // (Supabase additionally uses it for Google Sign-In). Every backend runs
878
+ // `flutterfire configure` during generation (see generate.js), so
879
+ // firebase_options_dev.dart and the google-services files always exist, which
880
+ // makes Firebase.initializeApp safe to call in all of them.
881
+ const withFirebase = true;
754
882
 
755
883
  const lines = [];
756
884
 
757
885
  // ── Imports ────────────────────────────────────────────────────────────────
886
+ lines.push(`import 'package:device_preview/device_preview.dart';`);
758
887
  if (withFirebase) {
759
888
  lines.push(`import 'package:firebase_core/firebase_core.dart';`);
889
+ lines.push(`import 'package:firebase_messaging/firebase_messaging.dart';`);
760
890
  }
761
891
  lines.push(`import 'package:flutter/foundation.dart';`);
762
892
  lines.push(`import 'package:flutter/material.dart';`);
763
893
  lines.push(`import 'package:flutter/services.dart';`);
764
894
  lines.push(`import 'package:flutter_localizations/flutter_localizations.dart';`);
895
+ lines.push(`import 'package:flutter_native_splash/flutter_native_splash.dart';`);
765
896
  lines.push(`import 'package:flutter_riverpod/flutter_riverpod.dart';`);
897
+ lines.push(`import 'package:jiffy/jiffy.dart';`);
766
898
  lines.push(`import 'package:logger/logger.dart';`);
767
899
  lines.push(`import 'package:${pkg}/core/config/app_env.dart';`);
768
900
  lines.push(`import 'package:${pkg}/core/data/api/analytics_api.dart';`);
@@ -772,9 +904,12 @@ async function writeMainDart(projectDir, backend, modules, packageName, options
772
904
  lines.push(`import 'package:${pkg}/core/home_widgets/home_widget_service.dart';`);
773
905
  }
774
906
  lines.push(`import 'package:${pkg}/core/initializer/onstart_widget.dart';`);
907
+ lines.push(`import 'package:${pkg}/core/keyboard_fix/keyboard_flicker_fix.dart';`);
775
908
  lines.push(`import 'package:${pkg}/core/shared_preferences/shared_preferences.dart';`);
776
909
  lines.push(`import 'package:${pkg}/core/states/user_state_notifier.dart';`);
910
+ lines.push(`import 'package:${pkg}/core/dev_inspector/dev_inspector.dart';`);
777
911
  lines.push(`import 'package:${pkg}/core/theme/theme.dart';`);
912
+ lines.push(`import 'package:${pkg}/core/web_device_preview/web_device_preview.dart';`);
778
913
  lines.push(`import 'package:${pkg}/environnements.dart';`);
779
914
  if (withFirebase) {
780
915
  lines.push(`import 'package:${pkg}/firebase_options_dev.dart' as firebase_dev;`);
@@ -782,6 +917,9 @@ async function writeMainDart(projectDir, backend, modules, packageName, options
782
917
  lines.push(`import 'package:${pkg}/i18n/translations.g.dart';`);
783
918
  lines.push(`import 'package:${pkg}/features/authentication/api/authentication_api.dart';`);
784
919
  lines.push(`import 'package:${pkg}/features/notifications/api/local_notifier.dart';`);
920
+ if (withFirebase) {
921
+ lines.push(`import 'package:${pkg}/features/notifications/repositories/background_notification_handler.dart';`);
922
+ }
785
923
  lines.push(`import 'package:${pkg}/features/notifications/repositories/notifications_repository.dart';`);
786
924
  if (withRevenuecat) {
787
925
  lines.push(`import 'package:${pkg}/features/subscription/repositories/subscription_repository.dart';`);
@@ -798,7 +936,8 @@ async function writeMainDart(projectDir, backend, modules, packageName, options
798
936
 
799
937
  // ── main() ────────────────────────────────────────────────────────────────
800
938
  lines.push(`void main() async {`);
801
- lines.push(` WidgetsFlutterBinding.ensureInitialized();`);
939
+ lines.push(` final widgetsBinding = KasyBinding.ensureInitialized();`);
940
+ lines.push(` FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);`);
802
941
  lines.push(` await AppEnv.load();`);
803
942
  lines.push(` final env = Environment.fromEnv();`);
804
943
  lines.push(` final logger = Logger();`);
@@ -853,6 +992,13 @@ async function writeMainDart(projectDir, backend, modules, packageName, options
853
992
  lines.push(` ),`);
854
993
  lines.push(` };`);
855
994
  lines.push('');
995
+ lines.push(` // Jiffy locale must be set AFTER Firebase init because Firebase resets`);
996
+ lines.push(` // Intl.defaultLocale internally, which would override our setting.`);
997
+ lines.push(` await Jiffy.setLocale(LocaleSettings.instance.currentLocale.languageCode);`);
998
+ lines.push('');
999
+ lines.push(` // MUST be registered at the top-level BEFORE runApp().`);
1000
+ lines.push(` FirebaseMessaging.onBackgroundMessage(onBackgroundMessage);`);
1001
+ lines.push('');
856
1002
  }
857
1003
 
858
1004
  if (withSentry) {
@@ -895,73 +1041,102 @@ async function writeMainDart(projectDir, backend, modules, packageName, options
895
1041
  lines.push(`// mode: ThemeMode.dark,`);
896
1042
  lines.push(`// ),`);
897
1043
  lines.push(`// See ./docs/theme.md for more details`);
898
- lines.push(`class MyApp extends ConsumerWidget {`);
1044
+ lines.push(`class MyApp extends ConsumerStatefulWidget {`);
899
1045
  lines.push(` final SharedPreferences sharedPreferences;`);
900
1046
  lines.push('');
901
1047
  lines.push(` const MyApp({super.key, required this.sharedPreferences});`);
902
1048
  lines.push('');
1049
+ lines.push(` @override`);
1050
+ lines.push(` ConsumerState<MyApp> createState() => _MyAppState();`);
1051
+ lines.push(`}`);
1052
+ lines.push('');
1053
+ lines.push(`class _MyAppState extends ConsumerState<MyApp> {`);
1054
+ lines.push(` late final AppTheme _appTheme;`);
1055
+ lines.push('');
1056
+ lines.push(` @override`);
1057
+ lines.push(` void initState() {`);
1058
+ lines.push(` super.initState();`);
1059
+ lines.push(` _appTheme = AppTheme.uniform(`);
1060
+ lines.push(` sharedPreferences: widget.sharedPreferences,`);
1061
+ lines.push(` themeFactory: const UniversalThemeFactory(),`);
1062
+ lines.push(` lightColors: KasyColors.light(),`);
1063
+ lines.push(` darkColors: KasyColors.dark(),`);
1064
+ lines.push(` textTheme: KasyTextTheme.build(),`);
1065
+ lines.push(` defaultMode: ThemeMode.system,`);
1066
+ lines.push(` );`);
1067
+ lines.push(` }`);
1068
+ lines.push('');
1069
+ lines.push(` @override`);
1070
+ lines.push(` void dispose() {`);
1071
+ lines.push(` _appTheme.dispose();`);
1072
+ lines.push(` super.dispose();`);
1073
+ lines.push(` }`);
1074
+ lines.push('');
903
1075
  lines.push(` // This widget is the root of your application.`);
904
1076
  lines.push(` @override`);
905
- lines.push(` Widget build(BuildContext context, WidgetRef ref) {`);
1077
+ lines.push(` Widget build(BuildContext context) {`);
906
1078
  lines.push(` ErrorWidget.builder = (FlutterErrorDetails details) {`);
907
1079
  lines.push(` return AppErrorWidget(error: details);`);
908
1080
  lines.push(` };`);
909
1081
  lines.push(` final goRouter = ref.watch(goRouterProvider);`);
910
1082
  lines.push('');
911
1083
  lines.push(` return ThemeProvider(`);
912
- lines.push(` notifier: AppTheme.uniform(`);
913
- lines.push(` sharedPreferences: sharedPreferences,`);
914
- lines.push(` themeFactory: const UniversalThemeFactory(),`);
915
- lines.push(` lightColors: KasyColors.light(),`);
916
- lines.push(` darkColors: KasyColors.dark(),`);
917
- lines.push(` textTheme: KasyTextTheme.build(),`);
918
- lines.push(` defaultMode: ThemeMode.light,`);
919
- lines.push(` ),`);
1084
+ lines.push(` notifier: _appTheme,`);
920
1085
  lines.push(` child: Builder(`);
921
1086
  lines.push(` builder: (context) {`);
922
- lines.push(` return MaterialApp.router(`);
923
- lines.push(` title: 'Kasy',`);
924
- lines.push(` theme: ThemeProvider.of(context).light,`);
925
- lines.push(` darkTheme: ThemeProvider.of(context).dark,`);
926
- lines.push(` themeMode: ThemeProvider.of(context).mode,`);
927
- lines.push(` routerConfig: goRouter,`);
928
- lines.push(` localizationsDelegates: const [`);
929
- lines.push(` GlobalMaterialLocalizations.delegate,`);
930
- lines.push(` GlobalWidgetsLocalizations.delegate,`);
931
- lines.push(` GlobalCupertinoLocalizations.delegate,`);
932
- lines.push(` ],`);
933
- lines.push(` locale: TranslationProvider.of(context).flutterLocale,`);
934
- lines.push(` supportedLocales: AppLocaleUtils.supportedLocales,`);
935
- lines.push(` // Initializer is a widget that allows us to run some code before the app is ready`);
936
- lines.push(` builder: (context, child) => Initializer(`);
937
- lines.push(` services: [`);
938
- lines.push(` authenticationApiProvider,`);
939
- lines.push(` // shared preferences must be loaded`);
940
- lines.push(` sharedPreferencesProvider,`);
941
- lines.push(` // remote config api`);
942
- lines.push(` remoteConfigApiProvider,`);
943
- lines.push(` // notifications`);
944
- lines.push(` notificationsSettingsProvider,`);
945
- lines.push(` notificationRepositoryProvider,`);
946
- lines.push(` // user state`);
1087
+ // WebDevicePreview + DevInspector wrap the app so the in-app dev tools work:
1088
+ // • Cmd/Ctrl + Shift + P → widget inspector
1089
+ // • Cmd/Ctrl + Shift + D → device preview (web)
1090
+ // Both are no-ops in release builds.
1091
+ lines.push(` return WebDevicePreview.wrap(`);
1092
+ lines.push(` child: DevInspector.wrap(`);
1093
+ lines.push(` child: MaterialApp.router(`);
1094
+ lines.push(` title: 'Kasy',`);
1095
+ lines.push(` scaffoldMessengerKey: devInspectorRootScaffoldMessengerKey,`);
1096
+ lines.push(` theme: ThemeProvider.of(context).light,`);
1097
+ lines.push(` darkTheme: ThemeProvider.of(context).dark,`);
1098
+ lines.push(` themeMode: ThemeProvider.of(context).mode,`);
1099
+ lines.push(` themeAnimationDuration: Duration.zero,`);
1100
+ lines.push(` routerConfig: goRouter,`);
1101
+ lines.push(` localizationsDelegates: const [`);
1102
+ lines.push(` GlobalMaterialLocalizations.delegate,`);
1103
+ lines.push(` GlobalWidgetsLocalizations.delegate,`);
1104
+ lines.push(` GlobalCupertinoLocalizations.delegate,`);
1105
+ lines.push(` ],`);
1106
+ lines.push(` locale: TranslationProvider.of(context).flutterLocale,`);
1107
+ lines.push(` supportedLocales: AppLocaleUtils.supportedLocales,`);
1108
+ lines.push(` // Initializer is a widget that allows us to run some code before the app is ready`);
1109
+ lines.push(` builder: (context, child) => Initializer(`);
1110
+ lines.push(` services: [`);
1111
+ lines.push(` authenticationApiProvider,`);
1112
+ lines.push(` // shared preferences must be loaded`);
1113
+ lines.push(` sharedPreferencesProvider,`);
1114
+ lines.push(` // remote config api`);
1115
+ lines.push(` remoteConfigApiProvider,`);
1116
+ lines.push(` // notifications`);
1117
+ lines.push(` notificationsSettingsProvider,`);
1118
+ lines.push(` notificationRepositoryProvider,`);
1119
+ lines.push(` // user state`);
947
1120
  if (withRevenuecat) {
948
- lines.push(` subscriptionRepositoryProvider,`);
1121
+ lines.push(` subscriptionRepositoryProvider,`);
949
1122
  }
950
- lines.push(` userStateNotifierProvider.notifier,`);
1123
+ lines.push(` userStateNotifierProvider.notifier,`);
951
1124
  if (withWidget) {
952
- lines.push(` homeWidgetsManagerProvider,`);
1125
+ lines.push(` homeWidgetsManagerProvider,`);
953
1126
  }
954
- lines.push(` // analytics`);
955
- lines.push(` analyticsApiProvider,`);
956
- lines.push(` facebookEventApiProvider,`);
957
- lines.push(` ],`);
958
- lines.push(` onReady: child!,`);
959
- lines.push(` onError: (_, error) => InitializationErrorPage(error: error),`);
960
- lines.push(` onLoading: Scaffold(`);
961
- lines.push(` body: Center(`);
962
- lines.push(` child: CircularProgressIndicator.adaptive(`);
963
- lines.push(` valueColor: AlwaysStoppedAnimation<Color>(`);
964
- lines.push(` context.colors.primary,`);
1127
+ lines.push(` // analytics`);
1128
+ lines.push(` analyticsApiProvider,`);
1129
+ lines.push(` facebookEventApiProvider,`);
1130
+ lines.push(` ],`);
1131
+ lines.push(` onReady: DevicePreview.appBuilder(context, child),`);
1132
+ lines.push(` onError: (_, error) => InitializationErrorPage(error: error),`);
1133
+ lines.push(` onLoading: Scaffold(`);
1134
+ lines.push(` body: Center(`);
1135
+ lines.push(` child: CircularProgressIndicator.adaptive(`);
1136
+ lines.push(` valueColor: AlwaysStoppedAnimation<Color>(`);
1137
+ lines.push(` context.colors.primary,`);
1138
+ lines.push(` ),`);
1139
+ lines.push(` ),`);
965
1140
  lines.push(` ),`);
966
1141
  lines.push(` ),`);
967
1142
  lines.push(` ),`);
@@ -1185,6 +1360,30 @@ class AdminHomeWidgets extends StatelessWidget {
1185
1360
  );
1186
1361
  }
1187
1362
 
1363
+ /**
1364
+ * Strips the home-widget hook from language_switcher.dart when the widget module
1365
+ * is not selected. The switcher pushes the new locale to the home widget via
1366
+ * myWidgetHomeWidgetProvider, which only exists with the widget module — without
1367
+ * this the generated project fails to compile (undefined provider + dead import).
1368
+ *
1369
+ * @param {string} projectDir
1370
+ */
1371
+ async function patchLanguageSwitcherNoWidget(projectDir) {
1372
+ const filePath = path.join(
1373
+ projectDir, 'lib', 'features', 'settings', 'ui', 'components', 'language_switcher.dart',
1374
+ );
1375
+ if (!(await fs.pathExists(filePath))) return;
1376
+ let content = await fs.readFile(filePath, 'utf8');
1377
+ // Drop the home-widget service import.
1378
+ content = content.replace(/^import 'package:[^']*home_widget_mywidget_service\.dart';\n/m, '');
1379
+ // Drop the unawaited(...) block that updates the home widget on locale change.
1380
+ content = content.replace(
1381
+ /\n\s*unawaited\(\s*ref[\s\S]*?myWidgetHomeWidgetProvider[\s\S]*?\);/,
1382
+ '',
1383
+ );
1384
+ await fs.outputFile(filePath, content, 'utf8');
1385
+ }
1386
+
1188
1387
  /**
1189
1388
  * Writes a NoOp feature_request_repository.dart when the feedback module is not selected.
1190
1389
  * premium_page_provider.dart imports this provider — without it the project won't compile.
@@ -1286,7 +1485,12 @@ async function writeNoOpSentryUsages(projectDir) {
1286
1485
  if (!(await fs.pathExists(filePath))) continue;
1287
1486
  let content = await fs.readFile(filePath, 'utf8');
1288
1487
  content = content.replace(sentryImport, '');
1289
- content = content.replace(/^[ \t]*Sentry\.captureException\([^)]*\);\n/gm, '');
1488
+ // Replace the Sentry call with a comment (not nothing) so the catch block
1489
+ // isn't left empty — an empty catch trips the empty_catches lint.
1490
+ content = content.replace(
1491
+ /^([ \t]*)Sentry\.captureException\([^)]*\);\n/gm,
1492
+ '$1// Error not reported — Sentry is disabled. Run: kasy add sentry.\n',
1493
+ );
1290
1494
  await fs.outputFile(filePath, content, 'utf8');
1291
1495
  }
1292
1496
  }
@@ -1466,9 +1670,13 @@ sealed class Subscription with _$Subscription {
1466
1670
  }
1467
1671
 
1468
1672
  // 1. No-op subscription_repository.dart (used by user_repository, onboarding, home_widget_background_task)
1673
+ // The constructor keeps subscriptionApi/inAppSubscriptionApi/prefs params (unused
1674
+ // here) for source-compat with the bundled tests. ignore_for_file stops
1675
+ // `dart fix` from stripping them via avoid_unused_constructor_parameters.
1469
1676
  await fs.outputFile(
1470
1677
  path.join(projectDir, 'lib', 'features', 'subscription', 'repositories', 'subscription_repository.dart'),
1471
- `import 'package:flutter_riverpod/flutter_riverpod.dart';
1678
+ `// ignore_for_file: avoid_unused_constructor_parameters
1679
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
1472
1680
  import 'package:${pkg}/core/data/models/subscription.dart';
1473
1681
  import 'package:${pkg}/core/initializer/onstart_service.dart';
1474
1682
  // No-op subscription repository. Run: kasy add revenuecat to activate.
@@ -1561,7 +1769,8 @@ enum PaywallFactory { basic, basicRow, minimal, withSwitch }
1561
1769
  // 5. No-op subscription_entity.dart (used by test_utils, fake_subscription_api)
1562
1770
  await fs.outputFile(
1563
1771
  path.join(projectDir, 'lib', 'features', 'subscription', 'api', 'entities', 'subscription_entity.dart'),
1564
- `// No-op subscription entity. Run: kasy add revenuecat to activate.
1772
+ `// ignore_for_file: constant_identifier_names
1773
+ // No-op subscription entity. Run: kasy add revenuecat to activate.
1565
1774
  enum SubscriptionStatus { ACTIVE, PAUSED, EXPIRED, LIFETIME, CANCELLED }
1566
1775
 
1567
1776
  class SubscriptionEntity {
@@ -1736,6 +1945,7 @@ module.exports = {
1736
1945
  removeFacebookSigninFromAuthPages,
1737
1946
  removeAndroidFacebookMetadata,
1738
1947
  writeNoOpAdminHomeWidgets,
1948
+ patchLanguageSwitcherNoWidget,
1739
1949
  writeNoOpFeatureRequestRepository,
1740
1950
  writeNoOpSubscriptionStubs,
1741
1951
  writeNoOpSentryUsages,