kasy-cli 1.30.0 → 1.31.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/lib/scaffold/backends/supabase/deploy.js +124 -20
- package/lib/scaffold/shared/fcm-service-account.js +27 -12
- package/lib/scaffold/shared/post-build.js +5 -1
- package/package.json +1 -1
- package/templates/firebase/lib/core/bottom_menu/active_tab_notifier.dart +14 -0
- package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +33 -10
- package/templates/firebase/lib/core/states/user_state_notifier.dart +4 -0
- package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +9 -6
- package/templates/firebase/pubspec.yaml +1 -1
|
@@ -9,14 +9,14 @@
|
|
|
9
9
|
* Requires: supabase CLI installed, user logged in (supabase login).
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
const { exec
|
|
12
|
+
const { exec } = require('node:child_process');
|
|
13
13
|
const { promisify } = require('node:util');
|
|
14
14
|
const path = require('node:path');
|
|
15
|
+
const os = require('node:os');
|
|
15
16
|
const fs = require('fs-extra');
|
|
16
17
|
const { augmentedEnv } = require('../../../utils/env-tools');
|
|
17
18
|
|
|
18
19
|
const execAsync = promisify(exec);
|
|
19
|
-
const execFileAsync = promisify(execFile);
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* Parse JSON from a CLI's stdout tolerantly. The Supabase CLI sometimes prints
|
|
@@ -187,23 +187,116 @@ async function getProjectKeys(projectRef) {
|
|
|
187
187
|
}
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
// The Supabase CLI stores its access token via go-keyring under the service
|
|
191
|
+
// "Supabase CLI". The account/key has varied across CLI versions (e.g.
|
|
192
|
+
// "supabase", "access-token"), so we DON'T hardcode it — we read by service
|
|
193
|
+
// only. Each OS keeps this in a different vault. Used by the Management API
|
|
194
|
+
// calls below (auth providers), which the CLI itself has no command for.
|
|
195
|
+
const SUPABASE_KEYRING_SERVICE = 'Supabase CLI';
|
|
196
|
+
|
|
197
|
+
// Read the Supabase token out of the Windows Credential Manager. go-keyring's
|
|
198
|
+
// wincred backend names the target "<service>:<user>", but since the user part
|
|
199
|
+
// differs by CLI version we ENUMERATE every credential whose target starts with
|
|
200
|
+
// the service name and return the first non-empty blob as base64 — the caller
|
|
201
|
+
// picks the decoding. Robust to the account name we can't see from here.
|
|
202
|
+
async function readWindowsSupabaseTokenBase64() {
|
|
203
|
+
const ps = `
|
|
204
|
+
$ErrorActionPreference = 'Stop'
|
|
205
|
+
$sig = @"
|
|
206
|
+
using System;
|
|
207
|
+
using System.Runtime.InteropServices;
|
|
208
|
+
public class KasyCred {
|
|
209
|
+
[DllImport("advapi32.dll", CharSet=CharSet.Unicode, SetLastError=true)]
|
|
210
|
+
public static extern bool CredEnumerate(string filter, int flag, out int count, out IntPtr creds);
|
|
211
|
+
[DllImport("advapi32.dll")] public static extern void CredFree(IntPtr cred);
|
|
212
|
+
[StructLayout(LayoutKind.Sequential)] public struct CREDENTIAL {
|
|
213
|
+
public int Flags; public int Type; public IntPtr TargetName; public IntPtr Comment;
|
|
214
|
+
public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
|
|
215
|
+
public int CredentialBlobSize; public IntPtr CredentialBlob; public int Persist;
|
|
216
|
+
public int AttributeCount; public IntPtr Attributes; public IntPtr TargetAlias; public IntPtr UserName;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
"@
|
|
220
|
+
Add-Type $sig | Out-Null
|
|
221
|
+
$count = 0; $ptr = [IntPtr]::Zero
|
|
222
|
+
if ([KasyCred]::CredEnumerate('${SUPABASE_KEYRING_SERVICE}*', 0, [ref]$count, [ref]$ptr)) {
|
|
223
|
+
for ($i = 0; $i -lt $count; $i++) {
|
|
224
|
+
$credPtr = [System.Runtime.InteropServices.Marshal]::ReadIntPtr($ptr, $i * [IntPtr]::Size)
|
|
225
|
+
$c = [System.Runtime.InteropServices.Marshal]::PtrToStructure($credPtr, [type]([KasyCred+CREDENTIAL]))
|
|
226
|
+
if ($c.CredentialBlobSize -gt 0) {
|
|
227
|
+
$bytes = New-Object byte[] $c.CredentialBlobSize
|
|
228
|
+
[System.Runtime.InteropServices.Marshal]::Copy($c.CredentialBlob, $bytes, 0, $c.CredentialBlobSize)
|
|
229
|
+
[Convert]::ToBase64String($bytes)
|
|
230
|
+
break
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
`;
|
|
235
|
+
const encoded = Buffer.from(ps, 'utf16le').toString('base64');
|
|
236
|
+
const { stdout } = await execAsync(
|
|
237
|
+
`powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${encoded}`,
|
|
238
|
+
{ windowsHide: true, maxBuffer: 1024 * 1024 },
|
|
239
|
+
);
|
|
240
|
+
return (stdout || '').trim();
|
|
241
|
+
}
|
|
242
|
+
|
|
190
243
|
/**
|
|
191
|
-
* Get the Supabase access token stored by `supabase login
|
|
192
|
-
*
|
|
193
|
-
*
|
|
244
|
+
* Get the Supabase access token stored by `supabase login`, cross-platform:
|
|
245
|
+
* - SUPABASE_ACCESS_TOKEN env var (any OS, also the CI path)
|
|
246
|
+
* - macOS: Keychain (`security`)
|
|
247
|
+
* - Windows: Credential Manager (CredRead via PowerShell)
|
|
248
|
+
* - Linux: libsecret (`secret-tool`)
|
|
249
|
+
* Returns null if none works (caller degrades to a manual hint).
|
|
194
250
|
*/
|
|
195
251
|
async function getSupabaseAccessToken() {
|
|
196
252
|
if (process.env.SUPABASE_ACCESS_TOKEN) return process.env.SUPABASE_ACCESS_TOKEN;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
const
|
|
200
|
-
if (
|
|
201
|
-
|
|
253
|
+
|
|
254
|
+
const decodeKeyring = (raw) => {
|
|
255
|
+
const s = (raw || '').trim();
|
|
256
|
+
if (!s) return null;
|
|
257
|
+
if (s.startsWith('go-keyring-base64:')) {
|
|
258
|
+
return Buffer.from(s.replace('go-keyring-base64:', ''), 'base64').toString('utf8').trim() || null;
|
|
259
|
+
}
|
|
260
|
+
return s;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
if (process.platform === 'darwin') {
|
|
264
|
+
try {
|
|
265
|
+
const { stdout } = await execAsync(`security find-generic-password -s "${SUPABASE_KEYRING_SERVICE}" -w`);
|
|
266
|
+
return decodeKeyring(stdout);
|
|
267
|
+
} catch {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (process.platform === 'win32') {
|
|
273
|
+
try {
|
|
274
|
+
const b64 = await readWindowsSupabaseTokenBase64();
|
|
275
|
+
if (!b64) return null;
|
|
276
|
+
const buf = Buffer.from(b64, 'base64');
|
|
277
|
+
// UTF-16LE blobs have a NUL after most bytes; UTF-8 blobs don't.
|
|
278
|
+
const nulCount = buf.reduce((n, b) => n + (b === 0 ? 1 : 0), 0);
|
|
279
|
+
const token = (nulCount > buf.length / 4 ? buf.toString('utf16le') : buf.toString('utf8')).trim();
|
|
280
|
+
return decodeKeyring(token);
|
|
281
|
+
} catch {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Linux (libsecret). The account key has varied by CLI version, so try the
|
|
287
|
+
// ones we've seen before giving up.
|
|
288
|
+
for (const user of ['supabase', 'access-token']) {
|
|
289
|
+
try {
|
|
290
|
+
const { stdout } = await execAsync(
|
|
291
|
+
`secret-tool lookup service "${SUPABASE_KEYRING_SERVICE}" username "${user}"`,
|
|
292
|
+
);
|
|
293
|
+
const token = decodeKeyring(stdout);
|
|
294
|
+
if (token) return token;
|
|
295
|
+
} catch {
|
|
296
|
+
// try next
|
|
202
297
|
}
|
|
203
|
-
return raw || null;
|
|
204
|
-
} catch {
|
|
205
|
-
return null;
|
|
206
298
|
}
|
|
299
|
+
return null;
|
|
207
300
|
}
|
|
208
301
|
|
|
209
302
|
/**
|
|
@@ -352,19 +445,30 @@ async function dbPush(projectDir) {
|
|
|
352
445
|
}
|
|
353
446
|
|
|
354
447
|
/**
|
|
355
|
-
* Set a single Supabase secret
|
|
356
|
-
*
|
|
448
|
+
* Set a single Supabase secret.
|
|
449
|
+
*
|
|
450
|
+
* On Windows the supabase binary is `supabase.cmd`, which execFile (no shell)
|
|
451
|
+
* can't launch — it failed with "spawn supabase ENOENT". We go through the
|
|
452
|
+
* shell-based run() instead, which resolves the .cmd and uses the augmented
|
|
453
|
+
* PATH. To keep the secret value off the command line (no shell escaping /
|
|
454
|
+
* injection), we hand it to the CLI via a temporary --env-file rather than as a
|
|
455
|
+
* positional KEY=VALUE arg.
|
|
357
456
|
*/
|
|
358
457
|
async function setSecret(projectDir, key, value) {
|
|
458
|
+
let tmpDir;
|
|
359
459
|
try {
|
|
360
|
-
await
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
});
|
|
460
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'kasy-secret-'));
|
|
461
|
+
const envFile = path.join(tmpDir, 'secret.env');
|
|
462
|
+
// The value is single-line (JSON is compacted upstream). dotenv splits on the
|
|
463
|
+
// first '=' only, so any '=' inside the value is preserved.
|
|
464
|
+
await fs.writeFile(envFile, `${key}=${value}\n`, 'utf8');
|
|
465
|
+
const r = await run(`supabase secrets set --env-file "${envFile}"`, projectDir);
|
|
466
|
+
if (!r.ok) return { ok: false, error: (r.stderr || r.error || '').slice(0, 300) };
|
|
365
467
|
return { ok: true };
|
|
366
468
|
} catch (err) {
|
|
367
469
|
return { ok: false, error: err.message };
|
|
470
|
+
} finally {
|
|
471
|
+
if (tmpDir) await fs.remove(tmpDir).catch(() => {});
|
|
368
472
|
}
|
|
369
473
|
}
|
|
370
474
|
|
|
@@ -14,12 +14,14 @@ const { promisify } = require('node:util');
|
|
|
14
14
|
const fs = require('fs-extra');
|
|
15
15
|
const path = require('node:path');
|
|
16
16
|
const os = require('node:os');
|
|
17
|
+
const { augmentedEnv } = require('../../utils/env-tools');
|
|
17
18
|
|
|
18
19
|
const execAsync = promisify(exec);
|
|
19
20
|
|
|
20
21
|
async function run(cmd) {
|
|
21
22
|
try {
|
|
22
|
-
|
|
23
|
+
// augmentedEnv so a gcloud installed earlier this run is found on Windows.
|
|
24
|
+
const { stdout, stderr } = await execAsync(cmd, { maxBuffer: 5 * 1024 * 1024, env: augmentedEnv() });
|
|
23
25
|
return { ok: true, stdout: stdout.trim(), stderr: stderr.trim() };
|
|
24
26
|
} catch (err) {
|
|
25
27
|
return {
|
|
@@ -86,17 +88,31 @@ async function createFcmServiceAccountKey(projectId) {
|
|
|
86
88
|
return { ok: false, error: 'projectId is required' };
|
|
87
89
|
}
|
|
88
90
|
|
|
89
|
-
const
|
|
90
|
-
if (!saResult.ok) {
|
|
91
|
-
return { ok: false, error: saResult.error };
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const saEmail = saResult.email;
|
|
91
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
95
92
|
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
93
|
+
// On a brand-new project the Firebase Admin SDK service account is provisioned
|
|
94
|
+
// asynchronously after Firebase is added, and the IAM role grant also needs a
|
|
95
|
+
// moment to take. Both can fail immediately on the first try, which is why
|
|
96
|
+
// push used to come out half-configured on fresh Supabase/API projects. Retry
|
|
97
|
+
// the discover+grant with backoff before giving up.
|
|
98
|
+
let saEmail = '';
|
|
99
|
+
let lastErr = 'Firebase Admin SDK service account not found';
|
|
100
|
+
for (let attempt = 1; attempt <= 7; attempt++) {
|
|
101
|
+
const saResult = await findFirebaseAdminSdkSA(projectId.trim());
|
|
102
|
+
if (saResult.ok) {
|
|
103
|
+
const roleResult = await grantFcmAdminRole(projectId.trim(), saResult.email);
|
|
104
|
+
if (roleResult.ok) {
|
|
105
|
+
saEmail = saResult.email;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
lastErr = roleResult.error;
|
|
109
|
+
} else {
|
|
110
|
+
lastErr = saResult.error;
|
|
111
|
+
}
|
|
112
|
+
if (attempt < 7) await sleep(10000);
|
|
113
|
+
}
|
|
114
|
+
if (!saEmail) {
|
|
115
|
+
return { ok: false, error: lastErr };
|
|
100
116
|
}
|
|
101
117
|
|
|
102
118
|
const tmpPath = path.join(os.tmpdir(), `kasy-fcm-${Date.now()}.json`);
|
|
@@ -105,7 +121,6 @@ async function createFcmServiceAccountKey(projectId) {
|
|
|
105
121
|
// IAM permission takes a moment to propagate — so the first key-create often
|
|
106
122
|
// fails with "permission still propagating". Back off and retry a few times
|
|
107
123
|
// before giving up, instead of leaving push half-configured.
|
|
108
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
109
124
|
let keyResult;
|
|
110
125
|
for (let attempt = 1; attempt <= 4; attempt++) {
|
|
111
126
|
keyResult = await run(
|
|
@@ -38,7 +38,11 @@ async function run(cmd, cwd, timeout) {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
async function pubGet(projectDir) {
|
|
41
|
-
|
|
41
|
+
// 15 min: the FIRST `flutter pub get` on a fresh machine downloads the whole
|
|
42
|
+
// dependency tree (this template pulls in firebase, supabase, revenuecat,
|
|
43
|
+
// stripe, sentry…), which timed out at the old 5-min cap on slower Windows
|
|
44
|
+
// connections. It's a ceiling, not a wait — a warm cache still returns fast.
|
|
45
|
+
return run('flutter pub get', projectDir, 900_000);
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
async function slangGenerate(projectDir) {
|
package/package.json
CHANGED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import 'package:flutter/foundation.dart';
|
|
2
|
+
|
|
3
|
+
/// The bottom-bar tab the user last opened, held at top level so it outlives the
|
|
4
|
+
/// [BottomMenu] remount that happens whenever the responsive layout flips
|
|
5
|
+
/// small↔large (e.g. toggling the web device preview, which renders the app in a
|
|
6
|
+
/// phone-width frame). Persisting it lets the bottom bar restore the tab instead
|
|
7
|
+
/// of snapping back to the first one on remount or hard reload (F5).
|
|
8
|
+
///
|
|
9
|
+
/// It lives in its own dependency-free file so both [BottomMenu] and the logout
|
|
10
|
+
/// flow can touch it without an import cycle. Cleared on logout so a fresh login
|
|
11
|
+
/// always lands on the default tab. Null until the user opens a tab.
|
|
12
|
+
final ValueNotifier<String?> activeTabRouteNotifier = ValueNotifier<String?>(
|
|
13
|
+
null,
|
|
14
|
+
);
|
|
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|
|
4
4
|
import 'package:flutter/services.dart';
|
|
5
5
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
6
6
|
import 'package:kasy_kit/components/kasy_sidebar.dart';
|
|
7
|
+
import 'package:kasy_kit/core/bottom_menu/active_tab_notifier.dart';
|
|
7
8
|
import 'package:kasy_kit/core/bottom_menu/bottom_router.dart';
|
|
8
9
|
import 'package:kasy_kit/core/bottom_menu/kasy_bottom_bar_factory.dart';
|
|
9
10
|
import 'package:kasy_kit/core/bottom_menu/web_content_wrapper.dart';
|
|
@@ -15,6 +16,12 @@ import 'package:kasy_kit/core/widgets/responsive_layout.dart';
|
|
|
15
16
|
import 'package:kasy_kit/features/settings/ui/widgets/kasy_user_avatar.dart';
|
|
16
17
|
import 'package:kasy_kit/i18n/translations.g.dart';
|
|
17
18
|
|
|
19
|
+
/// Records the active tab so it survives the next remount. Wired to
|
|
20
|
+
/// [bart.BartScaffold.onRouteChanged]. See [activeTabRouteNotifier].
|
|
21
|
+
void _rememberActiveTab(bart.BartMenuRoute route) {
|
|
22
|
+
activeTabRouteNotifier.value = route.path;
|
|
23
|
+
}
|
|
24
|
+
|
|
18
25
|
/// Bottom navigation host powered by Bart (https://pub.dev/packages/bart).
|
|
19
26
|
///
|
|
20
27
|
/// [ResponsiveLayout] swaps between three [bart.BartScaffold]s (small / medium /
|
|
@@ -89,6 +96,7 @@ class BottomMenu extends StatelessWidget {
|
|
|
89
96
|
showBottomBarOnStart: showBottomBarOnStart,
|
|
90
97
|
scaffoldOptions: scaffoldOptions,
|
|
91
98
|
sideBarOptions: connectedSidebar(),
|
|
99
|
+
onRouteChanged: _rememberActiveTab,
|
|
92
100
|
),
|
|
93
101
|
);
|
|
94
102
|
|
|
@@ -104,6 +112,7 @@ class BottomMenu extends StatelessWidget {
|
|
|
104
112
|
initialRoute: resolvedInitialRoute,
|
|
105
113
|
showBottomBarOnStart: showBottomBarOnStart,
|
|
106
114
|
scaffoldOptions: scaffoldOptions,
|
|
115
|
+
onRouteChanged: _rememberActiveTab,
|
|
107
116
|
),
|
|
108
117
|
medium: connectedScaffold(),
|
|
109
118
|
large: connectedScaffold(),
|
|
@@ -115,6 +124,19 @@ class BottomMenu extends StatelessWidget {
|
|
|
115
124
|
if (route != null) {
|
|
116
125
|
return route;
|
|
117
126
|
}
|
|
127
|
+
// Restore the last tab across a remount. This is the reliable source: the
|
|
128
|
+
// browser URL is contended by both GoRouter and Bart, but this notifier is
|
|
129
|
+
// owned solely by the bottom bar and lives above the rebuilt subtree.
|
|
130
|
+
//
|
|
131
|
+
// The value is returned as a BARE tab name (e.g. "settings", not
|
|
132
|
+
// "/settings"). Bart's NestedNavigator matches routes by their exact path,
|
|
133
|
+
// which has no leading slash; passing a "/"-prefixed route makes Flutter's
|
|
134
|
+
// Navigator split it into segments that never match, so it falls back to the
|
|
135
|
+
// first tab (home). See bart's nested_navigator.dart onGenerateRoute.
|
|
136
|
+
final String? lastTab = activeTabRouteNotifier.value;
|
|
137
|
+
if (lastTab != null && _isKnownTab(lastTab)) {
|
|
138
|
+
return _bareTab(lastTab);
|
|
139
|
+
}
|
|
118
140
|
if (!kIsWeb) {
|
|
119
141
|
return null;
|
|
120
142
|
}
|
|
@@ -124,21 +146,22 @@ class BottomMenu extends StatelessWidget {
|
|
|
124
146
|
return null;
|
|
125
147
|
}
|
|
126
148
|
// A single segment is a bottom-bar tab (home/notifications/settings/…).
|
|
127
|
-
// Bart keeps the browser URL in sync
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
// instead of snapping back to the first one. Only accept it when it maps to
|
|
131
|
-
// a real tab; anything else falls back to the default tab.
|
|
149
|
+
// Bart keeps the browser URL in sync via history.pushState, so honoring it
|
|
150
|
+
// here also restores the tab on a hard reload (F5). Only accept it when it
|
|
151
|
+
// maps to a real tab; anything else falls back to the default tab.
|
|
132
152
|
if (segments.length == 1) {
|
|
133
|
-
|
|
134
|
-
final bool isKnownTab = subRoutes().any(
|
|
135
|
-
(r) => r.path.replaceAll('/', '') == tab,
|
|
136
|
-
);
|
|
137
|
-
return isKnownTab ? '/$tab' : null;
|
|
153
|
+
return _isKnownTab(segments.first) ? _bareTab(segments.first) : null;
|
|
138
154
|
}
|
|
139
155
|
return '/${segments.join('/')}';
|
|
140
156
|
}
|
|
141
157
|
|
|
158
|
+
String _bareTab(String path) => path.replaceAll('/', '');
|
|
159
|
+
|
|
160
|
+
bool _isKnownTab(String path) {
|
|
161
|
+
final String tab = _bareTab(path);
|
|
162
|
+
return subRoutes().any((r) => _bareTab(r.path) == tab);
|
|
163
|
+
}
|
|
164
|
+
|
|
142
165
|
String _initialWebPath(Uri uri) {
|
|
143
166
|
final path = uri.path;
|
|
144
167
|
if (path != '/' && path.isNotEmpty) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import 'dart:async';
|
|
2
2
|
|
|
3
3
|
import 'package:flutter/foundation.dart';
|
|
4
|
+
import 'package:kasy_kit/core/bottom_menu/active_tab_notifier.dart';
|
|
4
5
|
import 'package:kasy_kit/core/config/features.dart';
|
|
5
6
|
import 'package:kasy_kit/core/data/models/entitlement.dart';
|
|
6
7
|
import 'package:kasy_kit/core/data/models/subscription.dart';
|
|
@@ -139,6 +140,9 @@ class UserStateNotifier extends _$UserStateNotifier implements OnStartService {
|
|
|
139
140
|
// Biometric lock is a per-account preference, not a device-wide one.
|
|
140
141
|
// The next user signing in on this install should start without it set.
|
|
141
142
|
await ref.read(sharedPreferencesProvider).setBiometricEnabled(false);
|
|
143
|
+
// Forget the last bottom-bar tab so the next login lands on the default tab
|
|
144
|
+
// (Home) instead of wherever the previous account left off.
|
|
145
|
+
activeTabRouteNotifier.value = null;
|
|
142
146
|
state = const UserState(user: User.anonymous());
|
|
143
147
|
if (mode == AuthenticationMode.anonymous) {
|
|
144
148
|
await _loadAnonymousState();
|
|
@@ -13,7 +13,10 @@ import 'package:provider/provider.dart';
|
|
|
13
13
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
14
14
|
import 'package:universal_html/html.dart' as html;
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
// Suffixed `_v2` because the default flipped from OFF to ON. Values saved under
|
|
17
|
+
// the old key were written while the default was OFF, so we ignore them and
|
|
18
|
+
// start fresh — every install now gets the new ON default until it's toggled.
|
|
19
|
+
const String webDevicePreviewEnabledPrefKey = 'web_device_preview_enabled_v2';
|
|
17
20
|
const String _platformPrefKey = 'web_device_preview_platform';
|
|
18
21
|
const String _iosIndexPrefKey = 'web_device_preview_ios_index';
|
|
19
22
|
const String _androidIndexPrefKey = 'web_device_preview_android_index';
|
|
@@ -203,11 +206,11 @@ class _WebDevicePreviewState extends State<WebDevicePreview>
|
|
|
203
206
|
Future<void> _bootstrap() async {
|
|
204
207
|
final prefs = await SharedPreferences.getInstance();
|
|
205
208
|
|
|
206
|
-
// Default
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
final savedEnabled = prefs.getBool(webDevicePreviewEnabledPrefKey) ??
|
|
209
|
+
// Default ON: previewing the mobile app inside a device frame is the
|
|
210
|
+
// expected first view for this mobile-first template. Devs who prefer the
|
|
211
|
+
// real desktop proportions toggle it off (shortcut), and that choice
|
|
212
|
+
// persists for subsequent launches.
|
|
213
|
+
final savedEnabled = prefs.getBool(webDevicePreviewEnabledPrefKey) ?? true;
|
|
211
214
|
final savedPlatform = prefs.getInt(_platformPrefKey);
|
|
212
215
|
final savedIosIndex = prefs.getInt(_iosIndexPrefKey);
|
|
213
216
|
final savedAndroidIndex = prefs.getInt(_androidIndexPrefKey);
|
|
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|
|
16
16
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
|
17
17
|
# In Windows, build-name is used as the major, minor, and patch parts
|
|
18
18
|
# of the product and file versions while build-number is used as the build suffix.
|
|
19
|
-
version: 1.0.0+
|
|
19
|
+
version: 1.0.0+35
|
|
20
20
|
|
|
21
21
|
environment:
|
|
22
22
|
sdk: ^3.11.0
|