kasy-cli 1.22.0 → 1.24.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.
- package/bin/kasy.js +26 -4
- package/lib/utils/checks.js +20 -4
- package/lib/utils/env-tools.js +7 -0
- package/lib/utils/flutter-install.js +114 -0
- package/lib/utils/i18n/messages-en.js +2 -0
- package/lib/utils/i18n/messages-es.js +2 -0
- package/lib/utils/i18n/messages-pt.js +2 -0
- package/package.json +1 -1
- package/templates/firebase/lib/components/kasy_sidebar.dart +14 -1
- package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +138 -66
- package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +19 -6
- package/templates/firebase/lib/core/widgets/kasy_focus_ring.dart +9 -1
- package/templates/firebase/lib/core/widgets/kasy_hover.dart +28 -1
- package/templates/firebase/lib/features/authentication/ui/widgets/auth_card_scaffold.dart +51 -41
- package/templates/firebase/pubspec.yaml +1 -1
package/bin/kasy.js
CHANGED
|
@@ -463,9 +463,15 @@ function buildProgram(language) {
|
|
|
463
463
|
if (p[p.length - 1] === 'lib') p = p.slice(0, -1);
|
|
464
464
|
prefix = p.join(path.sep) || null;
|
|
465
465
|
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
466
|
+
// Build a single, pre-quoted command string. Passing a quoted string
|
|
467
|
+
// (instead of an args array) with shell:true keeps a prefix that contains
|
|
468
|
+
// spaces intact — e.g. C:\Users\John Silva\.kasy — which the array form
|
|
469
|
+
// splits into a broken path (npm reads "Silva\.kasy" as a second package).
|
|
470
|
+
// It also avoids Node's DEP0190 arg-concatenation warning.
|
|
471
|
+
const quote = (s) => `"${s}"`;
|
|
472
|
+
let cmd = 'npm install -g kasy-cli@latest';
|
|
473
|
+
if (prefix) cmd += ` --prefix ${quote(prefix)}`;
|
|
474
|
+
const result = spawnSync(cmd, { stdio: 'inherit', shell: true });
|
|
469
475
|
if (result.status === 0) {
|
|
470
476
|
const after = readVersion();
|
|
471
477
|
let msg;
|
|
@@ -486,9 +492,25 @@ function buildProgram(language) {
|
|
|
486
492
|
.description(t('cli.command.uninstall.description'))
|
|
487
493
|
.action(() => {
|
|
488
494
|
const { spawnSync } = require('node:child_process');
|
|
495
|
+
const path = require('node:path');
|
|
489
496
|
printCompactHeader(t);
|
|
490
497
|
console.log(kleur.cyan(t('cli.command.uninstall.running')) + '\n');
|
|
491
|
-
|
|
498
|
+
// Remove from the SAME prefix the CLI was installed into (~/.kasy), the
|
|
499
|
+
// way `kasy upgrade` derives it — otherwise npm targets its default global
|
|
500
|
+
// prefix and leaves the real copy in place. Quote it so a path with
|
|
501
|
+
// spaces (C:\Users\John Silva\.kasy) survives shell:true.
|
|
502
|
+
const segs = __dirname.split(path.sep);
|
|
503
|
+
const nm = segs.lastIndexOf('node_modules');
|
|
504
|
+
let prefix = null;
|
|
505
|
+
if (nm > 0) {
|
|
506
|
+
let p = segs.slice(0, nm);
|
|
507
|
+
if (p[p.length - 1] === 'lib') p = p.slice(0, -1);
|
|
508
|
+
prefix = p.join(path.sep) || null;
|
|
509
|
+
}
|
|
510
|
+
const quote = (s) => `"${s}"`;
|
|
511
|
+
let cmd = 'npm uninstall -g kasy-cli';
|
|
512
|
+
if (prefix) cmd += ` --prefix ${quote(prefix)}`;
|
|
513
|
+
const result = spawnSync(cmd, { stdio: 'inherit', shell: true });
|
|
492
514
|
if (result.status === 0) {
|
|
493
515
|
console.log('\n' + kleur.green('✓ ' + t('cli.command.uninstall.done')) + '\n');
|
|
494
516
|
} else {
|
package/lib/utils/checks.js
CHANGED
|
@@ -5,6 +5,7 @@ const ui = require('./ui');
|
|
|
5
5
|
const { createTranslator, detectDefaultLanguage } = require('./i18n');
|
|
6
6
|
const { augmentedEnv, pubCacheBin } = require('./env-tools');
|
|
7
7
|
const { promptOpenBrowser } = require('./browser');
|
|
8
|
+
const { installFlutterWindows } = require('./flutter-install');
|
|
8
9
|
|
|
9
10
|
const execAsync = promisify(exec);
|
|
10
11
|
|
|
@@ -66,6 +67,12 @@ const BASE_CHECKS = [
|
|
|
66
67
|
minVersion: MIN_FLUTTER_VERSION,
|
|
67
68
|
installGuide: () => getInstallGuide('flutter'),
|
|
68
69
|
confirmInstall: true,
|
|
70
|
+
// Windows has no clean winget path for Flutter, so we run a custom installer
|
|
71
|
+
// (download SDK zip + Git) instead of a package-manager command. macOS/Linux
|
|
72
|
+
// keep using installGuide().cmd (brew). See flutter-install.js.
|
|
73
|
+
installFn: process.platform === 'win32' ? installFlutterWindows : null,
|
|
74
|
+
installFnDescKey: 'checks.install.flutterWinDesc',
|
|
75
|
+
installFnNoteKey: 'checks.install.flutterWinNote',
|
|
69
76
|
},
|
|
70
77
|
{
|
|
71
78
|
name: 'Dart SDK',
|
|
@@ -268,16 +275,25 @@ async function recoverCheckInteractively(check, t) {
|
|
|
268
275
|
// The step list already flagged this tool as missing; go straight to fixing it.
|
|
269
276
|
const guide = typeof check.installGuide === 'function' ? check.installGuide() : null;
|
|
270
277
|
|
|
271
|
-
// 1) Offer auto-install for heavy tools
|
|
272
|
-
|
|
278
|
+
// 1) Offer auto-install for heavy tools. Most use a package-manager command
|
|
279
|
+
// (brew/winget); Flutter on Windows uses a custom installer (check.installFn)
|
|
280
|
+
// because winget ships no stable Flutter package.
|
|
281
|
+
if (check.confirmInstall && (check.installFn || (guide && guide.cmd))) {
|
|
282
|
+
const cmdLabel = check.installFn
|
|
283
|
+
? (check.installFnDescKey ? t(check.installFnDescKey) : check.name)
|
|
284
|
+
: guide.cmd;
|
|
273
285
|
const doInstall = await ui.confirm({
|
|
274
|
-
message: t('checks.install.confirm', { name: check.name, cmd:
|
|
286
|
+
message: t('checks.install.confirm', { name: check.name, cmd: cmdLabel }),
|
|
275
287
|
initialValue: true,
|
|
276
288
|
});
|
|
277
289
|
if (doInstall) {
|
|
290
|
+
// Heads-up for the custom installer: it's a big download and may pop a UAC.
|
|
291
|
+
if (check.installFn && check.installFnNoteKey) ui.log.info(t(check.installFnNoteKey));
|
|
278
292
|
const spinner = ui.timedSpinner();
|
|
279
293
|
spinner.start(t('checks.install.running', { name: check.name }));
|
|
280
|
-
const installed =
|
|
294
|
+
const installed = check.installFn
|
|
295
|
+
? await check.installFn({})
|
|
296
|
+
: await execTool(guide.cmd, INSTALL_TIMEOUT);
|
|
281
297
|
spinner.stop(t('checks.install.running', { name: check.name }));
|
|
282
298
|
if (installed.ok && (await revalidate(check, t))) return true;
|
|
283
299
|
ui.log.warn(t('checks.install.failedManual', { name: check.name }));
|
package/lib/utils/env-tools.js
CHANGED
|
@@ -105,6 +105,13 @@ function extraToolDirs() {
|
|
|
105
105
|
process.env.LOCALAPPDATA && path.win32.join(process.env.LOCALAPPDATA, 'Google', 'Cloud SDK', 'google-cloud-sdk', 'bin'),
|
|
106
106
|
process.env.ProgramFiles && path.win32.join(process.env.ProgramFiles, 'Google', 'Cloud SDK', 'google-cloud-sdk', 'bin'),
|
|
107
107
|
process.env['ProgramFiles(x86)'] && path.win32.join(process.env['ProgramFiles(x86)'], 'Google', 'Cloud SDK', 'google-cloud-sdk', 'bin'),
|
|
108
|
+
// Flutter SDK auto-installed by `kasy new` (archive unzipped into %LOCALAPPDATA%\flutter).
|
|
109
|
+
// Putting its bin here lets the same run's flutter pub get / build_runner /
|
|
110
|
+
// flutterfire find both `flutter` and `dart` without a new terminal.
|
|
111
|
+
process.env.LOCALAPPDATA && path.win32.join(process.env.LOCALAPPDATA, 'flutter', 'bin'),
|
|
112
|
+
// Git (a Flutter prerequisite) installed via winget Git.Git — default locations.
|
|
113
|
+
process.env.ProgramFiles && path.win32.join(process.env.ProgramFiles, 'Git', 'cmd'),
|
|
114
|
+
process.env.LOCALAPPDATA && path.win32.join(process.env.LOCALAPPDATA, 'Programs', 'Git', 'cmd'),
|
|
108
115
|
].filter(Boolean);
|
|
109
116
|
return candidates.filter((dir) => fs.existsSync(dir));
|
|
110
117
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-install the Flutter SDK on Windows, the only OS where there is no clean
|
|
3
|
+
* package-manager path (winget ships no stable Flutter package — the official
|
|
4
|
+
* route is the SDK archive). We mirror what the Flutter docs tell a human to do,
|
|
5
|
+
* but do it for the user so `kasy new` can prepare a bare machine end to end:
|
|
6
|
+
*
|
|
7
|
+
* 1. Install Git via winget if missing (Flutter refuses to run without it).
|
|
8
|
+
* 2. Download the current stable Flutter SDK zip and unzip it into
|
|
9
|
+
* %LOCALAPPDATA%\flutter (a per-user dir that never needs admin rights).
|
|
10
|
+
* 3. Persist %LOCALAPPDATA%\flutter\bin on the User PATH for future terminals.
|
|
11
|
+
*
|
|
12
|
+
* The freshly-installed dirs are added to `augmentedEnv()` (see env-tools), so
|
|
13
|
+
* the rest of THIS `kasy new` run — flutter pub get, build_runner, flutterfire —
|
|
14
|
+
* finds the SDK immediately, without the user opening a new terminal.
|
|
15
|
+
*
|
|
16
|
+
* macOS/Linux don't need this: there Flutter installs via `brew install --cask
|
|
17
|
+
* flutter` (handled by the generic install path in checks.js).
|
|
18
|
+
*/
|
|
19
|
+
const { exec } = require('node:child_process');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
|
|
22
|
+
const isWindows = process.platform === 'win32';
|
|
23
|
+
|
|
24
|
+
// Where the SDK archive is unzipped. Must stay in sync with env-tools
|
|
25
|
+
// extraToolDirs() so the recheck and the build steps look in the same place.
|
|
26
|
+
function flutterWinHome() {
|
|
27
|
+
const base =
|
|
28
|
+
process.env.LOCALAPPDATA ||
|
|
29
|
+
path.win32.join(process.env.USERPROFILE || '', 'AppData', 'Local');
|
|
30
|
+
return path.win32.join(base, 'flutter');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The PowerShell that does the actual work. Kept as a script (not a one-liner)
|
|
34
|
+
// because it has to download ~1 GB, unzip it, and edit the persistent PATH —
|
|
35
|
+
// none of which fits a single shell command cleanly.
|
|
36
|
+
const PS_SCRIPT = `
|
|
37
|
+
$ErrorActionPreference = 'Stop'
|
|
38
|
+
# Silencing the progress bar makes Invoke-WebRequest dramatically faster and
|
|
39
|
+
# keeps our spinner the only thing the user sees.
|
|
40
|
+
$ProgressPreference = 'SilentlyContinue'
|
|
41
|
+
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
|
42
|
+
|
|
43
|
+
$dest = Join-Path $env:LOCALAPPDATA 'flutter'
|
|
44
|
+
$bin = Join-Path $dest 'bin'
|
|
45
|
+
|
|
46
|
+
# 1. Git — Flutter uses it internally and won't run without it.
|
|
47
|
+
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
|
|
48
|
+
if (Get-Command winget -ErrorAction SilentlyContinue) {
|
|
49
|
+
winget install --id Git.Git -e --source winget --silent --accept-source-agreements --accept-package-agreements --disable-interactivity | Out-Null
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# 2. Flutter SDK — download + unzip, unless it's already there.
|
|
54
|
+
if (-not (Test-Path (Join-Path $bin 'flutter.bat'))) {
|
|
55
|
+
if (Test-Path $dest) { Remove-Item -Recurse -Force $dest }
|
|
56
|
+
$json = Invoke-RestMethod 'https://storage.googleapis.com/flutter_infra_release/releases/releases_windows.json'
|
|
57
|
+
$hash = $json.current_release.stable
|
|
58
|
+
$rel = $json.releases | Where-Object { $_.hash -eq $hash } | Select-Object -First 1
|
|
59
|
+
if (-not $rel) { throw 'Could not find the current stable Flutter release.' }
|
|
60
|
+
$url = $json.base_url + '/' + $rel.archive
|
|
61
|
+
$zip = Join-Path $env:TEMP ('flutter_sdk_' + $rel.version + '.zip')
|
|
62
|
+
Invoke-WebRequest -Uri $url -OutFile $zip
|
|
63
|
+
# ZipFile.ExtractToDirectory is much faster than Expand-Archive for a SDK-sized
|
|
64
|
+
# archive. The zip contains a top-level 'flutter\\' folder, so extracting into
|
|
65
|
+
# %LOCALAPPDATA% yields exactly %LOCALAPPDATA%\flutter.
|
|
66
|
+
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
|
67
|
+
[System.IO.Compression.ZipFile]::ExtractToDirectory($zip, $env:LOCALAPPDATA)
|
|
68
|
+
Remove-Item $zip -Force -ErrorAction SilentlyContinue
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# 3. Persist flutter\\bin on the User PATH so every future terminal finds it.
|
|
72
|
+
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
|
|
73
|
+
if (-not $userPath) { $userPath = '' }
|
|
74
|
+
if (($userPath -split ';') -notcontains $bin) {
|
|
75
|
+
$newPath = if ($userPath.Length -gt 0) { $userPath.TrimEnd(';') + ';' + $bin } else { $bin }
|
|
76
|
+
[Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
Write-Output ('FLUTTER_BIN=' + $bin)
|
|
80
|
+
`;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Run the installer. Resolves to the same shape as checks.execTool
|
|
84
|
+
* ({ ok, stdout, stderr, error }) so the caller can treat it uniformly.
|
|
85
|
+
*
|
|
86
|
+
* @param {{ timeout?: number }} [opts]
|
|
87
|
+
* @returns {Promise<{ ok: boolean, stdout: string, stderr: string, error: string|null }>}
|
|
88
|
+
*/
|
|
89
|
+
function installFlutterWindows({ timeout = 1_800_000 } = {}) {
|
|
90
|
+
return new Promise((resolve) => {
|
|
91
|
+
if (!isWindows) {
|
|
92
|
+
resolve({ ok: false, stdout: '', stderr: '', error: 'not windows' });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
// -EncodedCommand (UTF-16LE base64) sidesteps every quoting headache of
|
|
96
|
+
// passing a multi-line script through a shell.
|
|
97
|
+
const encoded = Buffer.from(PS_SCRIPT, 'utf16le').toString('base64');
|
|
98
|
+
const cmd = `powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${encoded}`;
|
|
99
|
+
exec(
|
|
100
|
+
cmd,
|
|
101
|
+
{ timeout, maxBuffer: 50 * 1024 * 1024, windowsHide: true },
|
|
102
|
+
(error, stdout, stderr) => {
|
|
103
|
+
resolve({
|
|
104
|
+
ok: !error,
|
|
105
|
+
stdout: stdout || '',
|
|
106
|
+
stderr: stderr || '',
|
|
107
|
+
error: error ? error.message : null,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = { installFlutterWindows, flutterWinHome };
|
|
@@ -134,6 +134,8 @@ module.exports = {
|
|
|
134
134
|
'checks.thenRun': 'Then run',
|
|
135
135
|
'checks.recheck': 'After installing {name}, press Enter to check again',
|
|
136
136
|
'checks.install.confirm': 'Install {name} now? ({cmd})',
|
|
137
|
+
'checks.install.flutterWinDesc': 'downloads the official Flutter SDK + Git, ~1.8 GB',
|
|
138
|
+
'checks.install.flutterWinNote': 'This can take a few minutes (large download). If a Windows approval popup appears, click Yes.',
|
|
137
139
|
'checks.install.running': 'Installing {name}…',
|
|
138
140
|
'checks.install.failedManual': 'Could not install {name} automatically. Do it manually:',
|
|
139
141
|
'checks.install.openDocs': 'Open the install page in the browser?',
|
|
@@ -134,6 +134,8 @@ module.exports = {
|
|
|
134
134
|
'checks.thenRun': 'Luego ejecuta',
|
|
135
135
|
'checks.recheck': 'Tras instalar {name}, presiona Enter para verificar de nuevo',
|
|
136
136
|
'checks.install.confirm': '¿Instalar {name} ahora? ({cmd})',
|
|
137
|
+
'checks.install.flutterWinDesc': 'descarga el SDK oficial de Flutter + Git, ~1,8 GB',
|
|
138
|
+
'checks.install.flutterWinNote': 'Esto puede tardar unos minutos (descarga grande). Si aparece una ventana de permiso de Windows, haz clic en Sí.',
|
|
137
139
|
'checks.install.running': 'Instalando {name}…',
|
|
138
140
|
'checks.install.failedManual': 'No pude instalar {name} automáticamente. Hazlo manualmente:',
|
|
139
141
|
'checks.install.openDocs': '¿Abrir la página de instalación en el navegador?',
|
|
@@ -134,6 +134,8 @@ module.exports = {
|
|
|
134
134
|
'checks.thenRun': 'Depois rode',
|
|
135
135
|
'checks.recheck': 'Após instalar {name}, pressione Enter para verificar novamente',
|
|
136
136
|
'checks.install.confirm': 'Instalar {name} agora? ({cmd})',
|
|
137
|
+
'checks.install.flutterWinDesc': 'baixa o SDK oficial do Flutter + Git, ~1,8 GB',
|
|
138
|
+
'checks.install.flutterWinNote': 'Pode levar alguns minutos (download grande). Se aparecer um popup do Windows pedindo permissão, clique em Sim.',
|
|
137
139
|
'checks.install.running': 'Instalando {name}…',
|
|
138
140
|
'checks.install.failedManual': 'Não consegui instalar {name} automaticamente. Faça manualmente:',
|
|
139
141
|
'checks.install.openDocs': 'Abrir a página de instalação no navegador?',
|
package/package.json
CHANGED
|
@@ -308,8 +308,15 @@ class _KasySidebarState extends State<KasySidebar> {
|
|
|
308
308
|
setState(() => _userChoseCollapsed = !_userChoseCollapsed);
|
|
309
309
|
|
|
310
310
|
/// Navigates to a real route via Bart and clears any static-item highlight.
|
|
311
|
+
/// Moving to another screen also collapses an open submenu (e.g. Income) and
|
|
312
|
+
/// drops its selected sub-item, so the sidebar always reflects the active
|
|
313
|
+
/// screen rather than a left-over expanded menu.
|
|
311
314
|
void _navigateTo(int index) {
|
|
312
|
-
setState(()
|
|
315
|
+
setState(() {
|
|
316
|
+
_activeItemId = '';
|
|
317
|
+
_incomeExpanded = false;
|
|
318
|
+
_activeSubItem = '';
|
|
319
|
+
});
|
|
313
320
|
widget.onTapItem!(index);
|
|
314
321
|
}
|
|
315
322
|
|
|
@@ -738,6 +745,8 @@ class _KasySidebarState extends State<KasySidebar> {
|
|
|
738
745
|
borderRadius: BorderRadius.circular(_kItemRadius),
|
|
739
746
|
hoverColor: c.activeBg,
|
|
740
747
|
pressColor: c.textActive,
|
|
748
|
+
focusable: true,
|
|
749
|
+
focusGapColor: c.bg,
|
|
741
750
|
onTap: widget.onProfileTap ?? () {},
|
|
742
751
|
child: Padding(
|
|
743
752
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
|
@@ -868,6 +877,8 @@ class _KasySidebarState extends State<KasySidebar> {
|
|
|
868
877
|
borderRadius: BorderRadius.circular(_kItemRadius),
|
|
869
878
|
hoverColor: c.activeBg,
|
|
870
879
|
pressColor: c.textActive,
|
|
880
|
+
focusable: true,
|
|
881
|
+
focusGapColor: c.bg,
|
|
871
882
|
onTap: onTap,
|
|
872
883
|
child: Container(
|
|
873
884
|
constraints: const BoxConstraints(minHeight: _kItemMinH),
|
|
@@ -937,6 +948,8 @@ class _KasySidebarState extends State<KasySidebar> {
|
|
|
937
948
|
borderRadius: BorderRadius.circular(_kItemRadius),
|
|
938
949
|
hoverColor: c.activeBg,
|
|
939
950
|
pressColor: c.textActive,
|
|
951
|
+
focusable: true,
|
|
952
|
+
focusGapColor: c.bg,
|
|
940
953
|
onTap: () => _activateItem(item.id),
|
|
941
954
|
child: Container(
|
|
942
955
|
constraints: const BoxConstraints(minHeight: _kItemMinH),
|
|
@@ -38,6 +38,59 @@ class BottomMenu extends StatelessWidget {
|
|
|
38
38
|
extendBody: true,
|
|
39
39
|
);
|
|
40
40
|
|
|
41
|
+
// Desktop/tablet sidebar, marked as the FIRST stop in the keyboard Tab
|
|
42
|
+
// order. Together with the header/content orders set in WebContentWrapper
|
|
43
|
+
// and the OrderedTraversalPolicy below, every screen tabs the same way:
|
|
44
|
+
// sidebar → header → content. (Touch/mobile has no Tab, so the `small`
|
|
45
|
+
// layout below is left untouched.)
|
|
46
|
+
bart.CustomSideBarOptions connectedSidebar() => bart.CustomSideBarOptions(
|
|
47
|
+
sideBarBuilder: (routes, onTap, current) => FocusTraversalOrder(
|
|
48
|
+
order: const NumericFocusOrder(1),
|
|
49
|
+
child: _FocusableSidebar(
|
|
50
|
+
currentItem: current,
|
|
51
|
+
child: Consumer(
|
|
52
|
+
builder: (context, ref, _) {
|
|
53
|
+
final User user =
|
|
54
|
+
ref.watch(userStateNotifierProvider).user;
|
|
55
|
+
final (String name, String email) = switch (user) {
|
|
56
|
+
final AuthenticatedUserData u => (
|
|
57
|
+
(u.name?.isNotEmpty ?? false)
|
|
58
|
+
? u.name!
|
|
59
|
+
: u.email.split('@').first,
|
|
60
|
+
u.email,
|
|
61
|
+
),
|
|
62
|
+
_ => (context.t.settings.my_account, ''),
|
|
63
|
+
};
|
|
64
|
+
return KasySidebar(
|
|
65
|
+
routes: routes,
|
|
66
|
+
onTapItem: onTap,
|
|
67
|
+
currentItem: current,
|
|
68
|
+
onLogout: () => confirmLogout(context, ref),
|
|
69
|
+
profileName: name,
|
|
70
|
+
profileEmail: email,
|
|
71
|
+
profileAvatar: const KasyUserAvatar(),
|
|
72
|
+
);
|
|
73
|
+
},
|
|
74
|
+
),
|
|
75
|
+
),
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// medium and large are identical here — the sidebar collapses itself by
|
|
80
|
+
// viewport width. The OrderedTraversalPolicy turns the numbered orders
|
|
81
|
+
// (sidebar=1, header=2, content=3) into the actual Tab sequence.
|
|
82
|
+
Widget connectedScaffold() => FocusTraversalGroup(
|
|
83
|
+
policy: OrderedTraversalPolicy(),
|
|
84
|
+
child: bart.BartScaffold(
|
|
85
|
+
routesBuilder: subRoutes,
|
|
86
|
+
bottomBar: kasyPaddedSurfaceBottomBar(),
|
|
87
|
+
initialRoute: resolvedInitialRoute,
|
|
88
|
+
showBottomBarOnStart: showBottomBarOnStart,
|
|
89
|
+
scaffoldOptions: scaffoldOptions,
|
|
90
|
+
sideBarOptions: connectedSidebar(),
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
|
|
41
94
|
return AnnotatedRegion<SystemUiOverlayStyle>(
|
|
42
95
|
value: switch (Theme.brightnessOf(context)) {
|
|
43
96
|
Brightness.dark => SystemUiOverlayStyle.light,
|
|
@@ -51,72 +104,8 @@ class BottomMenu extends StatelessWidget {
|
|
|
51
104
|
showBottomBarOnStart: showBottomBarOnStart,
|
|
52
105
|
scaffoldOptions: scaffoldOptions,
|
|
53
106
|
),
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
routesBuilder: subRoutes,
|
|
57
|
-
bottomBar: kasyPaddedSurfaceBottomBar(),
|
|
58
|
-
initialRoute: resolvedInitialRoute,
|
|
59
|
-
showBottomBarOnStart: showBottomBarOnStart,
|
|
60
|
-
scaffoldOptions: scaffoldOptions,
|
|
61
|
-
sideBarOptions: bart.CustomSideBarOptions(
|
|
62
|
-
sideBarBuilder: (routes, onTap, current) => Consumer(
|
|
63
|
-
builder: (context, ref, _) {
|
|
64
|
-
final User user = ref.watch(userStateNotifierProvider).user;
|
|
65
|
-
final (String name, String email) = switch (user) {
|
|
66
|
-
final AuthenticatedUserData u => (
|
|
67
|
-
(u.name?.isNotEmpty ?? false)
|
|
68
|
-
? u.name!
|
|
69
|
-
: u.email.split('@').first,
|
|
70
|
-
u.email,
|
|
71
|
-
),
|
|
72
|
-
_ => (context.t.settings.my_account, ''),
|
|
73
|
-
};
|
|
74
|
-
return KasySidebar(
|
|
75
|
-
routes: routes,
|
|
76
|
-
onTapItem: onTap,
|
|
77
|
-
currentItem: current,
|
|
78
|
-
onLogout: () => confirmLogout(context, ref),
|
|
79
|
-
profileName: name,
|
|
80
|
-
profileEmail: email,
|
|
81
|
-
profileAvatar: const KasyUserAvatar(),
|
|
82
|
-
);
|
|
83
|
-
},
|
|
84
|
-
),
|
|
85
|
-
),
|
|
86
|
-
),
|
|
87
|
-
// large (1024 px+): full expanded sidebar
|
|
88
|
-
large: bart.BartScaffold(
|
|
89
|
-
routesBuilder: subRoutes,
|
|
90
|
-
bottomBar: kasyPaddedSurfaceBottomBar(),
|
|
91
|
-
initialRoute: resolvedInitialRoute,
|
|
92
|
-
showBottomBarOnStart: showBottomBarOnStart,
|
|
93
|
-
scaffoldOptions: scaffoldOptions,
|
|
94
|
-
sideBarOptions: bart.CustomSideBarOptions(
|
|
95
|
-
sideBarBuilder: (routes, onTap, current) => Consumer(
|
|
96
|
-
builder: (context, ref, _) {
|
|
97
|
-
final User user = ref.watch(userStateNotifierProvider).user;
|
|
98
|
-
final (String name, String email) = switch (user) {
|
|
99
|
-
final AuthenticatedUserData u => (
|
|
100
|
-
(u.name?.isNotEmpty ?? false)
|
|
101
|
-
? u.name!
|
|
102
|
-
: u.email.split('@').first,
|
|
103
|
-
u.email,
|
|
104
|
-
),
|
|
105
|
-
_ => (context.t.settings.my_account, ''),
|
|
106
|
-
};
|
|
107
|
-
return KasySidebar(
|
|
108
|
-
routes: routes,
|
|
109
|
-
onTapItem: onTap,
|
|
110
|
-
currentItem: current,
|
|
111
|
-
onLogout: () => confirmLogout(context, ref),
|
|
112
|
-
profileName: name,
|
|
113
|
-
profileEmail: email,
|
|
114
|
-
profileAvatar: const KasyUserAvatar(),
|
|
115
|
-
);
|
|
116
|
-
},
|
|
117
|
-
),
|
|
118
|
-
),
|
|
119
|
-
),
|
|
107
|
+
medium: connectedScaffold(),
|
|
108
|
+
large: connectedScaffold(),
|
|
120
109
|
),
|
|
121
110
|
);
|
|
122
111
|
}
|
|
@@ -162,3 +151,86 @@ class BottomMenu extends StatelessWidget {
|
|
|
162
151
|
return segments.length < 2;
|
|
163
152
|
}
|
|
164
153
|
}
|
|
154
|
+
|
|
155
|
+
/// Keeps the initial keyboard Tab focus anchored on the sidebar — on every
|
|
156
|
+
/// screen, like Stripe/Linear.
|
|
157
|
+
///
|
|
158
|
+
/// Why this exists: Bart renders each page inside a nested [Navigator]
|
|
159
|
+
/// (see bart's nested_navigator.dart), which has its OWN FocusScope and claims
|
|
160
|
+
/// the primary focus the moment a route mounts. A plain `autofocus` on a sidebar
|
|
161
|
+
/// item loses that race — the Navigator overwrites it in the same frame.
|
|
162
|
+
///
|
|
163
|
+
/// The fix is a tiny non-traversable [Focus] anchor inside the sidebar. A
|
|
164
|
+
/// post-frame callback (runs AFTER the Navigator has claimed focus) moves focus
|
|
165
|
+
/// to that anchor, pulling the primary focus out of the Navigator's scope and
|
|
166
|
+
/// back onto the sidebar. Because the anchor sets `skipTraversal: true`, it is
|
|
167
|
+
/// skipped by Tab, so the very first Tab lands on the first real sidebar item
|
|
168
|
+
/// and then flows on to the header and content — nothing is trapped. (A
|
|
169
|
+
/// [FocusScope] would have worked for the anchoring, but it adds a traversal
|
|
170
|
+
/// boundary that wraps Tab back to the sidebar's start instead of moving on to
|
|
171
|
+
/// the content, which is the opposite of what we want.)
|
|
172
|
+
///
|
|
173
|
+
/// It re-anchors whenever [currentItem] changes (a tab navigation) so a fresh
|
|
174
|
+
/// screen also starts at the sidebar. The ring still only paints during keyboard
|
|
175
|
+
/// navigation, so this is invisible to mouse/touch users.
|
|
176
|
+
class _FocusableSidebar extends StatefulWidget {
|
|
177
|
+
final Widget child;
|
|
178
|
+
final ValueNotifier<int> currentItem;
|
|
179
|
+
|
|
180
|
+
const _FocusableSidebar({required this.child, required this.currentItem});
|
|
181
|
+
|
|
182
|
+
@override
|
|
183
|
+
State<_FocusableSidebar> createState() => _FocusableSidebarState();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
class _FocusableSidebarState extends State<_FocusableSidebar> {
|
|
187
|
+
final FocusNode _anchor = FocusNode(
|
|
188
|
+
debugLabel: 'sidebarFocusAnchor',
|
|
189
|
+
skipTraversal: true,
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
@override
|
|
193
|
+
void initState() {
|
|
194
|
+
super.initState();
|
|
195
|
+
widget.currentItem.addListener(_anchorFocus);
|
|
196
|
+
_anchorFocus();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
@override
|
|
200
|
+
void didUpdateWidget(_FocusableSidebar oldWidget) {
|
|
201
|
+
super.didUpdateWidget(oldWidget);
|
|
202
|
+
if (oldWidget.currentItem != widget.currentItem) {
|
|
203
|
+
oldWidget.currentItem.removeListener(_anchorFocus);
|
|
204
|
+
widget.currentItem.addListener(_anchorFocus);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Defer to after the frame so we win the race against the nested Navigator,
|
|
209
|
+
// which claims focus for its own scope while the route is mounting.
|
|
210
|
+
void _anchorFocus() {
|
|
211
|
+
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
212
|
+
if (mounted) _anchor.requestFocus();
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
@override
|
|
217
|
+
void dispose() {
|
|
218
|
+
widget.currentItem.removeListener(_anchorFocus);
|
|
219
|
+
_anchor.dispose();
|
|
220
|
+
super.dispose();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
@override
|
|
224
|
+
Widget build(BuildContext context) {
|
|
225
|
+
return FocusTraversalGroup(
|
|
226
|
+
// The anchor is a zero-size sibling so it never affects the sidebar's
|
|
227
|
+
// layout; it exists only to hold the initial keyboard focus (see above).
|
|
228
|
+
child: Stack(
|
|
229
|
+
children: [
|
|
230
|
+
widget.child,
|
|
231
|
+
Focus(focusNode: _anchor, child: const SizedBox.shrink()),
|
|
232
|
+
],
|
|
233
|
+
),
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -20,6 +20,9 @@ class WebContentWrapper extends StatelessWidget {
|
|
|
20
20
|
MediaQuery.sizeOf(context).width >= DeviceType.large.breakpoint;
|
|
21
21
|
if (!isDesktop) return child;
|
|
22
22
|
|
|
23
|
+
// Keyboard Tab order across the whole app: sidebar (1, set in BottomMenu) →
|
|
24
|
+
// header (2) → content (3). Each block is its own FocusTraversalGroup so its
|
|
25
|
+
// internal order stays natural (e.g. header: search → theme → … → create).
|
|
23
26
|
return ColoredBox(
|
|
24
27
|
color: context.colors.background,
|
|
25
28
|
child: Column(
|
|
@@ -27,13 +30,23 @@ class WebContentWrapper extends StatelessWidget {
|
|
|
27
30
|
children: [
|
|
28
31
|
// The sidebar carries the user profile here, so the header drops its
|
|
29
32
|
// avatar (showAvatar: false) to avoid duplicating the account chip.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
FocusTraversalOrder(
|
|
34
|
+
order: const NumericFocusOrder(2),
|
|
35
|
+
child: FocusTraversalGroup(
|
|
36
|
+
child: KasyWebHeader(
|
|
37
|
+
onToggleTheme: () => ThemeProvider.of(context).toggle(),
|
|
38
|
+
onNotifications: () {},
|
|
39
|
+
onCreate: () {},
|
|
40
|
+
showAvatar: false,
|
|
41
|
+
),
|
|
42
|
+
),
|
|
43
|
+
),
|
|
44
|
+
Expanded(
|
|
45
|
+
child: FocusTraversalOrder(
|
|
46
|
+
order: const NumericFocusOrder(3),
|
|
47
|
+
child: FocusTraversalGroup(child: child),
|
|
48
|
+
),
|
|
35
49
|
),
|
|
36
|
-
Expanded(child: child),
|
|
37
50
|
],
|
|
38
51
|
),
|
|
39
52
|
);
|
|
@@ -27,12 +27,20 @@ class KasyFocusRing extends StatefulWidget {
|
|
|
27
27
|
/// When false the ring never takes focus or paints (disabled controls).
|
|
28
28
|
final bool enabled;
|
|
29
29
|
|
|
30
|
+
/// Colour of the hair-line gap between the control and the ring. Defaults to
|
|
31
|
+
/// [KasyColors.background] (correct on full-page backgrounds). Pass the actual
|
|
32
|
+
/// surface colour the control sits on (e.g. the sidebar/header `surface`) so
|
|
33
|
+
/// the gap blends in instead of painting a visible halo — this matters in dark
|
|
34
|
+
/// mode, where `background` is darker than `surface`.
|
|
35
|
+
final Color? gapColor;
|
|
36
|
+
|
|
30
37
|
const KasyFocusRing({
|
|
31
38
|
super.key,
|
|
32
39
|
required this.child,
|
|
33
40
|
required this.borderRadius,
|
|
34
41
|
this.onActivate,
|
|
35
42
|
this.enabled = true,
|
|
43
|
+
this.gapColor,
|
|
36
44
|
});
|
|
37
45
|
|
|
38
46
|
@override
|
|
@@ -57,7 +65,7 @@ class _KasyFocusRingState extends State<KasyFocusRing> {
|
|
|
57
65
|
// A hair-line gap in the canvas colour lifts the ring just off the control
|
|
58
66
|
// so it reads as a crisp outline (same focus language as KasyTextField) and
|
|
59
67
|
// stays visible even on primary-coloured buttons (e.g. the chat send orb).
|
|
60
|
-
final Color gapColor = context.colors.background;
|
|
68
|
+
final Color gapColor = widget.gapColor ?? context.colors.background;
|
|
61
69
|
final VoidCallback? onActivate = widget.onActivate;
|
|
62
70
|
|
|
63
71
|
return FocusableActionDetector(
|
|
@@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
|
|
|
6
6
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
7
7
|
import 'package:kasy_kit/core/haptics/haptic_feedback_notifier.dart';
|
|
8
8
|
import 'package:kasy_kit/core/theme/theme.dart';
|
|
9
|
+
import 'package:kasy_kit/core/widgets/kasy_focus_ring.dart';
|
|
9
10
|
|
|
10
11
|
/// Universal hover/press wrapper — works on any widget shape, any platform.
|
|
11
12
|
///
|
|
@@ -48,6 +49,8 @@ class KasyHover extends ConsumerStatefulWidget {
|
|
|
48
49
|
this.pressEnabled = true,
|
|
49
50
|
this.hoverColor,
|
|
50
51
|
this.pressColor,
|
|
52
|
+
this.focusable = false,
|
|
53
|
+
this.focusGapColor,
|
|
51
54
|
});
|
|
52
55
|
|
|
53
56
|
final Widget child;
|
|
@@ -103,6 +106,19 @@ class KasyHover extends ConsumerStatefulWidget {
|
|
|
103
106
|
/// background is already tinted so the feedback stays on-palette.
|
|
104
107
|
final Color? pressColor;
|
|
105
108
|
|
|
109
|
+
/// When true, the control becomes a keyboard tab-stop by wrapping its visual
|
|
110
|
+
/// in the kit's [KasyFocusRing]: a focus ring appears during keyboard
|
|
111
|
+
/// navigation (never on pointer/touch) and Enter/Space triggers [onTap].
|
|
112
|
+
/// Pointer and touch behaviour are unchanged. Defaults to false so existing
|
|
113
|
+
/// call sites stay plain, non-focusable rows.
|
|
114
|
+
final bool focusable;
|
|
115
|
+
|
|
116
|
+
/// Gap colour forwarded to the focus ring (only used when [focusable]). Pass
|
|
117
|
+
/// the surface colour the row sits on so the keyboard ring's hair-line gap
|
|
118
|
+
/// blends in instead of showing a darker halo (notably in dark mode). When
|
|
119
|
+
/// null the ring falls back to [KasyColors.background].
|
|
120
|
+
final Color? focusGapColor;
|
|
121
|
+
|
|
106
122
|
@override
|
|
107
123
|
ConsumerState<KasyHover> createState() => _KasyHoverState();
|
|
108
124
|
}
|
|
@@ -194,13 +210,24 @@ class _KasyHoverState extends ConsumerState<KasyHover> {
|
|
|
194
210
|
child: widget.child,
|
|
195
211
|
);
|
|
196
212
|
|
|
213
|
+
// Keyboard focus is owned by the kit's single focus indicator so Tab +
|
|
214
|
+
// Enter/Space behave identically to login, signup and the chat send button.
|
|
215
|
+
final Widget focusContent = widget.focusable
|
|
216
|
+
? KasyFocusRing(
|
|
217
|
+
borderRadius: widget.borderRadius,
|
|
218
|
+
onActivate: widget.onTap,
|
|
219
|
+
gapColor: widget.focusGapColor,
|
|
220
|
+
child: content,
|
|
221
|
+
)
|
|
222
|
+
: content;
|
|
223
|
+
|
|
197
224
|
Widget interactive = GestureDetector(
|
|
198
225
|
behavior: HitTestBehavior.opaque,
|
|
199
226
|
onTap: _handleTap,
|
|
200
227
|
onTapDown: _onTapDown,
|
|
201
228
|
onTapUp: _onTapUp,
|
|
202
229
|
onTapCancel: _onTapCancel,
|
|
203
|
-
child:
|
|
230
|
+
child: focusContent,
|
|
204
231
|
);
|
|
205
232
|
|
|
206
233
|
// MouseRegion is active on all platforms:
|
|
@@ -5,6 +5,7 @@ import 'package:kasy_kit/components/kasy_card.dart';
|
|
|
5
5
|
import 'package:kasy_kit/core/theme/theme.dart';
|
|
6
6
|
import 'package:kasy_kit/core/widgets/kasy_brand_logo.dart';
|
|
7
7
|
import 'package:kasy_kit/core/widgets/page_background.dart';
|
|
8
|
+
import 'package:kasy_kit/core/widgets/responsive_layout.dart';
|
|
8
9
|
import 'package:kasy_kit/features/authentication/ui/widgets/auth_page_back_button.dart';
|
|
9
10
|
|
|
10
11
|
/// Premium shell shared by the auth screens (sign in / sign up / recover).
|
|
@@ -54,6 +55,49 @@ class AuthCardScaffold extends StatelessWidget {
|
|
|
54
55
|
builder: (context, constraints) {
|
|
55
56
|
final double minHeight = (constraints.maxHeight - padding.vertical)
|
|
56
57
|
.clamp(0.0, double.infinity);
|
|
58
|
+
// On the mobile breakpoint (small, < 768) the card container is
|
|
59
|
+
// dropped: the form sits directly over the page background,
|
|
60
|
+
// edge‑to‑edge minus the page gutter. The elevated card stays on
|
|
61
|
+
// tablet/desktop (medium and up).
|
|
62
|
+
final bool isMobile =
|
|
63
|
+
DeviceType.fromWidth(constraints.maxWidth) == DeviceType.small;
|
|
64
|
+
final Widget content = Column(
|
|
65
|
+
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
66
|
+
mainAxisSize: MainAxisSize.min,
|
|
67
|
+
children: [
|
|
68
|
+
if (showLogo) ...[
|
|
69
|
+
Center(
|
|
70
|
+
child: KasyBrandLogo(height: logoHeight)
|
|
71
|
+
.animate()
|
|
72
|
+
.fadeIn(
|
|
73
|
+
duration: const Duration(milliseconds: 420),
|
|
74
|
+
curve: Curves.easeOut,
|
|
75
|
+
),
|
|
76
|
+
),
|
|
77
|
+
const SizedBox(height: KasySpacing.lg),
|
|
78
|
+
],
|
|
79
|
+
Text(
|
|
80
|
+
title,
|
|
81
|
+
textAlign: TextAlign.center,
|
|
82
|
+
style: context.textTheme.headlineSmall?.copyWith(
|
|
83
|
+
fontWeight: FontWeight.w700,
|
|
84
|
+
),
|
|
85
|
+
),
|
|
86
|
+
const SizedBox(height: KasySpacing.xs),
|
|
87
|
+
Text(
|
|
88
|
+
subtitle,
|
|
89
|
+
textAlign: TextAlign.center,
|
|
90
|
+
style: context.textTheme.bodyMedium?.copyWith(
|
|
91
|
+
color: context.colors.onSurface.withValues(
|
|
92
|
+
alpha: 0.62,
|
|
93
|
+
),
|
|
94
|
+
fontWeight: FontWeight.w500,
|
|
95
|
+
),
|
|
96
|
+
),
|
|
97
|
+
const SizedBox(height: KasySpacing.xl),
|
|
98
|
+
...children,
|
|
99
|
+
],
|
|
100
|
+
);
|
|
57
101
|
return SingleChildScrollView(
|
|
58
102
|
physics: const ClampingScrollPhysics(),
|
|
59
103
|
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
|
@@ -64,49 +108,15 @@ class AuthCardScaffold extends StatelessWidget {
|
|
|
64
108
|
alignment: kIsWeb ? Alignment.center : Alignment.topCenter,
|
|
65
109
|
child: ConstrainedBox(
|
|
66
110
|
constraints: BoxConstraints(maxWidth: maxContentWidth),
|
|
67
|
-
child:
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
74
|
-
mainAxisSize: MainAxisSize.min,
|
|
75
|
-
children: [
|
|
76
|
-
if (showLogo) ...[
|
|
77
|
-
Center(
|
|
78
|
-
child: KasyBrandLogo(height: logoHeight)
|
|
79
|
-
.animate()
|
|
80
|
-
.fadeIn(
|
|
81
|
-
duration: const Duration(milliseconds: 420),
|
|
82
|
-
curve: Curves.easeOut,
|
|
83
|
-
),
|
|
84
|
-
),
|
|
85
|
-
const SizedBox(height: KasySpacing.lg),
|
|
86
|
-
],
|
|
87
|
-
Text(
|
|
88
|
-
title,
|
|
89
|
-
textAlign: TextAlign.center,
|
|
90
|
-
style: context.textTheme.headlineSmall?.copyWith(
|
|
91
|
-
fontWeight: FontWeight.w700,
|
|
111
|
+
child: isMobile
|
|
112
|
+
? content
|
|
113
|
+
: KasyCard(
|
|
114
|
+
padding: const EdgeInsets.symmetric(
|
|
115
|
+
horizontal: KasySpacing.lg,
|
|
116
|
+
vertical: KasySpacing.xl,
|
|
92
117
|
),
|
|
118
|
+
child: content,
|
|
93
119
|
),
|
|
94
|
-
const SizedBox(height: KasySpacing.xs),
|
|
95
|
-
Text(
|
|
96
|
-
subtitle,
|
|
97
|
-
textAlign: TextAlign.center,
|
|
98
|
-
style: context.textTheme.bodyMedium?.copyWith(
|
|
99
|
-
color: context.colors.onSurface.withValues(
|
|
100
|
-
alpha: 0.62,
|
|
101
|
-
),
|
|
102
|
-
fontWeight: FontWeight.w500,
|
|
103
|
-
),
|
|
104
|
-
),
|
|
105
|
-
const SizedBox(height: KasySpacing.xl),
|
|
106
|
-
...children,
|
|
107
|
-
],
|
|
108
|
-
),
|
|
109
|
-
),
|
|
110
120
|
),
|
|
111
121
|
),
|
|
112
122
|
),
|
|
@@ -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+34
|
|
20
20
|
|
|
21
21
|
environment:
|
|
22
22
|
sdk: ^3.11.0
|