kasy-cli 1.15.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. package/lib/commands/icon.js +29 -1
  2. package/lib/commands/run.js +61 -2
  3. package/lib/scaffold/backends/api/pubspec.yaml.tpl +2 -0
  4. package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +2 -0
  5. package/lib/utils/i18n/messages-en.js +7 -0
  6. package/lib/utils/i18n/messages-es.js +7 -0
  7. package/lib/utils/i18n/messages-pt.js +7 -0
  8. package/lib/utils/png-padding.js +134 -2
  9. package/package.json +1 -1
  10. package/templates/firebase/android/app/src/main/res/drawable-hdpi/android12splash.png +0 -0
  11. package/templates/firebase/android/app/src/main/res/drawable-mdpi/android12splash.png +0 -0
  12. package/templates/firebase/android/app/src/main/res/drawable-night-hdpi/android12splash.png +0 -0
  13. package/templates/firebase/android/app/src/main/res/drawable-night-mdpi/android12splash.png +0 -0
  14. package/templates/firebase/android/app/src/main/res/drawable-night-xhdpi/android12splash.png +0 -0
  15. package/templates/firebase/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png +0 -0
  16. package/templates/firebase/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png +0 -0
  17. package/templates/firebase/android/app/src/main/res/drawable-xhdpi/android12splash.png +0 -0
  18. package/templates/firebase/android/app/src/main/res/drawable-xxhdpi/android12splash.png +0 -0
  19. package/templates/firebase/android/app/src/main/res/drawable-xxxhdpi/android12splash.png +0 -0
  20. package/templates/firebase/assets/images/splash_logo_dark_android12.png +0 -0
  21. package/templates/firebase/assets/images/splash_logo_light_android12.png +0 -0
  22. package/templates/firebase/web/index.html +3 -0
@@ -7,11 +7,17 @@ const ui = require('../utils/ui');
7
7
  const { printCompactHeader } = require('../utils/brand');
8
8
  const { createTranslator, detectDefaultLanguage } = require('../utils/i18n');
9
9
  const { inspectPng } = require('./splash');
10
+ const {
11
+ writeAndroidAdaptiveBackground,
12
+ writeTransparentSquare,
13
+ } = require('../utils/png-padding');
10
14
 
11
15
  const execAsync = promisify(exec);
12
16
 
13
17
  const ASSETS_DIR = path.join('assets', 'images');
14
18
  const ICON_NAME = 'icon.png';
19
+ const ANDROID_BG_NAME = 'icon_android.png';
20
+ const ANDROID_FG_EMPTY_NAME = 'icon_foreground_empty.png';
15
21
 
16
22
  async function assertKasyProject(projectDir, t) {
17
23
  const kitSetupPath = path.join(projectDir, 'kit_setup.json');
@@ -85,13 +91,35 @@ async function runIcon(projectDir, options = {}) {
85
91
  ui.note(warnings.map((w) => `${kleur.yellow('⚠')} ${w}`).join('\n'), t('icon.warn.title'));
86
92
  }
87
93
 
88
- const dest = path.join(projectDir, ASSETS_DIR, ICON_NAME);
94
+ const assetsDir = path.join(projectDir, ASSETS_DIR);
95
+ const dest = path.join(assetsDir, ICON_NAME);
96
+ const androidBgDest = path.join(assetsDir, ANDROID_BG_NAME);
97
+ const androidFgEmptyDest = path.join(assetsDir, ANDROID_FG_EMPTY_NAME);
89
98
 
90
99
  const copySpinner = ui.spinner();
91
100
  copySpinner.start(t('icon.copying'));
92
101
  await fs.copy(imagePath, dest, { overwrite: true });
93
102
  copySpinner.stop(t('icon.copied'));
94
103
 
104
+ const adaptiveSpinner = ui.spinner();
105
+ adaptiveSpinner.start(t('icon.adaptive.generating'));
106
+ let adaptiveInfo;
107
+ try {
108
+ adaptiveInfo = await writeAndroidAdaptiveBackground(dest, androidBgDest);
109
+ if (!(await fs.pathExists(androidFgEmptyDest))) {
110
+ await writeTransparentSquare(androidFgEmptyDest);
111
+ }
112
+ adaptiveSpinner.stop(t('icon.adaptive.generated'));
113
+ } catch (err) {
114
+ adaptiveSpinner.stop(`⚠ ${t('icon.adaptive.failed')}`);
115
+ ui.log.message(kleur.dim(err.message || String(err)));
116
+ process.exit(1);
117
+ }
118
+
119
+ if (!adaptiveInfo.color.sampled) {
120
+ ui.note(t('icon.adaptive.fallbackColor'), t('icon.adaptive.fallbackTitle'));
121
+ }
122
+
95
123
  if (options.skipGenerate) {
96
124
  ui.note(t('icon.skipGenerate.hint'), t('icon.skipGenerate.title'));
97
125
  ui.outro(t('icon.done'));
@@ -1,10 +1,52 @@
1
1
  const path = require('node:path');
2
+ const { spawnSync } = require('node:child_process');
2
3
  const fs = require('fs-extra');
3
4
  const kleur = require('kleur');
5
+ const ui = require('../utils/ui');
4
6
  const { createTranslator, detectDefaultLanguage } = require('../utils/i18n');
5
7
  const { printCompactHeader } = require('../utils/brand');
6
8
  const { spawnFlutterWithSpinner } = require('../utils/flutter-run');
7
9
 
10
+ function listFlutterDevices(projectDir) {
11
+ const res = spawnSync('flutter', ['devices', '--machine'], {
12
+ cwd: projectDir,
13
+ encoding: 'utf8',
14
+ });
15
+ if (res.status !== 0) return [];
16
+ try {
17
+ const parsed = JSON.parse(res.stdout);
18
+ return Array.isArray(parsed) ? parsed : [];
19
+ } catch {
20
+ return [];
21
+ }
22
+ }
23
+
24
+ function classifyDevice(device) {
25
+ const platform = (device.targetPlatform || '').toLowerCase();
26
+ if (platform === 'ios') return device.emulator ? 'ios-simulator' : 'ios-device';
27
+ if (platform.startsWith('android')) {
28
+ return device.emulator ? 'android-emulator' : 'android-device';
29
+ }
30
+ if (platform.startsWith('web')) return 'web';
31
+ if (platform.startsWith('darwin')) return 'macos';
32
+ if (platform.startsWith('linux')) return 'linux';
33
+ if (platform.startsWith('windows')) return 'windows';
34
+ return 'unknown';
35
+ }
36
+
37
+ async function pickDevice(devices, t) {
38
+ if (devices.length === 0) return null;
39
+ if (devices.length === 1) return devices[0];
40
+ const choice = await ui.select({
41
+ message: t('run.prompt.pickDevice'),
42
+ options: devices.map((d) => ({
43
+ value: d.id,
44
+ label: `${d.name} ${kleur.dim(`(${classifyDevice(d)})`)}`,
45
+ })),
46
+ });
47
+ return devices.find((d) => d.id === choice) || null;
48
+ }
49
+
8
50
  /**
9
51
  * Read dart-define args from .vscode/launch.json.
10
52
  * Returns the args array from the matching config (dev or prod).
@@ -35,8 +77,11 @@ async function runRun(directory, options = {}) {
35
77
  throw new Error(t('run.error.notFlutterProject'));
36
78
  }
37
79
 
38
- // Resolve device flag
80
+ // Resolve device flag. If none of the platform shortcuts or -d is set,
81
+ // ask the user when more than one device is available — `flutter run`
82
+ // bails out with "More than one device connected" otherwise.
39
83
  const deviceArgs = [];
84
+ let resolvedDeviceLabel = null;
40
85
  if (options.web) {
41
86
  deviceArgs.push('-d', 'chrome');
42
87
  } else if (options.ios) {
@@ -45,6 +90,20 @@ async function runRun(directory, options = {}) {
45
90
  deviceArgs.push('-d', 'android');
46
91
  } else if (options.device) {
47
92
  deviceArgs.push('-d', options.device);
93
+ } else {
94
+ const devices = listFlutterDevices(projectDir);
95
+ if (devices.length > 1) {
96
+ printCompactHeader(t);
97
+ const picked = await pickDevice(devices, t);
98
+ if (!picked) {
99
+ console.log(kleur.yellow(` ⚠ ${t('run.warn.nothingSelected')}`));
100
+ return;
101
+ }
102
+ deviceArgs.push('-d', picked.id);
103
+ resolvedDeviceLabel = `${picked.name} (${picked.id})`;
104
+ }
105
+ // 0 or 1 device → let flutter handle it; it picks the only one or
106
+ // prints its own "no devices" message.
48
107
  }
49
108
 
50
109
  // Read dart-defines from .vscode/launch.json (skip if --no-defines)
@@ -62,7 +121,7 @@ async function runRun(directory, options = {}) {
62
121
  ? 'ios'
63
122
  : options.android
64
123
  ? 'android'
65
- : options.device || null;
124
+ : options.device || resolvedDeviceLabel || null;
66
125
  const summaryParts = [];
67
126
  if (envValue) summaryParts.push(`ENV=${envValue}`);
68
127
  if (deviceLabel) summaryParts.push(`device: ${deviceLabel}`);
@@ -98,6 +98,8 @@ flutter_launcher_icons:
98
98
  android: ic_launcher
99
99
  ios: true
100
100
  remove_alpha_ios: true
101
+ adaptive_icon_background: assets/images/icon_android.png
102
+ adaptive_icon_foreground: assets/images/icon_foreground_empty.png
101
103
  web:
102
104
  generate: true
103
105
  image_path: assets/images/favicon.png
@@ -100,6 +100,8 @@ flutter_launcher_icons:
100
100
  android: ic_launcher
101
101
  ios: true
102
102
  remove_alpha_ios: true
103
+ adaptive_icon_background: assets/images/icon_android.png
104
+ adaptive_icon_foreground: assets/images/icon_foreground_empty.png
103
105
  web:
104
106
  generate: true
105
107
  image_path: assets/images/favicon.png
@@ -645,6 +645,8 @@ module.exports = {
645
645
  // run command
646
646
  'cli.command.run.description': 'Run your app on phone, simulator, or browser',
647
647
  'run.launching': 'Launching Flutter app...',
648
+ 'run.prompt.pickDevice': 'Multiple devices detected. Which one do you want to run on?',
649
+ 'run.warn.nothingSelected': 'No device selected.',
648
650
  'run.updateHint.prefix': 'Project improvements available —',
649
651
  'run.updateHint.suffix': 'to see what\'s new',
650
652
  'run.spinner.building': 'Starting Flutter…',
@@ -746,6 +748,11 @@ module.exports = {
746
748
  'icon.validated': 'PNG looks good',
747
749
  'icon.copying': 'Copying icon to assets/images/icon.png...',
748
750
  'icon.copied': 'Icon copied',
751
+ 'icon.adaptive.generating': 'Generating Android adaptive variant (fills the launcher circle)...',
752
+ 'icon.adaptive.generated': 'Android adaptive icon generated',
753
+ 'icon.adaptive.failed': 'Failed to generate Android adaptive icon',
754
+ 'icon.adaptive.fallbackTitle': 'Android background',
755
+ 'icon.adaptive.fallbackColor': 'Could not sample a background color from the PNG (transparent). Used white. Edit icon_android.png if you want a different color.',
749
756
  'icon.generating': 'Regenerating native icons (Android + iOS)...',
750
757
  'icon.generated': 'Native icons regenerated',
751
758
  'icon.done': 'Done. Uninstall the app from the device and reinstall to see the new icon.',
@@ -678,6 +678,8 @@ module.exports = {
678
678
  'reset.warn.launcherCacheFailed': 'No se pudo limpiar la caché del launcher.',
679
679
  'reset.warn.launcherNotDetected': 'Launcher por defecto no detectado — saltando limpieza de caché.',
680
680
  'run.launching': 'Iniciando app Flutter...',
681
+ 'run.prompt.pickDevice': 'Varios dispositivos detectados. ¿En cuál quieres ejecutar?',
682
+ 'run.warn.nothingSelected': 'Ningún dispositivo seleccionado.',
681
683
  'run.updateHint.prefix': 'Mejoras disponibles para el proyecto —',
682
684
  'run.updateHint.suffix': 'para ver las novedades',
683
685
  'run.spinner.building': 'Iniciando Flutter…',
@@ -744,6 +746,11 @@ module.exports = {
744
746
  'icon.validated': 'PNG está bien',
745
747
  'icon.copying': 'Copiando ícono a assets/images/icon.png...',
746
748
  'icon.copied': 'Ícono copiado',
749
+ 'icon.adaptive.generating': 'Generando variante adaptive para Android (llena el círculo del launcher)...',
750
+ 'icon.adaptive.generated': 'Adaptive icon Android generado',
751
+ 'icon.adaptive.failed': 'Fallo al generar adaptive icon Android',
752
+ 'icon.adaptive.fallbackTitle': 'Fondo Android',
753
+ 'icon.adaptive.fallbackColor': 'No pude muestrear el color de fondo del PNG (transparente). Usé blanco. Edita icon_android.png si quieres otro color.',
747
754
  'icon.generating': 'Regenerando íconos nativos (Android + iOS)...',
748
755
  'icon.generated': 'Íconos nativos regenerados',
749
756
  'icon.done': 'Listo. Desinstala el app del dispositivo y reinstala para ver el nuevo ícono.',
@@ -678,6 +678,8 @@ module.exports = {
678
678
  'reset.warn.launcherCacheFailed': 'Não foi possível limpar o cache do launcher.',
679
679
  'reset.warn.launcherNotDetected': 'Launcher padrão não detectado — pulando limpeza de cache.',
680
680
  'run.launching': 'Iniciando app Flutter...',
681
+ 'run.prompt.pickDevice': 'Vários dispositivos detectados. Em qual deles rodar?',
682
+ 'run.warn.nothingSelected': 'Nenhum dispositivo selecionado.',
681
683
  'run.updateHint.prefix': 'Melhorias disponíveis para o projeto —',
682
684
  'run.updateHint.suffix': 'para ver o que há de novo',
683
685
  'run.spinner.building': 'Iniciando Flutter…',
@@ -744,6 +746,11 @@ module.exports = {
744
746
  'icon.validated': 'PNG está bom',
745
747
  'icon.copying': 'Copiando ícone para assets/images/icon.png...',
746
748
  'icon.copied': 'Ícone copiado',
749
+ 'icon.adaptive.generating': 'Gerando versão adaptive para Android (preenche o círculo do launcher)...',
750
+ 'icon.adaptive.generated': 'Adaptive icon Android gerado',
751
+ 'icon.adaptive.failed': 'Falha ao gerar adaptive icon Android',
752
+ 'icon.adaptive.fallbackTitle': 'Fundo Android',
753
+ 'icon.adaptive.fallbackColor': 'Não consegui amostrar a cor de fundo do PNG (transparente). Usei branco. Se quiser outra cor, edite icon_android.png à mão.',
747
754
  'icon.generating': 'Regenerando ícones nativos (Android + iOS)...',
748
755
  'icon.generated': 'Ícones nativos regenerados',
749
756
  'icon.done': 'Pronto. Desinstale o app do dispositivo e reinstale para ver o novo ícone.',
@@ -1,7 +1,9 @@
1
1
  const fsp = require('node:fs/promises');
2
2
  const { PNG } = require('pngjs');
3
3
 
4
- const ANDROID12_SAFE_RATIO = 0.5;
4
+ const ANDROID12_SAFE_RATIO = 0.4;
5
+ const ANDROID_ADAPTIVE_LOGO_RATIO = 0.65;
6
+ const ANDROID_ADAPTIVE_CANVAS = 1024;
5
7
 
6
8
  async function readPng(filePath) {
7
9
  const buffer = await fsp.readFile(filePath);
@@ -117,4 +119,134 @@ async function writeAndroid12Variant(srcPath, dstPath, safeRatio = ANDROID12_SAF
117
119
  return { canvasSize, logoW, logoH };
118
120
  }
119
121
 
120
- module.exports = { writeAndroid12Variant, ANDROID12_SAFE_RATIO };
122
+ function sampleSolidBackground(png) {
123
+ const inset = Math.max(2, Math.floor(Math.min(png.width, png.height) * 0.02));
124
+ const corners = [
125
+ [inset, inset],
126
+ [png.width - inset - 1, inset],
127
+ [inset, png.height - inset - 1],
128
+ [png.width - inset - 1, png.height - inset - 1],
129
+ ];
130
+
131
+ let r = 0;
132
+ let g = 0;
133
+ let b = 0;
134
+ let opaque = 0;
135
+ for (const [x, y] of corners) {
136
+ const idx = (y * png.width + x) * 4;
137
+ const alpha = png.data[idx + 3];
138
+ if (alpha < 250) continue;
139
+ r += png.data[idx];
140
+ g += png.data[idx + 1];
141
+ b += png.data[idx + 2];
142
+ opaque += 1;
143
+ }
144
+
145
+ if (opaque === 0) {
146
+ return { r: 255, g: 255, b: 255, sampled: false };
147
+ }
148
+ return {
149
+ r: Math.round(r / opaque),
150
+ g: Math.round(g / opaque),
151
+ b: Math.round(b / opaque),
152
+ sampled: true,
153
+ };
154
+ }
155
+
156
+ function fillSolidColor(png, color) {
157
+ for (let i = 0; i < png.data.length; i += 4) {
158
+ png.data[i] = color.r;
159
+ png.data[i + 1] = color.g;
160
+ png.data[i + 2] = color.b;
161
+ png.data[i + 3] = 255;
162
+ }
163
+ }
164
+
165
+ function compositeLogoOnBackground(background, logo) {
166
+ const offsetX = Math.floor((background.width - logo.width) / 2);
167
+ const offsetY = Math.floor((background.height - logo.height) / 2);
168
+
169
+ for (let y = 0; y < logo.height; y++) {
170
+ for (let x = 0; x < logo.width; x++) {
171
+ const srcIdx = (y * logo.width + x) * 4;
172
+ const alpha = logo.data[srcIdx + 3] / 255;
173
+ if (alpha === 0) continue;
174
+
175
+ const dstIdx = ((y + offsetY) * background.width + (x + offsetX)) * 4;
176
+ const inv = 1 - alpha;
177
+ background.data[dstIdx] = Math.round(logo.data[srcIdx] * alpha + background.data[dstIdx] * inv);
178
+ background.data[dstIdx + 1] = Math.round(logo.data[srcIdx + 1] * alpha + background.data[dstIdx + 1] * inv);
179
+ background.data[dstIdx + 2] = Math.round(logo.data[srcIdx + 2] * alpha + background.data[dstIdx + 2] * inv);
180
+ background.data[dstIdx + 3] = 255;
181
+ }
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Generate the Android adaptive icon background asset. Reads the user-supplied
187
+ * icon PNG, samples a solid background color from its corners (falling back to
188
+ * white when the image is transparent), and writes a square canvas with the
189
+ * logo scaled down and centered. The result is what `flutter_launcher_icons`
190
+ * passes to `adaptive_icon_background`, fully filling the launcher's circular
191
+ * mask without the default white halo around square icons.
192
+ *
193
+ * @param {string} srcPath source icon PNG path
194
+ * @param {string} dstPath output path for icon_android.png
195
+ * @param {object} [options]
196
+ * @param {number} [options.canvasSize] output square size (default 1024)
197
+ * @param {number} [options.logoRatio] logo fill ratio inside canvas (default 0.65)
198
+ * @param {string} [options.backgroundColor] explicit hex like "#01171f" to override sampling
199
+ */
200
+ async function writeAndroidAdaptiveBackground(srcPath, dstPath, options = {}) {
201
+ const canvasSize = options.canvasSize || ANDROID_ADAPTIVE_CANVAS;
202
+ const logoRatio = options.logoRatio || ANDROID_ADAPTIVE_LOGO_RATIO;
203
+
204
+ const src = await readPng(srcPath);
205
+
206
+ let color;
207
+ if (options.backgroundColor) {
208
+ const hex = options.backgroundColor.replace('#', '');
209
+ color = {
210
+ r: parseInt(hex.slice(0, 2), 16),
211
+ g: parseInt(hex.slice(2, 4), 16),
212
+ b: parseInt(hex.slice(4, 6), 16),
213
+ sampled: false,
214
+ };
215
+ } else {
216
+ color = sampleSolidBackground(src);
217
+ }
218
+
219
+ const canvas = new PNG({ width: canvasSize, height: canvasSize });
220
+ fillSolidColor(canvas, color);
221
+
222
+ const logoSize = Math.round(canvasSize * logoRatio);
223
+ const resized = resizeBilinear(src, logoSize, logoSize);
224
+ compositeLogoOnBackground(canvas, resized);
225
+
226
+ await writePng(canvas, dstPath);
227
+ return { canvasSize, logoSize, color };
228
+ }
229
+
230
+ /**
231
+ * Write a fully transparent square PNG. Used to satisfy
232
+ * `adaptive_icon_foreground` when the entire image already lives in the
233
+ * background layer.
234
+ *
235
+ * @param {string} dstPath
236
+ * @param {number} [size]
237
+ */
238
+ async function writeTransparentSquare(dstPath, size = ANDROID_ADAPTIVE_CANVAS) {
239
+ const png = new PNG({ width: size, height: size });
240
+ png.data.fill(0);
241
+ await writePng(png, dstPath);
242
+ return { size };
243
+ }
244
+
245
+ module.exports = {
246
+ writeAndroid12Variant,
247
+ writeAndroidAdaptiveBackground,
248
+ writeTransparentSquare,
249
+ ANDROID12_SAFE_RATIO,
250
+ ANDROID_ADAPTIVE_LOGO_RATIO,
251
+ ANDROID_ADAPTIVE_CANVAS,
252
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kasy-cli",
3
- "version": "1.15.0",
3
+ "version": "1.16.0",
4
4
  "description": "CLI for scaffolding production-ready Flutter SaaS apps with Firebase, Supabase, or API REST backends.",
5
5
  "bin": {
6
6
  "kasy": "./bin/kasy.js"
@@ -38,6 +38,8 @@
38
38
 
39
39
 
40
40
 
41
+
42
+
41
43
  <style id="splash-screen-style">
42
44
  html {
43
45
  height: 100%
@@ -120,6 +122,7 @@
120
122
 
121
123
 
122
124
 
125
+
123
126
  <script src="flutter_bootstrap.js" async=""></script>
124
127
  <script src="./local_notifications.js"></script>
125
128