kasy-cli 1.20.0 → 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.
- package/README.md +11 -3
- package/lib/commands/new.js +78 -37
- package/lib/commands/run.js +7 -0
- package/lib/scaffold/backends/api/patch/lib/core/data/api/meta_ads_api.dart +1 -1
- package/lib/scaffold/backends/api/patch/lib/core/data/api/storage_api.dart +1 -1
- package/lib/scaffold/backends/api/patch/lib/core/data/entities/user_entity.dart +1 -1
- package/lib/scaffold/backends/api/patch/lib/features/authentication/api/authentication_api.dart +4 -5
- package/lib/scaffold/backends/api/patch/lib/features/feedbacks/api/feature_request_api.dart +1 -1
- package/lib/scaffold/backends/api/patch/lib/features/feedbacks/api/feature_vote_api.dart +1 -1
- package/lib/scaffold/backends/api/patch/lib/features/llm_chat/api/llm_chat_api.dart +1 -1
- package/lib/scaffold/backends/api/patch/lib/features/llm_chat/providers/llm_chat_notifier.dart +317 -0
- package/lib/scaffold/backends/api/patch/lib/features/notifications/api/device_api.dart +40 -1
- package/lib/scaffold/backends/api/patch/lib/features/notifications/api/entities/notifications_entity.dart +2 -0
- package/lib/scaffold/backends/api/patch/lib/features/onboarding/api/entities/user_info_entity.dart +1 -1
- package/lib/scaffold/backends/api/patch/lib/features/subscription/api/entities/subscription_entity.dart +1 -1
- package/lib/scaffold/backends/api/patch/lib/features/subscription/shared/maybeshow_premium.dart +0 -2
- package/lib/scaffold/backends/api/pubspec.yaml.tpl +2 -0
- package/lib/scaffold/backends/firebase/enable-auth-via-cli.js +11 -8
- package/lib/scaffold/backends/supabase/deploy.js +56 -3
- package/lib/scaffold/backends/supabase/patch/lib/core/data/api/storage_api.dart +5 -11
- package/lib/scaffold/backends/supabase/patch/lib/core/data/entities/user_entity.dart +2 -2
- package/lib/scaffold/backends/supabase/patch/lib/features/notifications/api/device_api.dart +31 -1
- package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/api/entities/user_info_entity.dart +1 -1
- package/lib/scaffold/backends/supabase/patch/lib/features/subscription/api/entities/subscription_entity.dart +1 -1
- package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +2 -0
- package/lib/scaffold/catalog.js +2 -2
- package/lib/scaffold/generate.js +19 -3
- package/lib/scaffold/shared/generator-utils.js +265 -55
- package/lib/scaffold/shared/post-build.js +11 -0
- package/lib/utils/i18n/messages-en.js +5 -1
- package/lib/utils/i18n/messages-es.js +5 -1
- package/lib/utils/i18n/messages-pt.js +5 -1
- package/package.json +1 -1
- package/templates/firebase/lib/components/kasy_sidebar_pro.dart +3 -1
- package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +38 -128
- package/templates/firebase/lib/core/bottom_menu/bottom_router.dart +6 -125
- package/templates/firebase/lib/core/states/user_state_notifier.dart +8 -10
- package/templates/firebase/lib/features/home/home_components_page.dart +8 -14
- package/templates/firebase/lib/features/home/home_page.dart +7 -8
- package/templates/firebase/lib/router.dart +60 -0
- package/templates/firebase/test/core/bottom_menu/detail_route_menu_test.dart +57 -0
- package/lib/scaffold/backends/api/patch/lib/core/rating/widgets/review_popup.dart +0 -211
- package/lib/scaffold/backends/api/patch/lib/features/notifications/providers/models/notification.dart +0 -185
- package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/components/onboarding_notifications_setup.dart +0 -73
- package/lib/scaffold/backends/api/patch/lib/main.dart +0 -275
- package/lib/scaffold/backends/api/patch/lib/router.dart +0 -133
- package/lib/scaffold/backends/supabase/patch/lib/core/rating/widgets/review_popup.dart +0 -211
- package/lib/scaffold/backends/supabase/patch/lib/features/feedbacks/ui/component/add_feature_form.dart +0 -199
- package/lib/scaffold/backends/supabase/patch/lib/features/notifications/providers/models/notification.dart +0 -174
- package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/components/onboarding_notifications_setup.dart +0 -73
- package/lib/scaffold/backends/supabase/patch/lib/main.dart +0 -307
- package/lib/scaffold/backends/supabase/patch/lib/router.dart +0 -133
package/lib/scaffold/backends/api/patch/lib/features/llm_chat/providers/llm_chat_notifier.dart
ADDED
|
@@ -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();
|
package/lib/scaffold/backends/api/patch/lib/features/onboarding/api/entities/user_info_entity.dart
CHANGED
|
@@ -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
|
-
}) =
|
|
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')
|
|
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,
|
|
@@ -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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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 || {}),
|
|
@@ -197,6 +197,51 @@ async function enableGoogleSignIn(projectRef, webClientId, clientSecret) {
|
|
|
197
197
|
}
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Enable Apple Sign-In on the Supabase project via Management API.
|
|
202
|
+
*
|
|
203
|
+
* For native iOS sign-in (signInWithIdToken) Supabase validates the ID token's
|
|
204
|
+
* audience against external_apple_client_id, a comma-separated list of allowed
|
|
205
|
+
* client IDs. For a native iOS app that audience is the app's bundle ID, so we
|
|
206
|
+
* set it there. No secret is required for native sign-in; external_apple_secret
|
|
207
|
+
* is only needed for the web OAuth flow (Sign in with Apple JS), which requires
|
|
208
|
+
* an Apple Developer Service ID and is configured manually later.
|
|
209
|
+
*
|
|
210
|
+
* Note: external_apple_additional_client_ids is NOT used here. The Management API
|
|
211
|
+
* only appends it onto external_apple_client_id on write and always returns it as
|
|
212
|
+
* null on read, which makes the configured value impossible to verify.
|
|
213
|
+
*
|
|
214
|
+
* @param {string} projectRef
|
|
215
|
+
* @param {string} bundleId - iOS bundle identifier (e.g. com.acme.app)
|
|
216
|
+
*/
|
|
217
|
+
async function enableAppleSignIn(projectRef, bundleId) {
|
|
218
|
+
if (!bundleId) return { ok: false, error: 'bundleId is required' };
|
|
219
|
+
const token = await getSupabaseAccessToken();
|
|
220
|
+
if (!token) return { ok: false, error: 'Could not retrieve Supabase access token' };
|
|
221
|
+
const payload = JSON.stringify({
|
|
222
|
+
external_apple_enabled: true,
|
|
223
|
+
external_apple_client_id: bundleId,
|
|
224
|
+
});
|
|
225
|
+
const result = await run(
|
|
226
|
+
`curl -s -X PATCH "https://api.supabase.com/v1/projects/${projectRef}/config/auth" ` +
|
|
227
|
+
`-H "Authorization: Bearer ${token}" ` +
|
|
228
|
+
`-H "Content-Type: application/json" ` +
|
|
229
|
+
`-d '${payload}'`,
|
|
230
|
+
process.cwd()
|
|
231
|
+
);
|
|
232
|
+
if (!result.ok) return { ok: false, error: result.error };
|
|
233
|
+
try {
|
|
234
|
+
const data = JSON.parse(result.stdout);
|
|
235
|
+
const allowedIds = String(data.external_apple_client_id || '')
|
|
236
|
+
.split(',')
|
|
237
|
+
.map((s) => s.trim());
|
|
238
|
+
if (data.external_apple_enabled === true && allowedIds.includes(bundleId)) return { ok: true };
|
|
239
|
+
return { ok: false, error: data.message || JSON.stringify(data) };
|
|
240
|
+
} catch {
|
|
241
|
+
return { ok: false, error: 'Could not parse Management API response' };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
200
245
|
/**
|
|
201
246
|
* Configure auth settings via Supabase Management API:
|
|
202
247
|
* - Enable anonymous sign-in
|
|
@@ -386,12 +431,13 @@ async function createProjectAndGetKeys(projectName, dbPassword, region = 'sa-eas
|
|
|
386
431
|
|
|
387
432
|
/**
|
|
388
433
|
* Setup linked project: init, link, db push, anonymous sign-in, Google Sign-In,
|
|
389
|
-
* functions deploy, secrets.
|
|
434
|
+
* Apple Sign-In, functions deploy, secrets.
|
|
390
435
|
* Run AFTER generate.
|
|
391
436
|
* @param {object} secrets - { rcWebhookKey, metaAccessToken, metaDatasetId, firebaseProjectId }
|
|
392
|
-
* @param {object} google - { webClientId, clientSecret }
|
|
437
|
+
* @param {object} google - { webClientId, clientSecret } - optional, configures Google OAuth
|
|
438
|
+
* @param {object} apple - { bundleId } - optional, enables native iOS Apple Sign-In
|
|
393
439
|
*/
|
|
394
|
-
async function setupLinkedProject(projectDir, projectRef, dbPassword, secrets = {}, google = {}) {
|
|
440
|
+
async function setupLinkedProject(projectDir, projectRef, dbPassword, secrets = {}, google = {}, apple = {}) {
|
|
395
441
|
const steps = [];
|
|
396
442
|
|
|
397
443
|
const init = await ensureSupabaseInit(projectDir);
|
|
@@ -413,6 +459,12 @@ async function setupLinkedProject(projectDir, projectRef, dbPassword, secrets =
|
|
|
413
459
|
steps.push({ name: 'google sign-in', ok: googleResult.ok, detail: googleResult.error });
|
|
414
460
|
}
|
|
415
461
|
|
|
462
|
+
// Enable native iOS Apple Sign-In if a bundle ID was provided (no secret needed).
|
|
463
|
+
if (apple.bundleId) {
|
|
464
|
+
const appleResult = await enableAppleSignIn(projectRef, apple.bundleId);
|
|
465
|
+
steps.push({ name: 'apple sign-in', ok: appleResult.ok, detail: appleResult.error });
|
|
466
|
+
}
|
|
467
|
+
|
|
416
468
|
// Always deploy all edge functions (send-push-notification, revenuecat-webhook, etc.)
|
|
417
469
|
const fnResult = await deployFunctions(projectDir);
|
|
418
470
|
if (Array.isArray(fnResult)) {
|
|
@@ -473,6 +525,7 @@ module.exports = {
|
|
|
473
525
|
setupLinkedProject,
|
|
474
526
|
enableAnonymousSignIn,
|
|
475
527
|
enableGoogleSignIn,
|
|
528
|
+
enableAppleSignIn,
|
|
476
529
|
checkLoggedIn,
|
|
477
530
|
getOrgsList,
|
|
478
531
|
getProjectsByOrg,
|
|
@@ -11,24 +11,18 @@ final storageApiProvider = Provider<StorageApi>(
|
|
|
11
11
|
),
|
|
12
12
|
);
|
|
13
13
|
|
|
14
|
-
class StorageApi {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
/// upload a file to firebase storage
|
|
18
|
-
/// and return a stream of the upload progress
|
|
14
|
+
abstract class StorageApi {
|
|
15
|
+
/// upload a file to storage and return a stream of the upload progress
|
|
19
16
|
Stream<UploadResult> uploadData(
|
|
20
17
|
Uint8List data,
|
|
21
18
|
String folder,
|
|
22
19
|
String filename, {
|
|
23
20
|
String? mimeType, // ex 'image/jpg'
|
|
24
21
|
bool isPublic = true,
|
|
25
|
-
})
|
|
26
|
-
throw UnimplementedError();
|
|
27
|
-
}
|
|
22
|
+
});
|
|
28
23
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
24
|
+
/// request to delete a file from path
|
|
25
|
+
Future<void> deleteFile(String? path);
|
|
32
26
|
}
|
|
33
27
|
|
|
34
28
|
class SupabaseStorageApi implements StorageApi {
|
|
@@ -29,7 +29,7 @@ class Credentials {
|
|
|
29
29
|
final String id;
|
|
30
30
|
// session access token — used internally for session tracking
|
|
31
31
|
// Supabase manages token refresh automatically via SupabaseClient
|
|
32
|
-
final String token;
|
|
32
|
+
final String? token;
|
|
33
33
|
|
|
34
34
|
Credentials({
|
|
35
35
|
required this.id,
|
|
@@ -42,7 +42,7 @@ class Credentials {
|
|
|
42
42
|
}
|
|
43
43
|
return Credentials(
|
|
44
44
|
id: json['id']! as String,
|
|
45
|
-
token: json['token'] as String
|
|
45
|
+
token: json['token'] as String?,
|
|
46
46
|
);
|
|
47
47
|
}
|
|
48
48
|
}
|
|
@@ -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
|
-
}) =
|
|
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')
|
|
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,
|