kasy-cli 1.17.0 → 1.19.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 (110) hide show
  1. package/bin/kasy.js +16 -2
  2. package/lib/commands/add.js +7 -7
  3. package/lib/commands/configure.js +548 -0
  4. package/lib/commands/deploy.js +4 -4
  5. package/lib/commands/doctor.js +17 -0
  6. package/lib/commands/favicon.js +4 -4
  7. package/lib/commands/icon.js +5 -5
  8. package/lib/commands/new.js +483 -324
  9. package/lib/commands/run.js +17 -4
  10. package/lib/commands/splash.js +5 -5
  11. package/lib/commands/update.js +9 -9
  12. package/lib/scaffold/CHANGELOG.json +14 -0
  13. package/lib/scaffold/backends/firebase/enable-auth-via-cli.js +108 -0
  14. package/lib/scaffold/backends/firebase/setup-from-scratch.js +123 -5
  15. package/lib/scaffold/generate.js +24 -8
  16. package/lib/scaffold/shared/post-build.js +8 -0
  17. package/lib/utils/brand.js +16 -12
  18. package/lib/utils/flutter-run.js +139 -11
  19. package/lib/utils/i18n/messages-en.js +62 -5
  20. package/lib/utils/i18n/messages-es.js +62 -5
  21. package/lib/utils/i18n/messages-pt.js +63 -6
  22. package/lib/utils/ui.js +79 -4
  23. package/package.json +1 -2
  24. package/templates/firebase/README.en.md +1 -1
  25. package/templates/firebase/README.es.md +1 -1
  26. package/templates/firebase/README.md +1 -1
  27. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MainActivity.kt +0 -15
  28. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MyWidget.kt +65 -26
  29. package/templates/firebase/android/app/src/main/res/drawable/widget_add_button.xml +2 -2
  30. package/templates/firebase/android/app/src/main/res/drawable/widget_gradient_bg.xml +7 -2
  31. package/templates/firebase/android/app/src/main/res/drawable/widget_gradient_inner.xml +6 -6
  32. package/templates/firebase/android/app/src/main/res/drawable/widget_plan_pill_bg.xml +1 -1
  33. package/templates/firebase/android/app/src/main/res/drawable/widget_preview_image.xml +2 -2
  34. package/templates/firebase/android/app/src/main/res/drawable/widget_pro_pill_bg.xml +1 -1
  35. package/templates/firebase/android/app/src/main/res/drawable-hdpi/android12splash.png +0 -0
  36. package/templates/firebase/android/app/src/main/res/drawable-hdpi/splash.png +0 -0
  37. package/templates/firebase/android/app/src/main/res/drawable-mdpi/android12splash.png +0 -0
  38. package/templates/firebase/android/app/src/main/res/drawable-mdpi/splash.png +0 -0
  39. package/templates/firebase/android/app/src/main/res/drawable-night-hdpi/android12splash.png +0 -0
  40. package/templates/firebase/android/app/src/main/res/drawable-night-hdpi/splash.png +0 -0
  41. package/templates/firebase/android/app/src/main/res/drawable-night-mdpi/android12splash.png +0 -0
  42. package/templates/firebase/android/app/src/main/res/drawable-night-mdpi/splash.png +0 -0
  43. package/templates/firebase/android/app/src/main/res/drawable-night-xhdpi/android12splash.png +0 -0
  44. package/templates/firebase/android/app/src/main/res/drawable-night-xhdpi/splash.png +0 -0
  45. package/templates/firebase/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png +0 -0
  46. package/templates/firebase/android/app/src/main/res/drawable-night-xxhdpi/splash.png +0 -0
  47. package/templates/firebase/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png +0 -0
  48. package/templates/firebase/android/app/src/main/res/drawable-night-xxxhdpi/splash.png +0 -0
  49. package/templates/firebase/android/app/src/main/res/drawable-xhdpi/android12splash.png +0 -0
  50. package/templates/firebase/android/app/src/main/res/drawable-xhdpi/splash.png +0 -0
  51. package/templates/firebase/android/app/src/main/res/drawable-xxhdpi/android12splash.png +0 -0
  52. package/templates/firebase/android/app/src/main/res/drawable-xxhdpi/splash.png +0 -0
  53. package/templates/firebase/android/app/src/main/res/drawable-xxxhdpi/android12splash.png +0 -0
  54. package/templates/firebase/android/app/src/main/res/drawable-xxxhdpi/splash.png +0 -0
  55. package/templates/firebase/android/app/src/main/res/layout/widget_preview.xml +3 -3
  56. package/templates/firebase/android/app/src/main/res/values/colors.xml +32 -0
  57. package/templates/firebase/assets/images/splash_logo_dark.png +0 -0
  58. package/templates/firebase/assets/images/splash_logo_dark_android12.png +0 -0
  59. package/templates/firebase/assets/images/splash_logo_light.png +0 -0
  60. package/templates/firebase/assets/images/splash_logo_light_android12.png +0 -0
  61. package/templates/firebase/ios/HomeWidgetExtension/MyWidget.swift +75 -29
  62. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png +0 -0
  63. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png +0 -0
  64. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png +0 -0
  65. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark.png +0 -0
  66. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@2x.png +0 -0
  67. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@3x.png +0 -0
  68. package/templates/firebase/ios/Runner/Base.lproj/LaunchScreen.storyboard +1 -1
  69. package/templates/firebase/lib/components/components.dart +1 -0
  70. package/templates/firebase/lib/components/kasy_avatar.dart +65 -17
  71. package/templates/firebase/lib/components/kasy_avatar_presets.dart +121 -97
  72. package/templates/firebase/lib/components/kasy_button.dart +8 -8
  73. package/templates/firebase/lib/components/kasy_date_picker.dart +2173 -0
  74. package/templates/firebase/lib/components/kasy_tabs.dart +214 -91
  75. package/templates/firebase/lib/components/kasy_text_area.dart +9 -4
  76. package/templates/firebase/lib/components/kasy_text_field.dart +96 -36
  77. package/templates/firebase/lib/components/kasy_text_field_otp.dart +1 -2
  78. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +88 -35
  79. package/templates/firebase/lib/core/bottom_menu/bottom_router.dart +7 -43
  80. package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +118 -16
  81. package/templates/firebase/lib/core/dev_inspector/dev_inspector_service.dart +14 -20
  82. package/templates/firebase/lib/core/home_widgets/home_widget_mywidget_service.dart +12 -18
  83. package/templates/firebase/lib/core/security/secured_storage.dart +56 -15
  84. package/templates/firebase/lib/core/theme/providers/theme_provider.dart +3 -0
  85. package/templates/firebase/lib/core/theme/web_background_sync.dart +3 -0
  86. package/templates/firebase/lib/core/theme/web_background_sync_web.dart +18 -0
  87. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +3 -6
  88. package/templates/firebase/lib/features/authentication/api/authentication_api.dart +6 -0
  89. package/templates/firebase/lib/features/home/home_components_page.dart +3 -2
  90. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +949 -77
  91. package/templates/firebase/lib/features/home/home_page.dart +17 -40
  92. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +1 -16
  93. package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +0 -4
  94. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +10 -5
  95. package/templates/firebase/lib/i18n/en.i18n.json +2 -1
  96. package/templates/firebase/lib/i18n/es.i18n.json +2 -1
  97. package/templates/firebase/lib/i18n/pt.i18n.json +2 -1
  98. package/templates/firebase/lib/main.dart +34 -34
  99. package/templates/firebase/pubspec.yaml +2 -1
  100. package/templates/firebase/storage.cors.json +8 -0
  101. package/templates/firebase/web/index.html +24 -2
  102. package/templates/firebase/web/splash/img/dark-1x.png +0 -0
  103. package/templates/firebase/web/splash/img/dark-2x.png +0 -0
  104. package/templates/firebase/web/splash/img/dark-3x.png +0 -0
  105. package/templates/firebase/web/splash/img/dark-4x.png +0 -0
  106. package/templates/firebase/web/splash/img/light-1x.png +0 -0
  107. package/templates/firebase/web/splash/img/light-2x.png +0 -0
  108. package/templates/firebase/web/splash/img/light-3x.png +0 -0
  109. package/templates/firebase/web/splash/img/light-4x.png +0 -0
  110. package/templates/firebase/lib/core/bottom_menu/kasy_bart_navigation.dart +0 -22
@@ -33,7 +33,7 @@ enum KasyTabsVariant {
33
33
 
34
34
  /// Sizing mode for [KasyTabs].
35
35
  enum KasyTabsMode {
36
- /// Wraps content (intrinsic width tabs).
36
+ /// Wraps content (intrinsic width tabs). Scrolls horizontally if needed.
37
37
  hug,
38
38
 
39
39
  /// Each tab stretches to fill the available width equally.
@@ -99,6 +99,15 @@ class _KasyTabsState extends State<KasyTabs> {
99
99
  // One GlobalKey per tab to measure position/size after layout.
100
100
  late List<GlobalKey> _keys;
101
101
 
102
+ // Key for the inner indicator Stack — used as the coordinate reference
103
+ // for measurements, ensuring correctness even when wrapped in a ScrollView.
104
+ final GlobalKey _stackKey = GlobalKey();
105
+
106
+ // Controls horizontal scrolling in hug mode so we can snap to the
107
+ // beginning/end when the first/last tab is selected (clears container
108
+ // padding and the pill overflow that extends 4px past tab edges).
109
+ final ScrollController _scrollController = ScrollController();
110
+
102
111
  // Indicator geometry (left offset, width) resolved from measured keys.
103
112
  double _indicatorLeft = 0;
104
113
  double _indicatorWidth = 0;
@@ -116,6 +125,12 @@ class _KasyTabsState extends State<KasyTabs> {
116
125
  WidgetsBinding.instance.addPostFrameCallback((_) => _measure());
117
126
  }
118
127
 
128
+ @override
129
+ void dispose() {
130
+ _scrollController.dispose();
131
+ super.dispose();
132
+ }
133
+
119
134
  @override
120
135
  void didUpdateWidget(KasyTabs old) {
121
136
  super.didUpdateWidget(old);
@@ -140,8 +155,10 @@ class _KasyTabsState extends State<KasyTabs> {
140
155
  widget.items.length - 1,
141
156
  );
142
157
 
158
+ // Measure relative to the inner Stack, not the outermost widget.
159
+ // This stays correct even when the component is inside a ScrollView.
143
160
  final RenderBox? containerBox =
144
- context.findRenderObject() as RenderBox?;
161
+ _stackKey.currentContext?.findRenderObject() as RenderBox?;
145
162
  if (containerBox == null) return;
146
163
 
147
164
  final GlobalKey key = _keys[clampedIndex];
@@ -166,6 +183,34 @@ class _KasyTabsState extends State<KasyTabs> {
166
183
  _measured = true;
167
184
  });
168
185
  }
186
+
187
+ // In hug mode, scroll the selected tab into view (handles overflow).
188
+ if (widget.mode == KasyTabsMode.hug) {
189
+ _ensureSelectedVisible(clampedIndex);
190
+ }
191
+ }
192
+
193
+ /// Scrolls the [SingleChildScrollView] so the selected tab is fully visible.
194
+ ///
195
+ /// First/last tab snap to the scroll extremes so the container's outer
196
+ /// padding (and the pill that extends 4px beyond tab edges) is never clipped.
197
+ void _ensureSelectedVisible(int index) {
198
+ const Duration duration = Duration(milliseconds: 250);
199
+ const Curve curve = Curves.easeInOut;
200
+ final int lastIndex = widget.items.length - 1;
201
+
202
+ if (_scrollController.hasClients &&
203
+ (index == 0 || index == lastIndex)) {
204
+ final double target = index == 0
205
+ ? _scrollController.position.minScrollExtent
206
+ : _scrollController.position.maxScrollExtent;
207
+ _scrollController.animateTo(target, duration: duration, curve: curve);
208
+ return;
209
+ }
210
+
211
+ final BuildContext? ctx = _keys[index].currentContext;
212
+ if (ctx == null) return;
213
+ Scrollable.ensureVisible(ctx, duration: duration, curve: curve);
169
214
  }
170
215
 
171
216
  @override
@@ -182,67 +227,99 @@ class _KasyTabsState extends State<KasyTabs> {
182
227
  Widget _buildPrimary(BuildContext context) {
183
228
  final KasyColors c = context.colors;
184
229
  final bool isDark = context.isDark;
185
-
186
- return DecoratedBox(
187
- decoration: BoxDecoration(
188
- color: c.avatarFallbackFill,
189
- borderRadius: BorderRadius.circular(KasyRadius.full),
190
- ),
191
- child: Padding(
192
- padding: const EdgeInsets.all(4),
193
- child: Stack(
194
- children: [
195
- // Animated pill background.
196
- if (_measured)
197
- AnimatedPositioned(
198
- duration: const Duration(milliseconds: 250),
199
- curve: Curves.easeInOut,
200
- left: _indicatorLeft,
201
- top: 0,
202
- bottom: 0,
203
- width: _indicatorWidth,
204
- child: DecoratedBox(
205
- decoration: BoxDecoration(
206
- color: c.surface,
207
- borderRadius: BorderRadius.circular(KasyRadius.full),
208
- boxShadow: [
209
- BoxShadow(
210
- color: Colors.black.withValues(
211
- alpha: isDark ? 0.18 : 0.08,
212
- ),
213
- blurRadius: 8,
214
- offset: const Offset(0, 2),
215
- ),
216
- BoxShadow(
217
- color: Colors.black.withValues(
218
- alpha: isDark ? 0.10 : 0.04,
219
- ),
220
- spreadRadius: 1,
230
+ final bool isFill = widget.mode == KasyTabsMode.fill;
231
+ final BorderRadius pillRadius = BorderRadius.circular(KasyRadius.full);
232
+
233
+ final Widget tabsContent = Padding(
234
+ // 8px horizontal, 4px vertical — per Figma spec.
235
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
236
+ child: Stack(
237
+ key: _stackKey,
238
+ // Allow pill to extend 4px beyond each tab edge (Figma: inset 0 -4px).
239
+ clipBehavior: Clip.none,
240
+ children: [
241
+ // Animated pill background.
242
+ if (_measured)
243
+ AnimatedPositioned(
244
+ duration: const Duration(milliseconds: 250),
245
+ curve: Curves.easeInOut,
246
+ // Extends 4px on each side beyond the measured tab.
247
+ left: _indicatorLeft - 4,
248
+ top: 0,
249
+ bottom: 0,
250
+ width: _indicatorWidth + 8,
251
+ child: DecoratedBox(
252
+ decoration: BoxDecoration(
253
+ color: c.surface,
254
+ borderRadius: BorderRadius.circular(KasyRadius.full),
255
+ boxShadow: [
256
+ BoxShadow(
257
+ color: Colors.black.withValues(
258
+ alpha: isDark ? 0.14 : 0.06,
221
259
  ),
222
- ],
223
- ),
260
+ blurRadius: 8,
261
+ offset: const Offset(0, 2),
262
+ ),
263
+ ],
224
264
  ),
225
265
  ),
226
- // Tab labels (on top of the pill).
227
- Row(
228
- mainAxisSize: widget.mode == KasyTabsMode.fill
229
- ? MainAxisSize.max
230
- : MainAxisSize.min,
231
- children: List.generate(widget.items.length, (i) {
232
- final KasyTabItem item = widget.items[i];
233
- final bool selected = i == widget.selectedIndex;
234
- return _PrimaryTab(
235
- key: _keys[i],
236
- item: item,
237
- selected: selected,
238
- expand: widget.mode == KasyTabsMode.fill,
239
- onTap: item.enabled ? () => widget.onTabSelected(i) : null,
240
- );
241
- }),
242
266
  ),
243
- ],
267
+ // Tab labels (on top of the pill).
268
+ Row(
269
+ mainAxisSize: isFill ? MainAxisSize.max : MainAxisSize.min,
270
+ children: [
271
+ for (int i = 0; i < widget.items.length; i++) ...[
272
+ // 2px gap between adjacent tabs — per Figma spec.
273
+ if (i > 0) const SizedBox(width: 2),
274
+ _PrimaryTab(
275
+ key: _keys[i],
276
+ item: widget.items[i],
277
+ selected: i == widget.selectedIndex,
278
+ expand: isFill,
279
+ onTap: widget.items[i].enabled
280
+ ? () => widget.onTabSelected(i)
281
+ : null,
282
+ ),
283
+ ],
284
+ ],
285
+ ),
286
+ ],
287
+ ),
288
+ );
289
+
290
+ if (!isFill) {
291
+ // Hug mode + horizontal overflow: keep the rounded container fixed at
292
+ // the parent's width and scroll only the tab strip INSIDE it. Putting
293
+ // the DecoratedBox inside the SingleChildScrollView (the previous
294
+ // structure) made the entire pill background scroll along with the
295
+ // tabs, so once the user scrolled the rounded corners were pushed off-
296
+ // screen and the visible portion looked like a flat-sided rectangle.
297
+ // Now the corners stay visible at the viewport edges no matter where
298
+ // the strip is scrolled, and ClipRRect masks any tab content that
299
+ // would otherwise leak past the rounded edges.
300
+ return DecoratedBox(
301
+ decoration: BoxDecoration(
302
+ color: c.avatarFallbackFill,
303
+ borderRadius: pillRadius,
244
304
  ),
305
+ child: ClipRRect(
306
+ borderRadius: pillRadius,
307
+ child: SingleChildScrollView(
308
+ controller: _scrollController,
309
+ scrollDirection: Axis.horizontal,
310
+ physics: const ClampingScrollPhysics(),
311
+ child: tabsContent,
312
+ ),
313
+ ),
314
+ );
315
+ }
316
+
317
+ return DecoratedBox(
318
+ decoration: BoxDecoration(
319
+ color: c.avatarFallbackFill,
320
+ borderRadius: pillRadius,
245
321
  ),
322
+ child: tabsContent,
246
323
  );
247
324
  }
248
325
 
@@ -250,8 +327,10 @@ class _KasyTabsState extends State<KasyTabs> {
250
327
 
251
328
  Widget _buildSecondary(BuildContext context) {
252
329
  final KasyColors c = context.colors;
330
+ final bool isFill = widget.mode == KasyTabsMode.fill;
253
331
 
254
- return Stack(
332
+ final Widget inner = Stack(
333
+ key: _stackKey,
255
334
  clipBehavior: Clip.none,
256
335
  children: [
257
336
  // Full-width bottom divider.
@@ -284,9 +363,7 @@ class _KasyTabsState extends State<KasyTabs> {
284
363
  ),
285
364
  // Tab labels.
286
365
  Row(
287
- mainAxisSize: widget.mode == KasyTabsMode.fill
288
- ? MainAxisSize.max
289
- : MainAxisSize.min,
366
+ mainAxisSize: isFill ? MainAxisSize.max : MainAxisSize.min,
290
367
  children: List.generate(widget.items.length, (i) {
291
368
  final KasyTabItem item = widget.items[i];
292
369
  final bool selected = i == widget.selectedIndex;
@@ -294,13 +371,25 @@ class _KasyTabsState extends State<KasyTabs> {
294
371
  key: _keys[i],
295
372
  item: item,
296
373
  selected: selected,
297
- expand: widget.mode == KasyTabsMode.fill,
374
+ expand: isFill,
298
375
  onTap: item.enabled ? () => widget.onTabSelected(i) : null,
299
376
  );
300
377
  }),
301
378
  ),
302
379
  ],
303
380
  );
381
+
382
+ // Same as primary: hug mode sizes to content via ScrollView.
383
+ if (!isFill) {
384
+ return SingleChildScrollView(
385
+ controller: _scrollController,
386
+ scrollDirection: Axis.horizontal,
387
+ physics: const ClampingScrollPhysics(),
388
+ child: inner,
389
+ );
390
+ }
391
+
392
+ return inner;
304
393
  }
305
394
  }
306
395
 
@@ -325,39 +414,66 @@ class _PrimaryTab extends StatelessWidget {
325
414
  Widget _tabContent(BuildContext context) {
326
415
  final KasyColors c = context.colors;
327
416
  final bool disabled = !item.enabled;
417
+ // Fill mode with icons uses a vertical (Column) layout per Figma spec:
418
+ // icon stacked above label, 12px all-sides padding, 12px font size.
419
+ final bool verticalLayout = expand && item.icon != null;
420
+
421
+ final Widget iconWidget = item.icon != null
422
+ ? Opacity(
423
+ opacity: disabled ? 0.4 : 1.0,
424
+ child: Icon(
425
+ item.icon,
426
+ size: 16,
427
+ color: selected ? c.onSurface : c.muted,
428
+ ),
429
+ )
430
+ : const SizedBox.shrink();
431
+
432
+ final Widget labelWidget = Opacity(
433
+ opacity: disabled ? 0.4 : 1.0,
434
+ child: Text(
435
+ item.label,
436
+ textAlign: TextAlign.center,
437
+ // Use labelLarge as defined in the Kasy theme (14px/w600).
438
+ // No fontWeight override — the theme token is the source of truth.
439
+ style: context.textTheme.labelLarge?.copyWith(
440
+ color: selected ? c.onSurface : c.muted,
441
+ // Fill+icon layout uses 12px (text-xs) — per Figma spec.
442
+ fontSize: verticalLayout ? 12 : null,
443
+ ),
444
+ ),
445
+ );
328
446
 
329
447
  return GestureDetector(
330
448
  onTap: onTap,
331
449
  behavior: HitTestBehavior.opaque,
332
450
  child: Padding(
333
- padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
334
- child: Row(
335
- mainAxisSize: MainAxisSize.min,
336
- mainAxisAlignment: MainAxisAlignment.center,
337
- children: [
338
- if (item.icon != null) ...[
339
- Opacity(
340
- opacity: disabled ? 0.4 : 1.0,
341
- child: Icon(
342
- item.icon,
343
- size: 16,
344
- color: selected ? c.onSurface : c.muted,
345
- ),
451
+ padding: verticalLayout
452
+ // Fill+icon: 12px all sides — per Figma spec.
453
+ ? const EdgeInsets.all(12)
454
+ // Default: 6px vertical, 12px horizontal — per Figma spec.
455
+ : const EdgeInsets.symmetric(vertical: 6, horizontal: 12),
456
+ child: verticalLayout
457
+ ? Column(
458
+ mainAxisSize: MainAxisSize.min,
459
+ mainAxisAlignment: MainAxisAlignment.center,
460
+ children: [
461
+ iconWidget,
462
+ const SizedBox(height: 6),
463
+ labelWidget,
464
+ ],
465
+ )
466
+ : Row(
467
+ mainAxisSize: MainAxisSize.min,
468
+ mainAxisAlignment: MainAxisAlignment.center,
469
+ children: [
470
+ if (item.icon != null) ...[
471
+ iconWidget,
472
+ const SizedBox(width: 6),
473
+ ],
474
+ labelWidget,
475
+ ],
346
476
  ),
347
- const SizedBox(width: 6),
348
- ],
349
- Opacity(
350
- opacity: disabled ? 0.4 : 1.0,
351
- child: Text(
352
- item.label,
353
- style: context.textTheme.labelLarge?.copyWith(
354
- color: selected ? c.onSurface : c.muted,
355
- fontWeight: selected ? FontWeight.w500 : FontWeight.w400,
356
- ),
357
- ),
358
- ),
359
- ],
360
- ),
361
477
  ),
362
478
  );
363
479
  }
@@ -391,7 +507,14 @@ class _SecondaryTab extends StatelessWidget {
391
507
  onTap: onTap,
392
508
  behavior: HitTestBehavior.opaque,
393
509
  child: Padding(
394
- padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
510
+ // top:4 bottom:6 horizontal:12 — per Figma spec.
511
+ // Extra bottom padding visually balances the 2px underline indicator.
512
+ padding: const EdgeInsets.only(
513
+ top: 4,
514
+ bottom: 6,
515
+ left: 12,
516
+ right: 12,
517
+ ),
395
518
  child: Row(
396
519
  mainAxisSize: MainAxisSize.min,
397
520
  mainAxisAlignment: MainAxisAlignment.center,
@@ -411,9 +534,9 @@ class _SecondaryTab extends StatelessWidget {
411
534
  opacity: disabled ? 0.4 : 1.0,
412
535
  child: Text(
413
536
  item.label,
537
+ // Use labelLarge as defined in the Kasy theme (14px/w600).
414
538
  style: context.textTheme.labelLarge?.copyWith(
415
539
  color: selected ? c.onSurface : c.muted,
416
- fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
417
540
  ),
418
541
  ),
419
542
  ),
@@ -135,9 +135,14 @@ class _KasyTextAreaState extends State<KasyTextArea> {
135
135
  const double disabledLabelOpacity = 0.46;
136
136
  const double disabledTextOpacity = 0.56;
137
137
  const double disabledDescriptionOpacity = 0.34;
138
- final BoxShadow fieldShadow = KasyShadows.inputField(
139
- context,
140
- enabled: !isDisabled,
138
+ // Contained shadow no Y offset, negative spread so the shadow hugs the
139
+ // border instead of leaking a halo below the field. Matches KasyTextField.
140
+ final BoxShadow fieldShadow = BoxShadow(
141
+ color: const Color(0xFF000000).withValues(
142
+ alpha: context.isDark ? 0.28 : 0.11,
143
+ ),
144
+ blurRadius: 3,
145
+ spreadRadius: -1,
141
146
  );
142
147
 
143
148
  const BorderRadius fieldRadius = BorderRadius.all(
@@ -345,7 +350,7 @@ class _KasyTextAreaState extends State<KasyTextArea> {
345
350
  DecoratedBox(
346
351
  decoration: BoxDecoration(
347
352
  borderRadius: fieldRadius,
348
- boxShadow: kIsWeb ? null : [fieldShadow],
353
+ boxShadow: [fieldShadow],
349
354
  ),
350
355
  child: innerField,
351
356
  ),
@@ -56,6 +56,30 @@ class KasyTextField extends StatefulWidget {
56
56
  /// Optional widget rendered to the right of [label] (e.g. a "Forgot password?" link).
57
57
  final Widget? labelTrailing;
58
58
 
59
+ /// Override for the field's vertical/horizontal padding. When null, the
60
+ /// design-system default is used (`KasySpacing.md` horizontal, `10`
61
+ /// vertical on mobile / `webSingleLineVerticalPadding` on web). Pass a
62
+ /// smaller value to make the field render shorter.
63
+ final EdgeInsetsGeometry? contentPadding;
64
+
65
+ /// Override for the field's drop shadow. When null, the design-system
66
+ /// default is used (single soft shadow on primary mobile variant, nothing
67
+ /// on secondary/embedded/web). Pass an empty list to render no shadow.
68
+ final List<BoxShadow>? boxShadow;
69
+
70
+ /// When true (default), the field shows the design-system blue focus border
71
+ /// while focused. Set to false to keep the resting border in every state —
72
+ /// useful for read-only triggers or compact contexts where the focus
73
+ /// affordance would feel noisy.
74
+ final bool focusBorder;
75
+
76
+ /// Forwards to [TextField.enableInteractiveSelection]. When false, the
77
+ /// field renders no caret, suppresses text-selection gestures, and stops
78
+ /// showing the I-beam cursor on web/desktop — handy for read-only triggers
79
+ /// (like [KasyDatePicker]) that should look like an input but feel like a
80
+ /// button.
81
+ final bool enableInteractiveSelection;
82
+
59
83
  const KasyTextField({
60
84
  super.key,
61
85
  this.controller,
@@ -89,6 +113,10 @@ class KasyTextField extends StatefulWidget {
89
113
  this.prefix,
90
114
  this.suffix,
91
115
  this.labelTrailing,
116
+ this.contentPadding,
117
+ this.boxShadow,
118
+ this.focusBorder = true,
119
+ this.enableInteractiveSelection = true,
92
120
  });
93
121
 
94
122
  @override
@@ -210,12 +238,9 @@ class _KasyTextFieldState extends State<KasyTextField> {
210
238
  widget.variant == KasyTextFieldVariant.secondary;
211
239
  final bool isEmbeddedVariant =
212
240
  widget.variant == KasyTextFieldVariant.embedded;
213
- final bool useWebSingleLinePadding =
214
- !isSecondaryVariant &&
215
- !isEmbeddedVariant &&
216
- kIsWeb &&
217
- widget.minLines == null &&
218
- widget.maxLines == 1;
241
+ // (No more web-specific padding — the field now uses the same vertical
242
+ // padding on every platform so primary/web TextFields render at the same
243
+ // height as mobile and as the KasyDatePicker trigger.)
219
244
  const double disabledTextOpacity = 0.56;
220
245
  const double disabledLabelOpacity = 0.46;
221
246
  const double disabledDescriptionOpacity = 0.34;
@@ -277,11 +302,17 @@ class _KasyTextFieldState extends State<KasyTextField> {
277
302
  ),
278
303
  );
279
304
  final BorderRadius fieldRadius = BorderRadius.circular(KasyRadius.md);
305
+ // Contained shadow (no Y offset, negative spread). Renders on every
306
+ // platform — removed the previous `!kIsWeb` guard so web matches mobile
307
+ // and the KasyDatePicker trigger.
280
308
  final bool shouldShowShadow =
281
- !isSecondaryVariant && !isEmbeddedVariant && !kIsWeb;
282
- final BoxShadow resolvedShadow = KasyShadows.inputField(
283
- context,
284
- enabled: !isDisabled,
309
+ !isSecondaryVariant && !isEmbeddedVariant;
310
+ final BoxShadow resolvedShadow = BoxShadow(
311
+ color: const Color(0xFF000000).withValues(
312
+ alpha: context.isDark ? 0.28 : 0.11,
313
+ ),
314
+ blurRadius: 3,
315
+ spreadRadius: -1,
285
316
  );
286
317
  final TargetPlatform platform = Theme.of(context).platform;
287
318
  final bool isApplePlatform =
@@ -317,15 +348,21 @@ class _KasyTextFieldState extends State<KasyTextField> {
317
348
  width: restingBorderWidth,
318
349
  ),
319
350
  );
320
- final InputBorder resolvedFocusedBorder = isEmbeddedVariant
321
- ? embeddedBorder
322
- : OutlineInputBorder(
323
- borderRadius: fieldRadius,
324
- borderSide: BorderSide(
325
- color: resolvedFocusedBorderColor,
326
- width: focusedBorderWidth,
327
- ),
328
- );
351
+ // When focusBorder is disabled, the focused state collapses to the
352
+ // resting border so the field never grows that bright outline. Error
353
+ // states still take priority via resolvedEnabledBorder picking the error
354
+ // color, so invalid fields keep the red highlight.
355
+ final InputBorder resolvedFocusedBorder = !widget.focusBorder
356
+ ? resolvedEnabledBorder
357
+ : isEmbeddedVariant
358
+ ? embeddedBorder
359
+ : OutlineInputBorder(
360
+ borderRadius: fieldRadius,
361
+ borderSide: BorderSide(
362
+ color: resolvedFocusedBorderColor,
363
+ width: focusedBorderWidth,
364
+ ),
365
+ );
329
366
  final Color surfaceColor = isEmbeddedVariant
330
367
  ? Colors.transparent
331
368
  : isSecondaryVariant
@@ -334,13 +371,20 @@ class _KasyTextFieldState extends State<KasyTextField> {
334
371
  final Color fieldFillColor = isDisabled
335
372
  ? surfaceColor.withValues(alpha: context.isDark ? 0.9 : 0.94)
336
373
  : surfaceColor;
374
+ // Affix icon tint matches the kit's helper-icon tone (0.62) when the
375
+ // field is enabled, and fades down with the same disabled curve used by
376
+ // the rest of the field so prefix/suffix don't stay "alive" while
377
+ // everything else dims.
378
+ final double affixAlpha = isDisabled
379
+ ? 0.62 * disabledTextOpacity
380
+ : 0.62;
337
381
  final Widget? resolvedPrefix = widget.prefix == null
338
382
  ? null
339
383
  : Center(
340
384
  child: IconTheme.merge(
341
385
  data: IconThemeData(
342
386
  size: KasyTextField.iconGlyphSize,
343
- color: context.colors.onSurface.withValues(alpha: 0.62),
387
+ color: context.colors.onSurface.withValues(alpha: affixAlpha),
344
388
  ),
345
389
  child: widget.prefix!,
346
390
  ),
@@ -351,7 +395,7 @@ class _KasyTextFieldState extends State<KasyTextField> {
351
395
  child: IconTheme.merge(
352
396
  data: IconThemeData(
353
397
  size: KasyTextField.iconGlyphSize,
354
- color: context.colors.onSurface.withValues(alpha: 0.62),
398
+ color: context.colors.onSurface.withValues(alpha: affixAlpha),
355
399
  ),
356
400
  child: widget.suffix!,
357
401
  ),
@@ -410,12 +454,11 @@ class _KasyTextFieldState extends State<KasyTextField> {
410
454
  width: KasyTextField.iconSlotExtent,
411
455
  height: KasyTextField.iconSlotExtent,
412
456
  ),
413
- contentPadding: EdgeInsets.symmetric(
414
- horizontal: isEmbeddedVariant ? 0 : KasySpacing.md,
415
- vertical: useWebSingleLinePadding
416
- ? KasyTextField.webSingleLineVerticalPadding
417
- : KasySpacing.smd,
418
- ),
457
+ contentPadding: widget.contentPadding ??
458
+ EdgeInsets.symmetric(
459
+ horizontal: isEmbeddedVariant ? 0 : KasySpacing.md,
460
+ vertical: 13,
461
+ ),
419
462
  fillColor: fieldFillColor,
420
463
  filled: !isEmbeddedVariant,
421
464
  // On web: keep fill unchanged on hover (hoverColor replaces fillColor — don't let
@@ -434,16 +477,29 @@ class _KasyTextFieldState extends State<KasyTextField> {
434
477
  borderRadius: fieldRadius,
435
478
  borderSide: BorderSide(color: context.colors.error, width: 1.3),
436
479
  ),
437
- focusedErrorBorder: OutlineInputBorder(
438
- borderRadius: fieldRadius,
439
- borderSide: BorderSide(
440
- color: context.colors.error,
441
- width: focusedBorderWidth,
442
- ),
443
- ),
480
+ // Mirrors the focusBorder opt-out: when disabled, the focused-error
481
+ // state reuses the resting error border (no thicker outline on focus).
482
+ focusedErrorBorder: !widget.focusBorder
483
+ ? OutlineInputBorder(
484
+ borderRadius: fieldRadius,
485
+ borderSide: BorderSide(
486
+ color: context.colors.error,
487
+ width: 1.3,
488
+ ),
489
+ )
490
+ : OutlineInputBorder(
491
+ borderRadius: fieldRadius,
492
+ borderSide: BorderSide(
493
+ color: context.colors.error,
494
+ width: focusedBorderWidth,
495
+ ),
496
+ ),
444
497
  hintStyle: hintBaseStyle.copyWith(
498
+ // Match the disabled description fade so the placeholder dims along
499
+ // with the rest of the field (the previous 0.42 read as "kind of
500
+ // greyed out" rather than disabled).
445
501
  color: (hintBaseStyle.color ?? context.colors.muted).withValues(
446
- alpha: isDisabled ? 0.42 : 1,
502
+ alpha: isDisabled ? disabledDescriptionOpacity : 1,
447
503
  ),
448
504
  ),
449
505
  );
@@ -471,11 +527,15 @@ class _KasyTextFieldState extends State<KasyTextField> {
471
527
  buildCounter: hasCounter ? _hideInputCounter : null,
472
528
  style: fieldTextStyle,
473
529
  decoration: decoration,
530
+ enableInteractiveSelection: widget.enableInteractiveSelection,
474
531
  );
532
+ // TESTE: sombra desabilitada para investigar diferença visual com DatePicker
533
+ final List<BoxShadow>? effectiveBoxShadow = widget.boxShadow ??
534
+ (shouldShowShadow ? <BoxShadow>[resolvedShadow] : null);
475
535
  final Widget decoratedField = DecoratedBox(
476
536
  decoration: BoxDecoration(
477
537
  borderRadius: fieldRadius,
478
- boxShadow: shouldShowShadow ? <BoxShadow>[resolvedShadow] : null,
538
+ boxShadow: effectiveBoxShadow,
479
539
  ),
480
540
  child: Semantics(
481
541
  textField: true,
@@ -1,4 +1,3 @@
1
- import 'package:flutter/foundation.dart' show kIsWeb;
2
1
  import 'package:flutter/material.dart';
3
2
  import 'package:flutter/services.dart';
4
3
  import 'package:kasy_kit/core/theme/theme.dart';
@@ -422,7 +421,7 @@ class _OTPCell extends StatelessWidget {
422
421
  ? _neutralFlatOtpFill(context)
423
422
  : (cellFillColor ?? context.colors.surface);
424
423
  final bool suppressShadow =
425
- enabled && (neutralFlatCells || suppressCellShadow || kIsWeb);
424
+ enabled && (neutralFlatCells || suppressCellShadow);
426
425
  final List<BoxShadow> boxShadow = suppressShadow
427
426
  ? <BoxShadow>[]
428
427
  : <BoxShadow>[adjustedShadow];