kasy-cli 1.19.3 → 1.20.1

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 (83) hide show
  1. package/README.md +11 -3
  2. package/bin/kasy.js +1 -0
  3. package/lib/commands/new.js +87 -37
  4. package/lib/commands/run.js +14 -0
  5. package/lib/scaffold/backends/api/patch/lib/core/data/api/meta_ads_api.dart +1 -1
  6. package/lib/scaffold/backends/api/patch/lib/core/data/api/storage_api.dart +1 -1
  7. package/lib/scaffold/backends/api/patch/lib/core/data/entities/user_entity.dart +1 -1
  8. package/lib/scaffold/backends/api/patch/lib/features/authentication/api/authentication_api.dart +4 -5
  9. package/lib/scaffold/backends/api/patch/lib/features/feedbacks/api/feature_request_api.dart +1 -1
  10. package/lib/scaffold/backends/api/patch/lib/features/feedbacks/api/feature_vote_api.dart +1 -1
  11. package/lib/scaffold/backends/api/patch/lib/features/llm_chat/api/llm_chat_api.dart +1 -1
  12. package/lib/scaffold/backends/api/patch/lib/features/llm_chat/providers/llm_chat_notifier.dart +317 -0
  13. package/lib/scaffold/backends/api/patch/lib/features/notifications/api/device_api.dart +40 -1
  14. package/lib/scaffold/backends/api/patch/lib/features/notifications/api/entities/notifications_entity.dart +2 -0
  15. package/lib/scaffold/backends/api/patch/lib/features/onboarding/api/entities/user_info_entity.dart +1 -1
  16. package/lib/scaffold/backends/api/patch/lib/features/subscription/api/entities/subscription_entity.dart +1 -1
  17. package/lib/scaffold/backends/api/patch/lib/features/subscription/shared/maybeshow_premium.dart +0 -2
  18. package/lib/scaffold/backends/api/pubspec.yaml.tpl +2 -0
  19. package/lib/scaffold/backends/firebase/enable-auth-via-cli.js +11 -8
  20. package/lib/scaffold/backends/firebase/setup-from-scratch.js +91 -2
  21. package/lib/scaffold/backends/supabase/deploy.js +56 -3
  22. package/lib/scaffold/backends/supabase/patch/lib/core/data/api/storage_api.dart +5 -11
  23. package/lib/scaffold/backends/supabase/patch/lib/core/data/entities/user_entity.dart +2 -2
  24. package/lib/scaffold/backends/supabase/patch/lib/features/notifications/api/device_api.dart +31 -1
  25. package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/api/entities/user_info_entity.dart +1 -1
  26. package/lib/scaffold/backends/supabase/patch/lib/features/subscription/api/entities/subscription_entity.dart +1 -1
  27. package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +2 -0
  28. package/lib/scaffold/catalog.js +2 -2
  29. package/lib/scaffold/engine.js +5 -0
  30. package/lib/scaffold/generate.js +23 -3
  31. package/lib/scaffold/shared/generator-utils.js +303 -56
  32. package/lib/scaffold/shared/post-build.js +11 -0
  33. package/lib/utils/i18n/messages-en.js +6 -1
  34. package/lib/utils/i18n/messages-es.js +6 -1
  35. package/lib/utils/i18n/messages-pt.js +6 -1
  36. package/package.json +1 -1
  37. package/templates/firebase/android/app/src/main/res/drawable/background.png +0 -0
  38. package/templates/firebase/android/app/src/main/res/drawable-night/background.png +0 -0
  39. package/templates/firebase/android/app/src/main/res/drawable-night-v21/background.png +0 -0
  40. package/templates/firebase/android/app/src/main/res/drawable-v21/background.png +0 -0
  41. package/templates/firebase/android/app/src/main/res/values-night-v31/styles.xml +1 -1
  42. package/templates/firebase/android/app/src/main/res/values-v31/styles.xml +1 -1
  43. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png +0 -0
  44. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png +0 -0
  45. package/templates/firebase/lib/components/kasy_date_picker.dart +14 -8
  46. package/templates/firebase/lib/components/kasy_sidebar_pro.dart +1150 -0
  47. package/templates/firebase/lib/components/kasy_tabs.dart +156 -43
  48. package/templates/firebase/lib/components/kasy_text_field.dart +37 -34
  49. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +13 -82
  50. package/templates/firebase/lib/core/bottom_menu/bottom_router.dart +6 -102
  51. package/templates/firebase/lib/core/bottom_menu/kasy_bottom_bar_factory.dart +8 -1
  52. package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +433 -243
  53. package/templates/firebase/lib/core/dev_inspector/dev_inspector_service.dart +198 -83
  54. package/templates/firebase/lib/core/icons/kasy_icons.dart +1 -0
  55. package/templates/firebase/lib/core/states/user_state_notifier.dart +8 -10
  56. package/templates/firebase/lib/core/theme/colors.dart +6 -2
  57. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +119 -19
  58. package/templates/firebase/lib/core/widgets/kasy_hover.dart +68 -27
  59. package/templates/firebase/lib/features/home/home_components_page.dart +11 -14
  60. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +121 -66
  61. package/templates/firebase/lib/features/home/home_page.dart +7 -8
  62. package/templates/firebase/lib/features/settings/settings_page.dart +27 -146
  63. package/templates/firebase/lib/features/settings/ui/components/admin/admin_bottom_sheet.dart +16 -3
  64. package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +22 -5
  65. package/templates/firebase/lib/i18n/en.i18n.json +3 -1
  66. package/templates/firebase/lib/i18n/es.i18n.json +3 -1
  67. package/templates/firebase/lib/i18n/pt.i18n.json +3 -1
  68. package/templates/firebase/lib/router.dart +60 -0
  69. package/templates/firebase/pubspec.yaml +6 -4
  70. package/templates/firebase/test/core/bottom_menu/detail_route_menu_test.dart +57 -0
  71. package/templates/firebase/web/index.html +7 -17
  72. package/lib/scaffold/backends/api/patch/lib/core/rating/widgets/review_popup.dart +0 -211
  73. package/lib/scaffold/backends/api/patch/lib/features/notifications/providers/models/notification.dart +0 -185
  74. package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/components/onboarding_notifications_setup.dart +0 -73
  75. package/lib/scaffold/backends/api/patch/lib/main.dart +0 -275
  76. package/lib/scaffold/backends/api/patch/lib/router.dart +0 -133
  77. package/lib/scaffold/backends/supabase/patch/lib/core/rating/widgets/review_popup.dart +0 -211
  78. package/lib/scaffold/backends/supabase/patch/lib/features/feedbacks/ui/component/add_feature_form.dart +0 -199
  79. package/lib/scaffold/backends/supabase/patch/lib/features/notifications/providers/models/notification.dart +0 -174
  80. package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/components/onboarding_notifications_setup.dart +0 -73
  81. package/lib/scaffold/backends/supabase/patch/lib/main.dart +0 -307
  82. package/lib/scaffold/backends/supabase/patch/lib/router.dart +0 -133
  83. package/templates/firebase/lib/firebase_options.dart +0 -75
@@ -0,0 +1,317 @@
1
+ import 'dart:convert';
2
+
3
+ import 'package:dio/dio.dart';
4
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
5
+ import 'package:kasy_kit/core/config/app_env.dart';
6
+ import 'package:kasy_kit/core/data/api/http_client.dart';
7
+ import 'package:kasy_kit/core/states/translations.dart';
8
+ import 'package:kasy_kit/core/states/user_state_notifier.dart';
9
+ import 'package:kasy_kit/features/llm_chat/api/llm_chat_api.dart';
10
+ import 'package:kasy_kit/features/llm_chat/api/llm_chat_message_entity.dart';
11
+ import 'package:logger/logger.dart';
12
+
13
+ /// Maximum number of recent messages sent to the AI as context.
14
+ ///
15
+ /// Lower values = fewer tokens = cheaper + faster responses.
16
+ /// Higher values = more conversational memory but higher cost.
17
+ ///
18
+ /// Each exchange = 2 messages (1 user + 1 assistant).
19
+ /// Default: 20 messages = 10 exchanges of back-and-forth.
20
+ ///
21
+ /// To change: update this constant and redeploy (no backend change needed).
22
+ const int _kMaxContextMessages = 20;
23
+
24
+ /// A single message in the chat conversation.
25
+ class ChatMessage {
26
+ final String role; // 'user' or 'assistant'
27
+ final String content;
28
+
29
+ const ChatMessage({required this.role, required this.content});
30
+
31
+ factory ChatMessage.user(String content) =>
32
+ ChatMessage(role: 'user', content: content);
33
+
34
+ factory ChatMessage.assistant(String content) =>
35
+ ChatMessage(role: 'assistant', content: content);
36
+
37
+ factory ChatMessage.fromEntity(LlmChatMessageEntity entity) =>
38
+ ChatMessage(role: entity.role, content: entity.content);
39
+
40
+ LlmChatMessageEntity toEntity() => LlmChatMessageEntity(
41
+ role: role,
42
+ content: content,
43
+ createdAt: DateTime.now(),
44
+ );
45
+ }
46
+
47
+ /// UI state for the LLM chat screen.
48
+ class LlmChatState {
49
+ final List<ChatMessage> messages;
50
+
51
+ /// True while the HTTP request to the LLM backend is in-flight.
52
+ final bool isReplying;
53
+
54
+ /// True once the first SSE chunk has been received.
55
+ /// While true the last message in [messages] is the partial assistant reply.
56
+ final bool streamingStarted;
57
+
58
+ const LlmChatState({
59
+ required this.messages,
60
+ this.isReplying = false,
61
+ this.streamingStarted = false,
62
+ });
63
+
64
+ LlmChatState copyWith({
65
+ List<ChatMessage>? messages,
66
+ bool? isReplying,
67
+ bool? streamingStarted,
68
+ }) {
69
+ return LlmChatState(
70
+ messages: messages ?? this.messages,
71
+ isReplying: isReplying ?? this.isReplying,
72
+ streamingStarted: streamingStarted ?? this.streamingStarted,
73
+ );
74
+ }
75
+ }
76
+
77
+ /// Manages LLM chat state: loads history from the backend on init,
78
+ /// persists each message, and streams the assistant reply word-by-word via SSE.
79
+ final llmChatNotifierProvider =
80
+ AsyncNotifierProvider<LlmChatNotifier, LlmChatState>(LlmChatNotifier.new);
81
+
82
+ class LlmChatNotifier extends AsyncNotifier<LlmChatState> {
83
+ final Logger _logger = Logger();
84
+
85
+ @override
86
+ Future<LlmChatState> build() async {
87
+ final userId = ref.read(userStateNotifierProvider).user.idOrNull;
88
+ if (userId == null) return const LlmChatState(messages: []);
89
+
90
+ try {
91
+ final entities = await ref.read(llmChatApiProvider).loadMessages(userId);
92
+ return LlmChatState(
93
+ messages: entities.map(ChatMessage.fromEntity).toList(),
94
+ );
95
+ } catch (e) {
96
+ _logger.e('Failed to load LLM chat history: $e');
97
+ // Graceful fallback: start fresh if the backend is unavailable.
98
+ return const LlmChatState(messages: []);
99
+ }
100
+ }
101
+
102
+ /// Adds the user message to the conversation, persists it, then streams
103
+ /// the assistant reply chunk-by-chunk via SSE, updating state on each token.
104
+ Future<void> sendMessage(String prompt) async {
105
+ final current = switch (state) {
106
+ AsyncData(:final value) => value,
107
+ _ => null,
108
+ };
109
+ if (current == null || current.isReplying) return;
110
+
111
+ final userMsg = ChatMessage.user(prompt);
112
+ state = AsyncData(
113
+ current.copyWith(
114
+ messages: [...current.messages, userMsg],
115
+ isReplying: true,
116
+ streamingStarted: false,
117
+ ),
118
+ );
119
+
120
+ _persistMessage(userMsg);
121
+
122
+ // Pass only the last _kMaxContextMessages messages to the AI.
123
+ // The full history is still stored in the DB for the user to read.
124
+ final allMessages = (state as AsyncData<LlmChatState>).value.messages;
125
+ final history = allMessages.length > _kMaxContextMessages
126
+ ? allMessages.sublist(allMessages.length - _kMaxContextMessages)
127
+ : allMessages;
128
+ await _requestReplyStream(history);
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // SSE streaming
133
+ // ---------------------------------------------------------------------------
134
+
135
+ Future<void> _requestReplyStream(List<ChatMessage> history) async {
136
+ final t = ref.read(translationsProvider).llm_chat;
137
+ final String llmChatEndpoint = AppEnv.llmChatEndpoint;
138
+
139
+ if (llmChatEndpoint.isEmpty) {
140
+ _finalizeAssistantMessage(t.error_not_configured);
141
+ return;
142
+ }
143
+
144
+ // API backend: reuse the Bearer token already managed by HttpClient
145
+ // (no Firebase Auth — the user authenticates against your REST backend).
146
+ final authToken = ref.read(httpClientProvider).authToken;
147
+ if (authToken == null) {
148
+ _finalizeAssistantMessage(t.error_not_configured);
149
+ return;
150
+ }
151
+
152
+ final dio = Dio(
153
+ BaseOptions(
154
+ headers: {
155
+ 'Authorization': 'Bearer $authToken',
156
+ 'Content-Type': 'application/json',
157
+ },
158
+ ),
159
+ );
160
+
161
+ try {
162
+ final response = await dio.post<ResponseBody>(
163
+ llmChatEndpoint,
164
+ data: {
165
+ 'message': history.last.content,
166
+ 'history': history
167
+ .map((e) => {'role': e.role, 'content': e.content})
168
+ .toList(growable: false),
169
+ },
170
+ options: Options(responseType: ResponseType.stream),
171
+ );
172
+
173
+ final lineBuffer = StringBuffer();
174
+ final contentBuffer = StringBuffer();
175
+
176
+ await for (final chunk in response.data!.stream) {
177
+ final text = utf8.decode(chunk, allowMalformed: true);
178
+ lineBuffer.write(text);
179
+
180
+ // Process only complete lines — keep the last incomplete fragment
181
+ // in the buffer for the next iteration.
182
+ final raw = lineBuffer.toString();
183
+ final lastNewline = raw.lastIndexOf('\n');
184
+ if (lastNewline == -1) continue;
185
+
186
+ final completeSection = raw.substring(0, lastNewline + 1);
187
+ lineBuffer.clear();
188
+ if (lastNewline < raw.length - 1) {
189
+ lineBuffer.write(raw.substring(lastNewline + 1));
190
+ }
191
+
192
+ for (final line in completeSection.split('\n')) {
193
+ final delta = _extractSSEDelta(line.trimRight());
194
+ if (delta.isEmpty) continue;
195
+
196
+ contentBuffer.write(delta);
197
+ _appendStreamingChunk(contentBuffer.toString());
198
+ }
199
+ }
200
+
201
+ // Persist the complete assistant reply once the stream ends.
202
+ final fullContent = contentBuffer.toString();
203
+ if (fullContent.isNotEmpty) {
204
+ _persistMessage(ChatMessage.assistant(fullContent));
205
+ } else {
206
+ // Stream ended with no content (e.g. error event)
207
+ _finalizeAssistantMessage(t.error_no_reply);
208
+ return;
209
+ }
210
+
211
+ // Mark the reply as finished without touching the message list.
212
+ final latest = switch (state) {
213
+ AsyncData(:final value) => value,
214
+ _ => null,
215
+ };
216
+ if (latest != null) {
217
+ state = AsyncData(
218
+ latest.copyWith(isReplying: false, streamingStarted: false),
219
+ );
220
+ }
221
+ } catch (error) {
222
+ _logger.e('LLM chat stream failed: $error');
223
+ _finalizeAssistantMessage(t.error_network);
224
+ }
225
+ }
226
+
227
+ /// Called on every incoming SSE token to update the last assistant bubble.
228
+ void _appendStreamingChunk(String partialContent) {
229
+ final current = switch (state) {
230
+ AsyncData(:final value) => value,
231
+ _ => null,
232
+ };
233
+ if (current == null) return;
234
+
235
+ final msgs = current.messages;
236
+ final newMsgs = current.streamingStarted
237
+ // Replace the in-progress bubble with the latest accumulated content.
238
+ ? [
239
+ ...msgs.sublist(0, msgs.length - 1),
240
+ ChatMessage.assistant(partialContent),
241
+ ]
242
+ // First chunk: append a new assistant bubble.
243
+ : [...msgs, ChatMessage.assistant(partialContent)];
244
+
245
+ state = AsyncData(
246
+ LlmChatState(messages: newMsgs, isReplying: true, streamingStarted: true),
247
+ );
248
+ }
249
+
250
+ /// Replaces (or appends) the assistant bubble with [content] and marks
251
+ /// the reply as finished. Used for error messages and empty-stream fallback.
252
+ void _finalizeAssistantMessage(String content) {
253
+ final current = switch (state) {
254
+ AsyncData(:final value) => value,
255
+ _ => const LlmChatState(messages: []),
256
+ };
257
+ final msgs = current.messages;
258
+ final newMsgs = current.streamingStarted
259
+ ? [...msgs.sublist(0, msgs.length - 1), ChatMessage.assistant(content)]
260
+ : [...msgs, ChatMessage.assistant(content)];
261
+ state = AsyncData(LlmChatState(messages: newMsgs));
262
+ }
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // SSE parsing
266
+ // ---------------------------------------------------------------------------
267
+
268
+ /// Extracts the text delta from a single SSE `data:` line.
269
+ /// Supports both OpenAI (`choices[0].delta.content`) and
270
+ /// Gemini (`candidates[0].content.parts[0].text`) formats.
271
+ String _extractSSEDelta(String line) {
272
+ if (!line.startsWith('data: ')) return '';
273
+ final payload = line.substring(6);
274
+ if (payload == '[DONE]') return '';
275
+ try {
276
+ final json = jsonDecode(payload) as Map<String, dynamic>;
277
+
278
+ // OpenAI format
279
+ final choices = json['choices'] as List?;
280
+ if (choices != null && choices.isNotEmpty) {
281
+ final delta =
282
+ (choices.first as Map<String, dynamic>)['delta']
283
+ as Map<String, dynamic>?;
284
+ return (delta?['content'] as String?) ?? '';
285
+ }
286
+
287
+ // Gemini format
288
+ final candidates = json['candidates'] as List?;
289
+ if (candidates != null && candidates.isNotEmpty) {
290
+ final content =
291
+ (candidates.first as Map<String, dynamic>)['content']
292
+ as Map<String, dynamic>?;
293
+ final parts = content?['parts'] as List?;
294
+ if (parts != null && parts.isNotEmpty) {
295
+ return ((parts.first as Map<String, dynamic>)['text'] as String?) ??
296
+ '';
297
+ }
298
+ }
299
+ } catch (_) {}
300
+ return '';
301
+ }
302
+
303
+ // ---------------------------------------------------------------------------
304
+ // Persistence
305
+ // ---------------------------------------------------------------------------
306
+
307
+ void _persistMessage(ChatMessage message) {
308
+ final userId = ref.read(userStateNotifierProvider).user.idOrNull;
309
+ if (userId == null) return;
310
+ ref
311
+ .read(llmChatApiProvider)
312
+ .saveMessage(userId, message.toEntity())
313
+ .catchError((e) {
314
+ _logger.e('Failed to persist message: $e');
315
+ });
316
+ }
317
+ }
@@ -109,13 +109,28 @@ class FirebaseDeviceApi implements DeviceApi {
109
109
  Future<DeviceEntity> get() async {
110
110
  try {
111
111
  final installationId = await _installations.getId();
112
+ // On iOS, the APNS token may not be immediately available on first launch.
113
+ // We wait up to ~10 seconds before giving up.
114
+ if (defaultTargetPlatform == TargetPlatform.iOS && !kIsWeb) {
115
+ for (int i = 0; i < 10; i++) {
116
+ final apns = await _messaging.getAPNSToken();
117
+ if (apns != null) break;
118
+ await Future.delayed(const Duration(seconds: 1));
119
+ }
120
+ }
112
121
  final token = await _messaging.getToken();
122
+ if (token == null) {
123
+ throw ApiError(
124
+ code: 0,
125
+ message: 'FCM token is null — check Firebase setup and notification permissions',
126
+ );
127
+ }
113
128
  final os = Platform.isAndroid
114
129
  ? OperatingSystem.android //
115
130
  : OperatingSystem.ios;
116
131
  return DeviceEntity(
117
132
  installationId: installationId,
118
- token: token!,
133
+ token: token,
119
134
  operatingSystem: os,
120
135
  creationDate: DateTime.now(),
121
136
  lastUpdateDate: DateTime.now(),
@@ -287,6 +302,7 @@ class FirebaseDeviceApi implements DeviceApi {
287
302
  }
288
303
 
289
304
  Future<String?> getIpAddress() async {
305
+ if (kIsWeb) return null;
290
306
  try {
291
307
  // First, try to find a public IP in network interfaces
292
308
  final interfaces = await io.NetworkInterface.list();
@@ -331,6 +347,29 @@ class FirebaseDeviceApi implements DeviceApi {
331
347
  /// Returns a map with all device information
332
348
  @override
333
349
  Future<Map<String, String>> fetchDeviceProperties() async {
350
+ // On web there is no native device layer (no NetworkInterface, no Platform):
351
+ // return static values so the app works as a PWA without throwing at runtime.
352
+ if (kIsWeb) {
353
+ final webLocale = PlatformDispatcher.instance.locale.toLanguageTag().replaceAll('-', '_');
354
+ return {
355
+ 'appLongVersion': '',
356
+ 'osVersion': 'web',
357
+ 'deviceModel': 'browser',
358
+ 'deviceLocale': webLocale,
359
+ 'timezone': '',
360
+ 'carrier': '',
361
+ 'screenWidth': '',
362
+ 'screenHeight': '',
363
+ 'screenDensity': '',
364
+ 'cpuCores': '',
365
+ 'storageSize': '',
366
+ 'freeStorage': '',
367
+ 'deviceTimezone': '',
368
+ 'mobileAdvertiserId': '',
369
+ 'anonymousFbId': '',
370
+ 'clientIpAddress': '',
371
+ };
372
+ }
334
373
  try {
335
374
  final deviceInfo = DeviceInfoPlugin();
336
375
  final packageInfo = await PackageInfo.fromPlatform();
@@ -1,3 +1,5 @@
1
+ // ignore_for_file: invalid_annotation_target, constant_identifier_names
2
+
1
3
  import 'package:freezed_annotation/freezed_annotation.dart';
2
4
 
3
5
  part 'notifications_entity.freezed.dart';
@@ -17,7 +17,7 @@ sealed class UserInfoEntity with _$UserInfoEntity {
17
17
  @JsonKey(name: 'user_id') required String userId,
18
18
  @JsonKey(name: 'key') required String key,
19
19
  @JsonKey(name: 'value') required String value,
20
- }) = SubscriptionEntityData;
20
+ }) = UserInfoEntityData;
21
21
 
22
22
  factory UserInfoEntity.fromJson(Map<String, Object?> json) =>
23
23
  _$UserInfoEntityFromJson(json);
@@ -20,7 +20,7 @@ enum SubscriptionStatus {
20
20
  sealed class SubscriptionEntity with _$SubscriptionEntity {
21
21
  const factory SubscriptionEntity({
22
22
  @JsonKey(includeIfNull: false) String? id,
23
- @JsonKey(name: 'offer_id') required String offerId,
23
+ @JsonKey(name: 'offer_id') String? offerId,
24
24
  @JsonKey(name: 'sku_id') required String skuId,
25
25
  @JsonKey(name: 'creation_date') DateTime? creationDate,
26
26
  @JsonKey(name: 'last_update_date') DateTime? lastUpdateDate,
@@ -13,8 +13,6 @@ import 'package:kasy_kit/router.dart';
13
13
 
14
14
  const _lastAskKey = 'subscription_last_asking_date';
15
15
 
16
- const _lastShowPromoKey = 'subscription_last_show_promo_date';
17
-
18
16
  const _kDaysToAsk = 2;
19
17
 
20
18
  class MaybeShowPremiumPage implements MaybeShowWithRef {
@@ -51,6 +51,7 @@ dependencies:
51
51
  intl: ^0.20.2
52
52
  jiffy: ^6.4.4
53
53
  json_annotation: ^4.9.0
54
+ local_auth: ^3.0.1
54
55
  logger: ^2.6.2
55
56
  lucide_icons_flutter: ^3.1.10
56
57
  mixpanel_flutter: ^2.5.0
@@ -71,6 +72,7 @@ dependencies:
71
72
  universal_html: ^2.3.0
72
73
  universal_io: ^2.3.1
73
74
  url_launcher: ^6.3.2
75
+ web: ^1.1.1
74
76
 
75
77
  dev_dependencies:
76
78
  build_runner: ^2.11.1
@@ -34,14 +34,17 @@ async function getGcloudAccountEmail() {
34
34
  */
35
35
  async function mergeAuthIntoFirebaseJson(projectDir, providers) {
36
36
  const firebaseJsonPath = path.join(projectDir, 'firebase.json');
37
- if (!(await fs.pathExists(firebaseJsonPath))) {
38
- return { ok: false, error: 'firebase.json not found in project root' };
39
- }
40
- let config;
41
- try {
42
- config = await fs.readJson(firebaseJsonPath);
43
- } catch (err) {
44
- return { ok: false, error: `Failed to parse firebase.json: ${err.message}` };
37
+ // Supabase projects have no firebase.json (Firebase is only used there for FCM
38
+ // and to create the Google OAuth client). Start from an empty config so the
39
+ // `firebase deploy --only auth` below has a file to read — the deploy only
40
+ // touches the auth block, leaving everything else untouched.
41
+ let config = {};
42
+ if (await fs.pathExists(firebaseJsonPath)) {
43
+ try {
44
+ config = await fs.readJson(firebaseJsonPath);
45
+ } catch (err) {
46
+ return { ok: false, error: `Failed to parse firebase.json: ${err.message}` };
47
+ }
45
48
  }
46
49
  config.auth = {
47
50
  ...(config.auth || {}),
@@ -659,6 +659,54 @@ async function checkBillingEnabled(projectId) {
659
659
  return { ok: true, enabled: result.stdout.trim().toLowerCase() === 'true' };
660
660
  }
661
661
 
662
+ /**
663
+ * Ensure `localhost` and `127.0.0.1` are in the project's authorized domains so
664
+ * Google/social sign-in works on Flutter web during local development.
665
+ *
666
+ * Projects created programmatically (via the Firebase Management API) do NOT get
667
+ * `localhost` seeded automatically — only `<project>.firebaseapp.com` and
668
+ * `<project>.web.app`. Without `localhost`, `signInWithPopup` fails with
669
+ * [firebase_auth/unauthorized-domain] when running `flutter run -d chrome` or
670
+ * `-d web-server`. The Firebase Console adds localhost by default; the API does
671
+ * not, so we add it here.
672
+ *
673
+ * Read-modify-write so the existing default domains are preserved — a blind PATCH
674
+ * with only [localhost] would wipe firebaseapp.com / web.app. Idempotent: it's a
675
+ * no-op when both entries are already present.
676
+ *
677
+ * @returns {{ ok: boolean, added?: string[], error?: string }}
678
+ */
679
+ async function ensureLocalhostAuthorizedDomains(projectId, token) {
680
+ const base = `https://identitytoolkit.googleapis.com/admin/v2/projects/${projectId}/config`;
681
+ const headers = {
682
+ Authorization: `Bearer ${token}`,
683
+ 'Content-Type': 'application/json',
684
+ 'X-Goog-User-Project': projectId,
685
+ };
686
+ // 1. Read the current authorized domains.
687
+ const getRes = await fetch(base, { headers });
688
+ if (!getRes.ok) {
689
+ const text = await getRes.text();
690
+ return { ok: false, error: `${getRes.status}: ${text}` };
691
+ }
692
+ const config = await getRes.json();
693
+ const current = Array.isArray(config.authorizedDomains) ? config.authorizedDomains : [];
694
+ const required = ['localhost', '127.0.0.1'];
695
+ const missing = required.filter((d) => !current.includes(d));
696
+ if (missing.length === 0) return { ok: true, added: [] };
697
+ // 2. Merge and write back, keeping every domain that was already there.
698
+ const patchRes = await fetch(`${base}?updateMask=authorizedDomains`, {
699
+ method: 'PATCH',
700
+ headers,
701
+ body: JSON.stringify({ authorizedDomains: [...current, ...missing] }),
702
+ });
703
+ if (!patchRes.ok) {
704
+ const text = await patchRes.text();
705
+ return { ok: false, error: `${patchRes.status}: ${text}` };
706
+ }
707
+ return { ok: true, added: missing };
708
+ }
709
+
662
710
  /**
663
711
  * Enable Firebase Auth sign-in providers: Email/Password, Anonymous and Google.
664
712
  * Uses the Identity Toolkit Admin v2 REST API with gcloud credentials.
@@ -807,7 +855,18 @@ async function enableAuthProviders(projectId, { maxRetries = 3, retryDelayMs = 1
807
855
  // Network error — skip silently.
808
856
  }
809
857
 
810
- return { ok: true, googleSignInSkipped, googleEnabled, appleEnabled };
858
+ // Step 5: Authorize localhost / 127.0.0.1 so web sign-in works in dev.
859
+ // Best effort — failure here doesn't block the providers we just enabled.
860
+ const domainsResult = await ensureLocalhostAuthorizedDomains(projectId, token);
861
+
862
+ return {
863
+ ok: true,
864
+ googleSignInSkipped,
865
+ googleEnabled,
866
+ appleEnabled,
867
+ localhostAuthorized: domainsResult.ok,
868
+ authorizedDomainsAdded: domainsResult.added || [],
869
+ };
811
870
  }
812
871
  const text = await res.text();
813
872
  lastError = `${res.status}: ${text}`;
@@ -818,7 +877,24 @@ async function enableAuthProviders(projectId, { maxRetries = 3, retryDelayMs = 1
818
877
  }
819
878
  break;
820
879
  }
821
- return { ok: false, error: lastError };
880
+ // The provider PATCH failed after retries. Still try to authorize localhost as a
881
+ // best-effort: the auth config may already exist (a transient failure here, or
882
+ // auth initialized elsewhere), and web sign-in depends on localhost being on the
883
+ // list. This keeps the function self-sufficient instead of relying on a later
884
+ // re-invocation to backfill localhost.
885
+ let fallbackDomains = { ok: false, added: [] };
886
+ try {
887
+ const freshToken = await getAccessToken();
888
+ fallbackDomains = await ensureLocalhostAuthorizedDomains(projectId, freshToken);
889
+ } catch (_) {
890
+ // Best effort — ignore.
891
+ }
892
+ return {
893
+ ok: false,
894
+ error: lastError,
895
+ localhostAuthorized: fallbackDomains.ok,
896
+ authorizedDomainsAdded: fallbackDomains.added || [],
897
+ };
822
898
  }
823
899
 
824
900
  /**
@@ -907,6 +983,13 @@ async function setupFromScratch(appName, bundleId, options = {}) {
907
983
  url: `https://console.firebase.google.com/project/${projectId}/authentication/providers`,
908
984
  });
909
985
  }
986
+ // Providers were enabled but localhost couldn't be authorized — warn so the user
987
+ // isn't surprised by [firebase_auth/unauthorized-domain] on web sign-in.
988
+ if (authResult.ok && authResult.localhostAuthorized === false) {
989
+ onProgress('auth-localhost-warn', {
990
+ url: `https://console.firebase.google.com/project/${projectId}/authentication/settings`,
991
+ });
992
+ }
910
993
 
911
994
  onProgress('firestore');
912
995
  const firestoreResult = await createFirestoreDatabase(projectId, region);
@@ -1022,6 +1105,11 @@ async function setupExistingProject(projectId, options = {}) {
1022
1105
  url: `https://console.firebase.google.com/project/${projectId}/authentication/providers`,
1023
1106
  });
1024
1107
  }
1108
+ if (authResult.ok && authResult.localhostAuthorized === false) {
1109
+ onProgress('auth-localhost-warn', {
1110
+ url: `https://console.firebase.google.com/project/${projectId}/authentication/settings`,
1111
+ });
1112
+ }
1025
1113
 
1026
1114
  onProgress('firestore');
1027
1115
  const firestoreResult = await createFirestoreDatabase(projectId);
@@ -1114,6 +1202,7 @@ module.exports = {
1114
1202
  applyStorageCors,
1115
1203
  checkBillingEnabled,
1116
1204
  enableAuthProviders,
1205
+ ensureLocalhostAuthorizedDomains,
1117
1206
  listBillingAccounts,
1118
1207
  listGcpOrganizations,
1119
1208
  checkGcloudAuth,