kasy-cli 1.18.0 → 1.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/bin/kasy.js +3 -1
  2. package/lib/commands/new.js +99 -105
  3. package/lib/commands/run.js +34 -6
  4. package/lib/scaffold/backends/firebase/setup-from-scratch.js +79 -0
  5. package/lib/utils/brand.js +1 -1
  6. package/lib/utils/i18n/messages-en.js +6 -0
  7. package/lib/utils/i18n/messages-es.js +6 -0
  8. package/lib/utils/i18n/messages-pt.js +6 -0
  9. package/package.json +1 -2
  10. package/templates/firebase/lib/components/kasy_date_picker.dart +1670 -331
  11. package/templates/firebase/lib/components/kasy_tabs.dart +111 -72
  12. package/templates/firebase/lib/components/kasy_text_area.dart +9 -4
  13. package/templates/firebase/lib/components/kasy_text_field.dart +96 -36
  14. package/templates/firebase/lib/components/kasy_text_field_otp.dart +1 -2
  15. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +88 -35
  16. package/templates/firebase/lib/core/bottom_menu/bottom_router.dart +7 -43
  17. package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +118 -16
  18. package/templates/firebase/lib/core/dev_inspector/dev_inspector_service.dart +14 -20
  19. package/templates/firebase/lib/core/security/secured_storage.dart +56 -15
  20. package/templates/firebase/lib/core/theme/providers/theme_provider.dart +3 -0
  21. package/templates/firebase/lib/core/theme/web_background_sync.dart +3 -0
  22. package/templates/firebase/lib/core/theme/web_background_sync_web.dart +18 -0
  23. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +3 -6
  24. package/templates/firebase/lib/features/authentication/api/authentication_api.dart +6 -0
  25. package/templates/firebase/lib/features/home/home_components_page.dart +3 -2
  26. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +457 -73
  27. package/templates/firebase/lib/features/home/home_page.dart +17 -40
  28. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +1 -16
  29. package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +0 -4
  30. package/templates/firebase/lib/main.dart +34 -34
  31. package/templates/firebase/pubspec.yaml +1 -0
  32. package/templates/firebase/storage.cors.json +8 -0
  33. package/templates/firebase/web/index.html +15 -2
  34. package/templates/firebase/lib/core/bottom_menu/kasy_bart_navigation.dart +0 -22
@@ -103,6 +103,11 @@ class _KasyTabsState extends State<KasyTabs> {
103
103
  // for measurements, ensuring correctness even when wrapped in a ScrollView.
104
104
  final GlobalKey _stackKey = GlobalKey();
105
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
+
106
111
  // Indicator geometry (left offset, width) resolved from measured keys.
107
112
  double _indicatorLeft = 0;
108
113
  double _indicatorWidth = 0;
@@ -120,6 +125,12 @@ class _KasyTabsState extends State<KasyTabs> {
120
125
  WidgetsBinding.instance.addPostFrameCallback((_) => _measure());
121
126
  }
122
127
 
128
+ @override
129
+ void dispose() {
130
+ _scrollController.dispose();
131
+ super.dispose();
132
+ }
133
+
123
134
  @override
124
135
  void didUpdateWidget(KasyTabs old) {
125
136
  super.didUpdateWidget(old);
@@ -180,14 +191,26 @@ class _KasyTabsState extends State<KasyTabs> {
180
191
  }
181
192
 
182
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.
183
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
+
184
211
  final BuildContext? ctx = _keys[index].currentContext;
185
212
  if (ctx == null) return;
186
- Scrollable.ensureVisible(
187
- ctx,
188
- duration: const Duration(milliseconds: 250),
189
- curve: Curves.easeInOut,
190
- );
213
+ Scrollable.ensureVisible(ctx, duration: duration, curve: curve);
191
214
  }
192
215
 
193
216
  @override
@@ -205,84 +228,99 @@ class _KasyTabsState extends State<KasyTabs> {
205
228
  final KasyColors c = context.colors;
206
229
  final bool isDark = context.isDark;
207
230
  final bool isFill = widget.mode == KasyTabsMode.fill;
208
-
209
- final Widget inner = DecoratedBox(
210
- decoration: BoxDecoration(
211
- color: c.avatarFallbackFill,
212
- borderRadius: BorderRadius.circular(KasyRadius.full),
213
- ),
214
- child: Padding(
215
- // 8px horizontal, 4px vertical per Figma spec.
216
- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
217
- child: Stack(
218
- key: _stackKey,
219
- // Allow pill to extend 4px beyond each tab edge (Figma: inset 0 -4px).
220
- clipBehavior: Clip.none,
221
- children: [
222
- // Animated pill background.
223
- if (_measured)
224
- AnimatedPositioned(
225
- duration: const Duration(milliseconds: 250),
226
- curve: Curves.easeInOut,
227
- // Extends 4px on each side beyond the measured tab.
228
- left: _indicatorLeft - 4,
229
- top: 0,
230
- bottom: 0,
231
- width: _indicatorWidth + 8,
232
- child: DecoratedBox(
233
- decoration: BoxDecoration(
234
- color: c.surface,
235
- borderRadius: BorderRadius.circular(KasyRadius.full),
236
- boxShadow: [
237
- BoxShadow(
238
- color: Colors.black.withValues(
239
- alpha: isDark ? 0.14 : 0.06,
240
- ),
241
- blurRadius: 8,
242
- offset: const Offset(0, 2),
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,
243
259
  ),
244
- ],
245
- ),
260
+ blurRadius: 8,
261
+ offset: const Offset(0, 2),
262
+ ),
263
+ ],
246
264
  ),
247
265
  ),
248
- // Tab labels (on top of the pill).
249
- Row(
250
- mainAxisSize: isFill ? MainAxisSize.max : MainAxisSize.min,
251
- children: [
252
- for (int i = 0; i < widget.items.length; i++) ...[
253
- // 2px gap between adjacent tabs — per Figma spec.
254
- if (i > 0) const SizedBox(width: 2),
255
- _PrimaryTab(
256
- key: _keys[i],
257
- item: widget.items[i],
258
- selected: i == widget.selectedIndex,
259
- expand: isFill,
260
- onTap: widget.items[i].enabled
261
- ? () => widget.onTabSelected(i)
262
- : null,
263
- ),
264
- ],
265
- ],
266
266
  ),
267
- ],
268
- ),
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
+ ],
269
287
  ),
270
288
  );
271
289
 
272
- // In hug mode, the component must size to its content — NOT stretch to
273
- // parent width. SingleChildScrollView gives unconstrained horizontal
274
- // space to its child, so the DecoratedBox → Stack → Row(min) chain
275
- // naturally measures at intrinsic width. It also handles overflow
276
- // gracefully (scrollable) when many long tab labels are used.
277
290
  if (!isFill) {
278
- return SingleChildScrollView(
279
- scrollDirection: Axis.horizontal,
280
- physics: const ClampingScrollPhysics(),
281
- child: inner,
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,
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
+ ),
282
314
  );
283
315
  }
284
316
 
285
- return inner;
317
+ return DecoratedBox(
318
+ decoration: BoxDecoration(
319
+ color: c.avatarFallbackFill,
320
+ borderRadius: pillRadius,
321
+ ),
322
+ child: tabsContent,
323
+ );
286
324
  }
287
325
 
288
326
  // ── Secondary (underline indicator) ───────────────────────────────────────
@@ -344,6 +382,7 @@ class _KasyTabsState extends State<KasyTabs> {
344
382
  // Same as primary: hug mode sizes to content via ScrollView.
345
383
  if (!isFill) {
346
384
  return SingleChildScrollView(
385
+ controller: _scrollController,
347
386
  scrollDirection: Axis.horizontal,
348
387
  physics: const ClampingScrollPhysics(),
349
388
  child: inner,
@@ -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];
@@ -1,4 +1,5 @@
1
1
  import 'package:bart/bart.dart' as bart;
2
+ import 'package:bart/bart/bart_bottombar_actions.dart';
2
3
  import 'package:flutter/foundation.dart';
3
4
  import 'package:flutter/material.dart';
4
5
  import 'package:flutter/services.dart';
@@ -8,22 +9,92 @@ import 'package:kasy_kit/core/sidebar/kasy_sidebar.dart';
8
9
  import 'package:kasy_kit/core/theme/theme.dart';
9
10
  import 'package:kasy_kit/core/widgets/responsive_layout.dart';
10
11
 
11
- /// This bottom menu is powered by Bart packages
12
- /// https://pub.dev/packages/bart
13
- /// It allows you to create a bottom menu with a router and handle
14
- /// all tabs navigation separately.
15
- /// See the bottom_router.dart file to add tabs or subpages to show on tabs
16
- class BottomMenu extends StatelessWidget {
12
+ /// Bottom navigation host powered by Bart (https://pub.dev/packages/bart).
13
+ ///
14
+ /// Bart stores its bottom-bar visibility inside a [ValueNotifier] created in
15
+ /// the [bart.BartScaffold] constructor. Because [ResponsiveLayout] recreates
16
+ /// one of three [bart.BartScaffold]s on every rebuild (breakpoint change,
17
+ /// theme toggle, device-preview resize), that notifier resets to its initial
18
+ /// value and a previously-hidden bar can come back wrong — or, when returning
19
+ /// from a native overlay (FaceID, photo picker, permission dialog), Bart's
20
+ /// internal state ends up out of sync with the visible route.
21
+ ///
22
+ /// This widget owns visibility end-to-end so feature pages never need to
23
+ /// touch it. The rules are simple:
24
+ /// * Bottom-bar tab (1 path segment) → bar visible.
25
+ /// * Inner route (multi-segment, or [bart.BartMenuRoute.showBottomBar] =
26
+ /// false) → bar hidden.
27
+ ///
28
+ /// Sync points: [onRouteChanged], post-frame after every [build] (catches
29
+ /// scaffold rebuilds), and [didChangeAppLifecycleState] on resume.
30
+ class BottomMenu extends StatefulWidget {
17
31
  final String? initialRoute;
18
32
 
19
33
  const BottomMenu({super.key, this.initialRoute});
20
34
 
35
+ @override
36
+ State<BottomMenu> createState() => _BottomMenuState();
37
+ }
38
+
39
+ class _BottomMenuState extends State<BottomMenu>
40
+ with WidgetsBindingObserver, BartNotifier {
41
+ String? _currentRoutePath;
42
+
43
+ @override
44
+ void initState() {
45
+ super.initState();
46
+ WidgetsBinding.instance.addObserver(this);
47
+ _currentRoutePath = _resolveInitialRoute(widget.initialRoute);
48
+ }
49
+
50
+ @override
51
+ void dispose() {
52
+ WidgetsBinding.instance.removeObserver(this);
53
+ super.dispose();
54
+ }
55
+
56
+ @override
57
+ void didChangeAppLifecycleState(AppLifecycleState state) {
58
+ super.didChangeAppLifecycleState(state);
59
+ if (state == AppLifecycleState.resumed) {
60
+ _scheduleSync();
61
+ }
62
+ }
63
+
64
+ void _onRouteChanged(bart.BartMenuRoute route) {
65
+ _currentRoutePath = route.path;
66
+ _scheduleSync();
67
+ }
68
+
69
+ void _scheduleSync() {
70
+ WidgetsBinding.instance.addPostFrameCallback((_) {
71
+ if (!mounted) return;
72
+ _applyVisibility();
73
+ });
74
+ }
75
+
76
+ void _applyVisibility() {
77
+ if (_shouldShowBottomBar(_currentRoutePath)) {
78
+ showBottomBar(context);
79
+ } else {
80
+ hideBottomBar(context);
81
+ }
82
+ }
83
+
21
84
  @override
22
85
  Widget build(BuildContext context) {
23
- final String? resolvedInitialRoute = _resolveInitialRoute(initialRoute);
24
- final bool showBottomBarOnStart = _showBottomBarOnStart(
25
- resolvedInitialRoute,
86
+ // Re-assert visibility after every rebuild: the Bart scaffold underneath
87
+ // may have been freshly instantiated (see class doc).
88
+ _scheduleSync();
89
+
90
+ final String? resolvedInitialRoute = _resolveInitialRoute(
91
+ widget.initialRoute,
92
+ );
93
+ final bool showBottomBarOnStart = _shouldShowBottomBar(resolvedInitialRoute);
94
+ final scaffoldOptions = bart.ScaffoldOptions(
95
+ backgroundColor: context.colors.background,
26
96
  );
97
+
27
98
  return AnnotatedRegion<SystemUiOverlayStyle>(
28
99
  value: switch (Theme.brightnessOf(context)) {
29
100
  Brightness.dark => SystemUiOverlayStyle.light,
@@ -35,13 +106,8 @@ class BottomMenu extends StatelessWidget {
35
106
  bottomBar: kasyPaddedSurfaceBottomBar(),
36
107
  initialRoute: resolvedInitialRoute,
37
108
  showBottomBarOnStart: showBottomBarOnStart,
38
- scaffoldOptions: bart.ScaffoldOptions(
39
- backgroundColor: context.colors.background,
40
- ),
41
- onRouteChanged: (route) {
42
- // If you want to log tab events to analytics
43
- // MixpanelAnalyticsApi.instance().logEvent('home/$route', {});
44
- },
109
+ scaffoldOptions: scaffoldOptions,
110
+ onRouteChanged: _onRouteChanged,
45
111
  ),
46
112
  // medium (768–1024 px): icon-only collapsed rail
47
113
  medium: bart.BartScaffold(
@@ -49,16 +115,11 @@ class BottomMenu extends StatelessWidget {
49
115
  bottomBar: kasyPaddedSurfaceBottomBar(),
50
116
  initialRoute: resolvedInitialRoute,
51
117
  showBottomBarOnStart: showBottomBarOnStart,
52
- scaffoldOptions: bart.ScaffoldOptions(
53
- backgroundColor: context.colors.background,
54
- ),
118
+ scaffoldOptions: scaffoldOptions,
55
119
  sideBarOptions: bart.CustomSideBarOptions(
56
120
  sideBarBuilder: kasySidebarCollapsedBuilder,
57
121
  ),
58
- onRouteChanged: (route) {
59
- // If you want to log tab events to analytics
60
- // MixpanelAnalyticsApi.instance().logEvent('home/$route', {});
61
- },
122
+ onRouteChanged: _onRouteChanged,
62
123
  ),
63
124
  // large (1024 px+): full expanded sidebar
64
125
  large: bart.BartScaffold(
@@ -66,16 +127,11 @@ class BottomMenu extends StatelessWidget {
66
127
  bottomBar: kasyPaddedSurfaceBottomBar(),
67
128
  initialRoute: resolvedInitialRoute,
68
129
  showBottomBarOnStart: showBottomBarOnStart,
69
- scaffoldOptions: bart.ScaffoldOptions(
70
- backgroundColor: context.colors.background,
71
- ),
130
+ scaffoldOptions: scaffoldOptions,
72
131
  sideBarOptions: bart.CustomSideBarOptions(
73
132
  sideBarBuilder: kasySidebarBuilder,
74
133
  ),
75
- onRouteChanged: (route) {
76
- // If you want to log tab events to analytics
77
- // MixpanelAnalyticsApi.instance().logEvent('home/$route', {});
78
- },
134
+ onRouteChanged: _onRouteChanged,
79
135
  ),
80
136
  ),
81
137
  );
@@ -114,14 +170,11 @@ class BottomMenu extends StatelessWidget {
114
170
  return path;
115
171
  }
116
172
 
117
- bool _showBottomBarOnStart(String? route) {
173
+ bool _shouldShowBottomBar(String? route) {
118
174
  if (route == null) {
119
175
  return true;
120
176
  }
121
177
  final segments = Uri.parse(route).pathSegments;
122
- if (segments.length < 2) {
123
- return true;
124
- }
125
- return false;
178
+ return segments.length < 2;
126
179
  }
127
180
  }