kasy-cli 1.39.1 → 1.40.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 (21) hide show
  1. package/lib/scaffold/CHANGELOG.json +14 -0
  2. package/package.json +1 -1
  3. package/templates/firebase/.firebase/hosting.YnVpbGQvd2Vi.cache +27 -27
  4. package/templates/firebase/lib/components/components.dart +2 -0
  5. package/templates/firebase/lib/components/kasy_bottom_sheet.dart +25 -7
  6. package/templates/firebase/lib/components/kasy_menu.dart +902 -0
  7. package/templates/firebase/lib/components/kasy_popover.dart +267 -0
  8. package/templates/firebase/lib/components/kasy_sidebar.dart +20 -10
  9. package/templates/firebase/lib/core/navigation/kasy_route_observer.dart +8 -0
  10. package/templates/firebase/lib/core/theme/texts.dart +25 -0
  11. package/templates/firebase/lib/features/home/home_components_page.dart +23 -4
  12. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +320 -0
  13. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +38 -94
  14. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +32 -107
  15. package/templates/firebase/lib/features/subscriptions/ui/widgets/comparison_table.dart +21 -21
  16. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_close_button.dart +35 -27
  17. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_col.dart +22 -17
  18. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_row.dart +12 -7
  19. package/templates/firebase/lib/router.dart +2 -0
  20. package/templates/firebase/pubspec.yaml +1 -1
  21. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_banner.dart +0 -81
@@ -0,0 +1,267 @@
1
+ import 'package:flutter/material.dart';
2
+ import 'package:kasy_kit/core/theme/theme.dart';
3
+
4
+ // ─────────────────────────────────────────────────────────────────────────────
5
+ // KasyPopover — desktop-native counterpart to a mobile menu bottom sheet.
6
+ //
7
+ // Instead of dimming the whole screen and dropping a centered card (what a
8
+ // bottom sheet "adapted" to desktop does), a popover opens right next to the
9
+ // control the user clicked, like a native desktop menu. Position is computed
10
+ // from the trigger's render box — no LayerLink wiring at the call site — so any
11
+ // `onTap` that still has the trigger's BuildContext can anchor one. It flips
12
+ // above the trigger when there's no room below, clamps to the viewport, and
13
+ // dismisses on tap-outside / Esc.
14
+ //
15
+ // Used by [showKasyMenuSheet] (see kasy_bottom_sheet.dart) as the desktop form
16
+ // of a menu sheet; callers normally go through that, not this directly.
17
+ // ─────────────────────────────────────────────────────────────────────────────
18
+
19
+ /// Horizontal anchoring of the popover panel relative to its trigger.
20
+ enum KasyPopoverAlign {
21
+ /// Panel's left edge aligns with the trigger's left edge.
22
+ start,
23
+
24
+ /// Panel's right edge aligns with the trigger's right edge (menus that hang
25
+ /// off a right-aligned value, e.g. a settings row).
26
+ end,
27
+
28
+ /// Panel is centered horizontally on the trigger.
29
+ center,
30
+ }
31
+
32
+ /// Side of the trigger the popover opens toward. Each side flips to its opposite
33
+ /// when there isn't room (e.g. [bottom] flips to top near the screen edge).
34
+ enum KasyPopoverPlacement { bottom, top, left, right }
35
+
36
+ const double _kPopoverDefaultWidth = 220;
37
+ const double _kPopoverGap = 6;
38
+ const double _kViewportMargin = 8;
39
+
40
+ // Below this much room on the chosen side we consider flipping to the opposite.
41
+ const double _kFlipThreshold = 220;
42
+
43
+ /// Presents [builder] as a floating panel anchored to the widget that owns
44
+ /// [anchorContext] (the control the user clicked). The content is wrapped in a
45
+ /// [KasyPopoverSurface] automatically, so [builder] returns the bare menu body.
46
+ ///
47
+ /// [placement] picks the side it opens toward (default below the trigger);
48
+ /// [align] sets cross-axis alignment for top/bottom placements. Pop the panel
49
+ /// with a value (`Navigator.pop(context, value)`) to return it through the
50
+ /// future, mirroring [showModalBottomSheet].
51
+ Future<T?> showKasyPopover<T>({
52
+ required BuildContext anchorContext,
53
+ required WidgetBuilder builder,
54
+ KasyPopoverAlign align = KasyPopoverAlign.start,
55
+ KasyPopoverPlacement placement = KasyPopoverPlacement.bottom,
56
+ double width = _kPopoverDefaultWidth,
57
+ double gap = _kPopoverGap,
58
+ }) {
59
+ // Snapshot the trigger geometry at open time (global coords). A transient menu
60
+ // doesn't need to follow scroll — tap-outside closes it.
61
+ final RenderBox? anchorBox = anchorContext.findRenderObject() as RenderBox?;
62
+ final Size viewport = MediaQuery.sizeOf(anchorContext);
63
+ final Offset anchorTopLeft =
64
+ anchorBox?.localToGlobal(Offset.zero) ??
65
+ Offset(viewport.width / 2, viewport.height / 2);
66
+ final Size anchorSize = anchorBox?.size ?? Size.zero;
67
+
68
+ return showGeneralDialog<T>(
69
+ context: anchorContext,
70
+ barrierDismissible: true,
71
+ barrierLabel: MaterialLocalizations.of(
72
+ anchorContext,
73
+ ).modalBarrierDismissLabel,
74
+ // No scrim: a desktop popover keeps the page visible behind it.
75
+ barrierColor: Colors.transparent,
76
+ transitionDuration: const Duration(milliseconds: 160),
77
+ pageBuilder: (ctx, animation, _) => _KasyPopoverLayout(
78
+ anchorTopLeft: anchorTopLeft,
79
+ anchorSize: anchorSize,
80
+ width: width,
81
+ gap: gap,
82
+ align: align,
83
+ placement: placement,
84
+ animation: animation,
85
+ child: Builder(builder: builder),
86
+ ),
87
+ transitionBuilder: (_, _, _, child) => child,
88
+ );
89
+ }
90
+
91
+ /// Positions and animates the popover panel over a full-screen, transparent
92
+ /// layer. Resolves [placement] (flipping to the opposite side when room is
93
+ /// tight) and clamps the panel within the viewport.
94
+ class _KasyPopoverLayout extends StatelessWidget {
95
+ final Offset anchorTopLeft;
96
+ final Size anchorSize;
97
+ final double width;
98
+ final double gap;
99
+ final KasyPopoverAlign align;
100
+ final KasyPopoverPlacement placement;
101
+ final Animation<double> animation;
102
+ final Widget child;
103
+
104
+ const _KasyPopoverLayout({
105
+ required this.anchorTopLeft,
106
+ required this.anchorSize,
107
+ required this.width,
108
+ required this.gap,
109
+ required this.align,
110
+ required this.placement,
111
+ required this.animation,
112
+ required this.child,
113
+ });
114
+
115
+ @override
116
+ Widget build(BuildContext context) {
117
+ final Size viewport = MediaQuery.sizeOf(context);
118
+ final EdgeInsets safe = MediaQuery.viewPaddingOf(context);
119
+ final double anchorRight = anchorTopLeft.dx + anchorSize.width;
120
+ final double anchorBottom = anchorTopLeft.dy + anchorSize.height;
121
+
122
+ final double spaceBelow = viewport.height - safe.bottom - anchorBottom;
123
+ final double spaceAbove = anchorTopLeft.dy - safe.top;
124
+ final double spaceRight = viewport.width - _kViewportMargin - anchorRight;
125
+ final double spaceLeft = anchorTopLeft.dx - _kViewportMargin;
126
+
127
+ // Resolve the effective side, flipping to the opposite when too tight.
128
+ final KasyPopoverPlacement side = switch (placement) {
129
+ KasyPopoverPlacement.bottom =>
130
+ spaceBelow < _kFlipThreshold && spaceAbove > spaceBelow
131
+ ? KasyPopoverPlacement.top
132
+ : KasyPopoverPlacement.bottom,
133
+ KasyPopoverPlacement.top =>
134
+ spaceAbove < _kFlipThreshold && spaceBelow > spaceAbove
135
+ ? KasyPopoverPlacement.bottom
136
+ : KasyPopoverPlacement.top,
137
+ KasyPopoverPlacement.right =>
138
+ spaceRight < width + gap && spaceLeft > spaceRight
139
+ ? KasyPopoverPlacement.left
140
+ : KasyPopoverPlacement.right,
141
+ KasyPopoverPlacement.left =>
142
+ spaceLeft < width + gap && spaceRight > spaceLeft
143
+ ? KasyPopoverPlacement.right
144
+ : KasyPopoverPlacement.left,
145
+ };
146
+
147
+ double? left;
148
+ double? top;
149
+ double? right;
150
+ double? bottom;
151
+ double maxHeight;
152
+ Alignment scaleAlign;
153
+
154
+ final bool vertical =
155
+ side == KasyPopoverPlacement.bottom || side == KasyPopoverPlacement.top;
156
+ if (vertical) {
157
+ left = switch (align) {
158
+ KasyPopoverAlign.start => anchorTopLeft.dx,
159
+ KasyPopoverAlign.end => anchorRight - width,
160
+ KasyPopoverAlign.center =>
161
+ anchorTopLeft.dx + (anchorSize.width - width) / 2,
162
+ }.clamp(_kViewportMargin, viewport.width - width - _kViewportMargin);
163
+ if (side == KasyPopoverPlacement.bottom) {
164
+ top = anchorBottom + gap;
165
+ maxHeight = spaceBelow - gap - _kViewportMargin;
166
+ scaleAlign = Alignment.topCenter;
167
+ } else {
168
+ bottom = viewport.height - (anchorTopLeft.dy - gap);
169
+ maxHeight = spaceAbove - gap - _kViewportMargin;
170
+ scaleAlign = Alignment.bottomCenter;
171
+ }
172
+ } else {
173
+ // Left / right: align the panel's top with the trigger, clamped so it
174
+ // never runs past the bottom edge.
175
+ top = anchorTopLeft.dy.clamp(
176
+ safe.top + _kViewportMargin,
177
+ viewport.height - safe.bottom - _kViewportMargin,
178
+ );
179
+ maxHeight = viewport.height - safe.bottom - top - _kViewportMargin;
180
+ if (side == KasyPopoverPlacement.right) {
181
+ left = anchorRight + gap;
182
+ scaleAlign = Alignment.centerLeft;
183
+ } else {
184
+ right = viewport.width - (anchorTopLeft.dx - gap);
185
+ scaleAlign = Alignment.centerRight;
186
+ }
187
+ }
188
+
189
+ final CurvedAnimation curved = CurvedAnimation(
190
+ parent: animation,
191
+ curve: Curves.easeOutCubic,
192
+ reverseCurve: Curves.easeIn,
193
+ );
194
+
195
+ return Stack(
196
+ children: [
197
+ Positioned(
198
+ left: left,
199
+ top: top,
200
+ right: right,
201
+ bottom: bottom,
202
+ child: FadeTransition(
203
+ opacity: curved,
204
+ child: ScaleTransition(
205
+ scale: Tween<double>(begin: 0.96, end: 1.0).animate(curved),
206
+ alignment: scaleAlign,
207
+ child: KasyPopoverSurface(
208
+ width: width,
209
+ maxHeight: maxHeight.clamp(0, viewport.height),
210
+ child: child,
211
+ ),
212
+ ),
213
+ ),
214
+ ),
215
+ ],
216
+ );
217
+ }
218
+ }
219
+
220
+ /// Floating surface chrome for popover content: design-system surface tone,
221
+ /// fully-rounded corners, hairline border and elevation shadow, with an internal
222
+ /// scroll so tall menus never overflow. Mirrors the dropdown panel look.
223
+ class KasyPopoverSurface extends StatelessWidget {
224
+ final Widget child;
225
+ final double width;
226
+ final double maxHeight;
227
+
228
+ const KasyPopoverSurface({
229
+ super.key,
230
+ required this.child,
231
+ this.width = _kPopoverDefaultWidth,
232
+ this.maxHeight = double.infinity,
233
+ });
234
+
235
+ @override
236
+ Widget build(BuildContext context) {
237
+ final BorderRadius radius = BorderRadius.circular(KasyRadius.xl);
238
+ return Material(
239
+ type: MaterialType.transparency,
240
+ child: Container(
241
+ width: width,
242
+ constraints: BoxConstraints(maxHeight: maxHeight),
243
+ decoration: BoxDecoration(
244
+ color: context.colors.surface,
245
+ borderRadius: radius,
246
+ border: Border.all(
247
+ color: context.colors.outline.withValues(alpha: 0.22),
248
+ ),
249
+ boxShadow: [
250
+ KasyShadows.component(
251
+ context,
252
+ blurRadius: 24,
253
+ spreadRadius: -2,
254
+ offset: const Offset(0, 10),
255
+ ),
256
+ ],
257
+ ),
258
+ child: ClipRRect(
259
+ borderRadius: radius,
260
+ child: SingleChildScrollView(
261
+ child: child,
262
+ ),
263
+ ),
264
+ ),
265
+ );
266
+ }
267
+ }
@@ -393,6 +393,12 @@ class _KasySidebarState extends State<KasySidebar> {
393
393
  /// Viewport width below which the sidebar auto-collapses (tablet breakpoint).
394
394
  static const double _kBreakpoint = 1024.0;
395
395
 
396
+ /// Phone breakpoint. Below this the sidebar is always an overlay (drawer or
397
+ /// full-screen preview), so the logo gets its mobile placement (nudged down,
398
+ /// band trimmed). Above it the inline rail keeps its divider aligned with the
399
+ /// page app bar / web header.
400
+ static const double _kMobileBreakpoint = 768.0;
401
+
396
402
 
397
403
  /// True when wired to Bart's navigation (real, tappable screens).
398
404
  bool get _connected =>
@@ -982,18 +988,22 @@ class _KasySidebarState extends State<KasySidebar> {
982
988
  // border: the web header (68) on desktop, but the shorter KasyAppBar on
983
989
  // tablet (medium), where the page keeps its own app bar instead of the
984
990
  // header. Without this the line breaks between the rail and the app bar.
985
- final bool isCompact = MediaQuery.sizeOf(context).width < _kBreakpoint;
986
- // The drawer trims a little off the band so there's less dead space below the
987
- // wordmark before the divider (the drawer is an overlay, so its divider has
988
- // no app-bar line to stay aligned with).
991
+ final double viewportWidth = MediaQuery.sizeOf(context).width;
992
+ final bool isCompact = viewportWidth < _kBreakpoint;
993
+ // On phones the sidebar is always an overlay (drawer or full-screen preview),
994
+ // never the inline rail beside a page app bar so mobile positioning is a
995
+ // sidebar default keyed off the phone breakpoint, not isDrawer.
996
+ final bool isMobile = viewportWidth < _kMobileBreakpoint;
997
+ // Mobile trims a little off the band so there's less dead space below the
998
+ // wordmark before the divider; the overlay has no app-bar line to align with.
989
999
  final double bandHeight = !isCompact
990
1000
  ? _kTopBandHeight
991
- : kasyAppBarBodyTopOverlap(context) - (widget.isDrawer ? 26.0 : 0.0);
992
- // In the mobile drawer the band starts right under the status bar notch, so
993
- // nudge the wordmark down a touch to breathe. Restricted to the drawer (an
994
- // overlay) so the inline rail keeps its divider aligned with the app bar /
995
- // web header on tablet and desktop.
996
- final double logoTopInset = widget.isDrawer ? 20.0 : 0.0;
1001
+ : kasyAppBarBodyTopOverlap(context) - (isMobile ? 26.0 : 0.0);
1002
+ // On mobile the band starts right under the status bar notch, so nudge the
1003
+ // brand (and the collapsed toggle, which shares this band) down to breathe.
1004
+ // Tablet/desktop keep it centred so the rail's divider stays aligned with the
1005
+ // app bar / web header.
1006
+ final double logoTopInset = isMobile ? 44.0 : 0.0;
997
1007
  // The collapse toggle is available on every breakpoint so any config can be
998
1008
  // switched thin↔wide — except a drawer, which is a dismissible overlay you
999
1009
  // close whole rather than collapse in place.
@@ -0,0 +1,8 @@
1
+ import 'package:flutter/widgets.dart';
2
+
3
+ /// Shared route observer on the root navigator so screens can react to being
4
+ /// revealed again after a route pushed on top of them pops, via
5
+ /// [RouteAware.didPopNext]. Register it in the router's `observers` list and
6
+ /// subscribe a [RouteAware] state to it.
7
+ final RouteObserver<ModalRoute<dynamic>> kasyRouteObserver =
8
+ RouteObserver<ModalRoute<dynamic>>();
@@ -184,6 +184,31 @@ class KasyTextTheme extends ThemeExtension<KasyTextTheme> {
184
184
  /// Caption, hint, version label, footnote. 12 / w400.
185
185
  TextStyle get caption => bodySmall;
186
186
 
187
+ // --- Overlay & component roles -----------------------------------------
188
+ // The harmonic ladder for surfaces and components: an OVERLAY (a full
189
+ // decision surface — dialog, bottom sheet) carries the largest title (20); an
190
+ // inline COMPONENT header (alert, accordion) sits a step below (16) so it
191
+ // reads as a header WITHIN the component, not a screen section; supporting
192
+ // text everywhere is 14. These are deliberately STABLE across breakpoints —
193
+ // only page/hero headings scale (Material/Apple model). Route every
194
+ // dialog/sheet/alert/accordion through these so titles never drift per
195
+ // component.
196
+
197
+ /// Title of a modal/overlay surface (dialog, bottom sheet, OTP sheet).
198
+ /// 20 / w600 — the largest title tier, for full decision surfaces.
199
+ TextStyle get overlayTitle => titleLarge;
200
+
201
+ /// Header of an inline component (alert, accordion). 16 / w600 — content
202
+ /// emphasis, one clear step below an overlay/section title and above body, so
203
+ /// a compact card never wears a screen-section-sized title.
204
+ TextStyle get componentTitle =>
205
+ bodyLarge.copyWith(fontWeight: FontWeight.w600);
206
+
207
+ /// Supporting text / description inside an overlay or component (the message
208
+ /// under a dialog/sheet/alert title, an accordion body). 14 / w400. Apply the
209
+ /// muted colour and any line-height at the call site.
210
+ TextStyle get supportingText => bodyMedium;
211
+
187
212
  /// Builds the full text theme for a given [device].
188
213
  ///
189
214
  /// Each slot resolves its size from the responsive [KasyTypeScale] ramp at the
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
2
2
  import 'package:go_router/go_router.dart';
3
3
  import 'package:kasy_kit/components/kasy_app_bar.dart';
4
4
  import 'package:kasy_kit/core/haptics/kasy_haptics.dart';
5
+ import 'package:kasy_kit/core/navigation/kasy_route_observer.dart';
5
6
  import 'package:kasy_kit/core/theme/theme.dart';
6
7
  import 'package:kasy_kit/core/widgets/kasy_hover.dart';
7
8
  import 'package:kasy_kit/core/widgets/kasy_scroll_behavior.dart';
@@ -46,18 +47,34 @@ class HomeComponentsCatalog extends StatefulWidget {
46
47
  State<HomeComponentsCatalog> createState() => _HomeComponentsCatalogState();
47
48
  }
48
49
 
49
- class _HomeComponentsCatalogState extends State<HomeComponentsCatalog> {
50
+ class _HomeComponentsCatalogState extends State<HomeComponentsCatalog>
51
+ with RouteAware {
50
52
  final TextEditingController _searchCtrl = TextEditingController();
51
53
  String _query = '';
52
54
 
55
+ @override
56
+ void didChangeDependencies() {
57
+ super.didChangeDependencies();
58
+ final ModalRoute<dynamic>? route = ModalRoute.of(context);
59
+ if (route != null) {
60
+ kasyRouteObserver.subscribe(this, route);
61
+ }
62
+ }
63
+
53
64
  @override
54
65
  void dispose() {
66
+ kasyRouteObserver.unsubscribe(this);
55
67
  _searchCtrl.dispose();
56
68
  super.dispose();
57
69
  }
58
70
 
59
- /// Reset the filter when leaving for a component, so coming back shows the
60
- /// full catalog instead of the stale search term.
71
+ /// When a pushed preview pops back to the catalog, reset the filter so the
72
+ /// list shows the full catalog instead of the stale search term. Backed up by
73
+ /// the navigation future's [_clearSearch] for the admin-embedded case, whose
74
+ /// nested navigator the root observer does not see.
75
+ @override
76
+ void didPopNext() => _clearSearch();
77
+
61
78
  void _clearSearch() {
62
79
  if (!mounted) return;
63
80
  if (_query.isEmpty && _searchCtrl.text.isEmpty) return;
@@ -325,6 +342,7 @@ const Set<String> _kReadyComponents = <String>{
325
342
  'Dialog',
326
343
  'DropDown',
327
344
  'Hover',
345
+ 'Menu',
328
346
  'Tabs',
329
347
  'TextArea',
330
348
  'TextField',
@@ -352,6 +370,7 @@ const Set<String> _kWebReadyComponents = <String>{
352
370
  'Dialog',
353
371
  'DropDown',
354
372
  'Hover',
373
+ 'Menu',
355
374
  'Sidebar',
356
375
  'Skeleton',
357
376
  'Status Tag',
@@ -384,6 +403,7 @@ const List<_CatalogRow> _kCatalog = [
384
403
  _CatalogRow('Dialog'),
385
404
  _CatalogRow('DropDown'),
386
405
  _CatalogRow('Hover'),
406
+ _CatalogRow('Menu'),
387
407
  _CatalogRow('Sidebar'),
388
408
  _CatalogRow('Skeleton'),
389
409
  _CatalogRow('Status Tag'),
@@ -412,7 +432,6 @@ const List<_CatalogRow> _kCatalog = [
412
432
  _CatalogRow('LineChart'),
413
433
  _CatalogRow('LinkButton'),
414
434
  _CatalogRow('ListGroup'),
415
- _CatalogRow('Menu'),
416
435
  _CatalogRow('NumberField'),
417
436
  _CatalogRow('NumberStepper'),
418
437
  _CatalogRow('NumberValue'),