kasy-cli 1.19.1 → 1.19.3

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.
@@ -343,11 +343,6 @@ async function runRun(directory, options = {}) {
343
343
 
344
344
  printCompactHeader(t);
345
345
  console.log(kleur.bold(`${t('run.launching')}${summary}`));
346
- if (isWebServerTarget) {
347
- const url = `http://localhost:${options.webPort || '5555'}`;
348
- console.log(` ${paintLime(`✦ ${t('run.web.open')}: ${url}`)}`);
349
- console.log(kleur.dim(` ${t('run.web.openHint')}`));
350
- }
351
346
  if (rcInfo && rcInfo.mode !== 'legacy') {
352
347
  const mode = (options.rc || 'auto').toLowerCase();
353
348
  let label;
@@ -361,8 +356,22 @@ async function runRun(directory, options = {}) {
361
356
  }
362
357
  console.log(kleur.dim(` ✦ ${t('run.updateHint.prefix')} `) + kleur.cyan('kasy update') + kleur.dim(` ${t('run.updateHint.suffix')}\n`));
363
358
 
359
+ // Print the open-in-browser URL only AFTER Flutter signals "ready" — otherwise
360
+ // the user clicks too early and lands on a blank/splash page because the
361
+ // web-server is still compiling.
362
+ const onReady = isWebServerTarget
363
+ ? () => {
364
+ const url = `http://localhost:${options.webPort || '5555'}`;
365
+ const modifier = process.platform === 'darwin' ? 'Cmd' : 'Ctrl';
366
+ console.log('');
367
+ console.log(` ${paintLime(`✦ ${t('run.web.open')}: ${url}`)}`);
368
+ console.log(kleur.dim(` ${t('run.web.openHint', { modifier })}`));
369
+ console.log('');
370
+ }
371
+ : undefined;
372
+
364
373
  try {
365
- await spawnFlutterWithSpinner(args, projectDir, t, { raw: Boolean(options.raw) });
374
+ await spawnFlutterWithSpinner(args, projectDir, t, { raw: Boolean(options.raw), onReady });
366
375
  } catch (err) {
367
376
  if (err.code === 'ENOENT') {
368
377
  throw new Error(t('run.error.flutterNotFound'));
@@ -125,9 +125,10 @@ function relLogPath(logPath) {
125
125
  function spawnFlutterWithSpinner(args, projectDir, t, options = {}) {
126
126
  const raw = Boolean(options.raw) || !process.stdout.isTTY;
127
127
  const log = openRunLog(projectDir);
128
+ const onReady = typeof options.onReady === 'function' ? options.onReady : null;
128
129
 
129
- if (raw) return spawnRaw(args, projectDir, t, log);
130
- return spawnWithSpinner(args, projectDir, t, log);
130
+ if (raw) return spawnRaw(args, projectDir, t, log, onReady);
131
+ return spawnWithSpinner(args, projectDir, t, log, onReady);
131
132
  }
132
133
 
133
134
  /**
@@ -137,16 +138,24 @@ function spawnFlutterWithSpinner(args, projectDir, t, options = {}) {
137
138
  * Stdin is forwarded directly so hot-reload keys (r/R/q) work without any
138
139
  * raw-mode toggling — the user is already on a "dumb" terminal here.
139
140
  */
140
- function spawnRaw(args, projectDir, t, log) {
141
+ function spawnRaw(args, projectDir, t, log, onReady) {
141
142
  return new Promise((resolve, reject) => {
142
143
  const proc = spawn('flutter', args, {
143
144
  cwd: projectDir,
144
145
  stdio: ['inherit', 'pipe', 'pipe'],
145
146
  });
146
147
 
148
+ let readyFired = false;
149
+ const fireReady = () => {
150
+ if (readyFired || !onReady) return;
151
+ readyFired = true;
152
+ try { onReady(); } catch (_) {}
153
+ };
154
+
147
155
  const tee = (chunk, sink) => {
148
156
  sink.write(chunk);
149
157
  if (log) log.stream.write(stripAnsi(chunk.toString()));
158
+ if (!readyFired && FLUTTER_READY_RE.test(chunk.toString())) fireReady();
150
159
  };
151
160
 
152
161
  proc.stdout.on('data', (c) => tee(c, process.stdout));
@@ -181,7 +190,7 @@ function spawnRaw(args, projectDir, t, log) {
181
190
  * ready. On early exit, the buffer is replayed so the user sees the real
182
191
  * Flutter error.
183
192
  */
184
- function spawnWithSpinner(args, projectDir, t, log) {
193
+ function spawnWithSpinner(args, projectDir, t, log, onReady) {
185
194
  return new Promise((resolve, reject) => {
186
195
  const proc = spawn('flutter', args, {
187
196
  cwd: projectDir,
@@ -208,6 +217,9 @@ function spawnWithSpinner(args, projectDir, t, log) {
208
217
  clearInterval(tick);
209
218
  const total = formatElapsed(startTime);
210
219
  spinner.stop(`${t('run.spinner.ready')} ${kleur.dim(`[${total}]`)}`);
220
+ if (onReady) {
221
+ try { onReady(); } catch (_) {}
222
+ }
211
223
  for (const chunk of buffer) process.stdout.write(chunk);
212
224
  buffer.length = 0;
213
225
  // Pipe stdin so the user can type r / R / q to control Flutter.
@@ -717,7 +717,7 @@ module.exports = {
717
717
  'cli.command.run.description': 'Run your app on phone, simulator, or browser',
718
718
  'run.launching': 'Launching Flutter app...',
719
719
  'run.web.open': 'Open in your browser',
720
- 'run.web.openHint': 'Cmd+click the link above (or copy/paste it). Use --open to auto-launch a dedicated Flutter Chrome window instead.',
720
+ 'run.web.openHint': '{modifier}+click the link above (or copy/paste it). Use --open to auto-launch a dedicated Flutter Chrome window instead.',
721
721
  'run.prompt.pickDevice': 'Multiple devices detected. Which one do you want to run on?',
722
722
  'run.warn.nothingSelected': 'No device selected.',
723
723
  'run.updateHint.prefix': 'Project improvements available —',
@@ -750,7 +750,7 @@ module.exports = {
750
750
  'reset.warn.launcherNotDetected': 'Launcher por defecto no detectado — saltando limpieza de caché.',
751
751
  'run.launching': 'Iniciando app Flutter...',
752
752
  'run.web.open': 'Abre en tu navegador',
753
- 'run.web.openHint': 'Cmd+clic en el enlace de arriba (o copia/pega). Usa --open para abrir automáticamente una ventana dedicada de Chrome de Flutter.',
753
+ 'run.web.openHint': '{modifier}+clic en el enlace de arriba (o copia/pega). Usa --open para abrir automáticamente una ventana dedicada de Chrome de Flutter.',
754
754
  'run.prompt.pickDevice': 'Varios dispositivos detectados. ¿En cuál quieres ejecutar?',
755
755
  'run.warn.nothingSelected': 'Ningún dispositivo seleccionado.',
756
756
  'run.updateHint.prefix': 'Mejoras disponibles para el proyecto —',
@@ -750,7 +750,7 @@ module.exports = {
750
750
  'reset.warn.launcherNotDetected': 'Launcher padrão não detectado — pulando limpeza de cache.',
751
751
  'run.launching': 'Iniciando app Flutter...',
752
752
  'run.web.open': 'Abra no seu navegador',
753
- 'run.web.openHint': 'Cmd+clique no link acima (ou copie e cole). Use --open pra abrir automaticamente num Chrome dedicado do Flutter.',
753
+ 'run.web.openHint': '{modifier}+clique no link acima (ou copie e cole). Use --open pra abrir automaticamente num Chrome dedicado do Flutter.',
754
754
  'run.prompt.pickDevice': 'Vários dispositivos detectados. Em qual deles rodar?',
755
755
  'run.warn.nothingSelected': 'Nenhum dispositivo selecionado.',
756
756
  'run.updateHint.prefix': 'Melhorias disponíveis para o projeto —',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kasy-cli",
3
- "version": "1.19.1",
3
+ "version": "1.19.3",
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"
@@ -428,15 +428,22 @@ class _KasyTextFieldState extends State<KasyTextField> {
428
428
  ),
429
429
  )
430
430
  : null);
431
+ // Resolved text color for the value rendered inside the field. We bake
432
+ // the disabled fade directly into the color (rather than relying on the
433
+ // Material TextField's own disabled handling) so the disabled state is
434
+ // visually identical across native, web and DatePicker contexts. Without
435
+ // this explicit color, Material would render the value at full opacity
436
+ // because [style] takes precedence over the framework's disabled fade.
437
+ final Color fieldTextColor = isDisabled
438
+ ? context.colors.onSurface.withValues(alpha: disabledTextOpacity)
439
+ : context.colors.onSurface;
431
440
  final TextStyle fieldTextStyle =
432
441
  context.textTheme.bodyLarge?.copyWith(
433
- color: context.colors.onSurface.withValues(
434
- alpha: isDisabled ? disabledTextOpacity : 1,
435
- ),
442
+ color: fieldTextColor,
436
443
  fontWeight: FontWeight.w400,
437
444
  fontSize: 15,
438
445
  ) ??
439
- const TextStyle(fontSize: 15);
446
+ TextStyle(fontSize: 15, color: fieldTextColor);
440
447
  final InputDecoration decoration = InputDecoration(
441
448
  isDense: true,
442
449
  hintText: widget.hint,
@@ -87,6 +87,7 @@ class _DevInspectorState extends State<DevInspector>
87
87
  RenderObject? _selectedRender;
88
88
  Rect? _highlightRect;
89
89
  Ticker? _highlightTicker;
90
+ final GlobalKey _overlayKey = GlobalKey(debugLabel: 'devInspectorOverlay');
90
91
 
91
92
  @override
92
93
  void initState() {
@@ -239,12 +240,26 @@ class _DevInspectorState extends State<DevInspector>
239
240
  setState(() {
240
241
  _selectedInfo = picked.info;
241
242
  _selectedRender = picked.renderObject;
242
- _highlightRect = picked.info.boundingBox;
243
+ _highlightRect = _toOverlayLocal(picked.info.boundingBox);
243
244
  });
244
245
  _startHighlightTicker();
245
246
  HapticFeedback.selectionClick();
246
247
  }
247
248
 
249
+ /// Converts a rect expressed in the root view's coordinate space into the
250
+ /// overlay's local space, so it draws correctly even when the overlay sits
251
+ /// inside a transformed parent (e.g. WebDevicePreview's device frame).
252
+ Rect? _toOverlayLocal(Rect rootRect) {
253
+ final RenderObject? renderObject =
254
+ _overlayKey.currentContext?.findRenderObject();
255
+ if (renderObject is! RenderBox) return rootRect;
256
+ if (!renderObject.attached) return rootRect;
257
+ final Offset topLeft = renderObject.globalToLocal(rootRect.topLeft);
258
+ final Offset bottomRight =
259
+ renderObject.globalToLocal(rootRect.bottomRight);
260
+ return Rect.fromPoints(topLeft, bottomRight);
261
+ }
262
+
248
263
  void _startHighlightTicker() {
249
264
  _highlightTicker ??= createTicker(_onHighlightTick);
250
265
  if (!_highlightTicker!.isActive) _highlightTicker!.start();
@@ -262,8 +277,9 @@ class _DevInspectorState extends State<DevInspector>
262
277
  setState(_clearSelection);
263
278
  return;
264
279
  }
265
- if (_highlightRect != rect) {
266
- setState(() => _highlightRect = rect);
280
+ final Rect? local = _toOverlayLocal(rect);
281
+ if (_highlightRect != local) {
282
+ setState(() => _highlightRect = local);
267
283
  }
268
284
  }
269
285
 
@@ -378,6 +394,7 @@ class _DevInspectorState extends State<DevInspector>
378
394
  _onInspectorTap(ev.position),
379
395
  child: IgnorePointer(
380
396
  child: CustomPaint(
397
+ key: _overlayKey,
381
398
  painter: _HighlightPainter(rect: _highlightRect),
382
399
  size: Size.infinite,
383
400
  ),
@@ -1,4 +1,5 @@
1
1
  import 'package:bart/bart/bart_model.dart';
2
+ import 'package:flutter/foundation.dart' show kIsWeb;
2
3
  import 'package:flutter/material.dart';
3
4
  import 'package:flutter/services.dart';
4
5
  import 'package:kasy_kit/components/components.dart';
@@ -8693,6 +8694,13 @@ class _DatePickerMultiMonthPreview extends StatefulWidget {
8693
8694
  _DatePickerMultiMonthPreviewState();
8694
8695
  }
8695
8696
 
8697
+ // Breakpoints for the multi-month preview. Below the 2-month threshold the
8698
+ // preview shows an explanation card instead of the calendars — picking dates
8699
+ // across 2 or 3 grids only makes sense on canvases wide enough to lay them
8700
+ // out side by side.
8701
+ const double _kMultiMonthMinWidth2 = 620;
8702
+ const double _kMultiMonthMinWidth3 = 920;
8703
+
8696
8704
  class _DatePickerMultiMonthPreviewState
8697
8705
  extends State<_DatePickerMultiMonthPreview> {
8698
8706
  KasyDateRange? _stay;
@@ -8700,24 +8708,38 @@ class _DatePickerMultiMonthPreviewState
8700
8708
 
8701
8709
  @override
8702
8710
  Widget build(BuildContext context) {
8711
+ // Multi-month is a desktop/wide-tablet pattern. On native iOS/Android and
8712
+ // narrow web viewports we don't have horizontal room to lay 2–3 month
8713
+ // grids side by side, so the preview shows a notice instead.
8714
+ final double width = MediaQuery.sizeOf(context).width;
8715
+ final bool fitsTwoMonths = kIsWeb && width >= _kMultiMonthMinWidth2;
8716
+ final bool fitsThreeMonths = kIsWeb && width >= _kMultiMonthMinWidth3;
8717
+
8718
+ if (!fitsTwoMonths) {
8719
+ return _MultiMonthUnavailableNotice();
8720
+ }
8721
+
8703
8722
  final KasyDatePickerLocale locale = _datePickerLocaleForApp(context);
8704
8723
  return Column(
8705
8724
  mainAxisSize: MainAxisSize.min,
8706
8725
  crossAxisAlignment: CrossAxisAlignment.stretch,
8707
8726
  children: [
8708
- // 3-month layout combined with range selection — the classic
8709
- // Airbnb / Booking desktop pattern for picking a stay.
8710
- KasyDatePicker(
8711
- label: '3 months + range',
8712
- placeholder: 'Check-in -> Check-out',
8713
- description: 'Pass monthsToShow: 3 to opt in. Best on wide canvases.',
8714
- selectionMode: KasyDateSelectionMode.range,
8715
- range: _stay,
8716
- onRangeChanged: (r) => setState(() => _stay = r),
8717
- monthsToShow: 3,
8718
- locale: locale,
8719
- ),
8720
- const SizedBox(height: KasySpacing.lg),
8727
+ if (fitsThreeMonths) ...[
8728
+ // 3-month layout combined with range selection the classic
8729
+ // Airbnb / Booking desktop pattern for picking a stay.
8730
+ KasyDatePicker(
8731
+ label: '3 months + range',
8732
+ placeholder: 'Check-in -> Check-out',
8733
+ description:
8734
+ 'Pass monthsToShow: 3 to opt in. Best on wide canvases.',
8735
+ selectionMode: KasyDateSelectionMode.range,
8736
+ range: _stay,
8737
+ onRangeChanged: (r) => setState(() => _stay = r),
8738
+ monthsToShow: 3,
8739
+ locale: locale,
8740
+ ),
8741
+ const SizedBox(height: KasySpacing.lg),
8742
+ ],
8721
8743
  // 2-month layout in single-date mode — useful when there's enough
8722
8744
  // horizontal room but you still want one tap to commit.
8723
8745
  KasyDatePicker(
@@ -8733,6 +8755,52 @@ class _DatePickerMultiMonthPreviewState
8733
8755
  }
8734
8756
  }
8735
8757
 
8758
+ /// Friendly placeholder shown when the multi-month preview is opened on a
8759
+ /// viewport too narrow for the layout (native mobile, narrow web). Surfacing
8760
+ /// this instead of hiding the variant keeps the design-system docs honest:
8761
+ /// the feature exists, it just isn't appropriate on small canvases.
8762
+ class _MultiMonthUnavailableNotice extends StatelessWidget {
8763
+ @override
8764
+ Widget build(BuildContext context) {
8765
+ final KasyColors c = context.colors;
8766
+ return Container(
8767
+ padding: const EdgeInsets.all(KasySpacing.lg),
8768
+ decoration: BoxDecoration(
8769
+ color: c.avatarFallbackFill,
8770
+ borderRadius: BorderRadius.circular(KasyRadius.md),
8771
+ ),
8772
+ child: Column(
8773
+ crossAxisAlignment: CrossAxisAlignment.start,
8774
+ mainAxisSize: MainAxisSize.min,
8775
+ children: [
8776
+ Row(
8777
+ children: [
8778
+ Icon(KasyIcons.info, size: 18, color: c.primary),
8779
+ const SizedBox(width: KasySpacing.sm),
8780
+ Text(
8781
+ 'Multi-month works best on wide screens',
8782
+ style: context.textTheme.labelLarge?.copyWith(
8783
+ fontWeight: FontWeight.w700,
8784
+ color: c.onSurface,
8785
+ ),
8786
+ ),
8787
+ ],
8788
+ ),
8789
+ const SizedBox(height: KasySpacing.sm),
8790
+ Text(
8791
+ 'The 2 and 3-month layouts need room to render side by side, so '
8792
+ 'they are previewed only on web at tablet width or larger. On '
8793
+ 'mobile breakpoints and native iOS/Android the picker stays at '
8794
+ 'a single month — opt in by passing monthsToShow: 2 or 3 from '
8795
+ 'your own desktop screens.',
8796
+ style: context.textTheme.bodyMedium?.copyWith(color: c.muted),
8797
+ ),
8798
+ ],
8799
+ ),
8800
+ );
8801
+ }
8802
+ }
8803
+
8736
8804
  // ─────────────────────────────────────────────────────────────────────────────
8737
8805
  // DatePicker — Variants (filled, no backdrop, no suffix, no focus border)
8738
8806
  // ─────────────────────────────────────────────────────────────────────────────