kasy-cli 1.39.0 → 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.
- package/lib/scaffold/CHANGELOG.json +23 -0
- package/package.json +1 -1
- package/templates/firebase/.firebase/hosting.YnVpbGQvd2Vi.cache +27 -27
- package/templates/firebase/AGENTS.md +2 -2
- package/templates/firebase/DESIGN_SYSTEM.md +3 -2
- package/templates/firebase/lib/components/components.dart +6 -1
- package/templates/firebase/lib/components/kasy_app_bar.dart +11 -1
- package/templates/firebase/lib/components/kasy_bottom_sheet.dart +25 -7
- package/templates/firebase/lib/components/kasy_menu.dart +902 -0
- package/templates/firebase/lib/components/kasy_popover.dart +267 -0
- package/templates/firebase/lib/components/kasy_sidebar.dart +20 -10
- package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +23 -7
- package/templates/firebase/lib/core/chrome/app_bar_config.dart +1 -1
- package/templates/firebase/lib/core/navigation/kasy_route_observer.dart +8 -0
- package/templates/firebase/lib/core/rating/widgets/review_popup.dart +96 -14
- package/templates/firebase/lib/core/theme/texts.dart +25 -0
- package/templates/firebase/lib/core/web_viewport_scale.dart +8 -7
- package/templates/firebase/lib/features/home/home_components_page.dart +23 -4
- package/templates/firebase/lib/features/home/home_components_preview_registry.dart +363 -13
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_home_widgets.dart +4 -0
- package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +38 -94
- package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +32 -107
- package/templates/firebase/lib/features/subscriptions/ui/widgets/comparison_table.dart +21 -21
- package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_close_button.dart +35 -27
- package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_col.dart +22 -17
- package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_row.dart +12 -7
- package/templates/firebase/lib/i18n/en.i18n.json +6 -2
- package/templates/firebase/lib/i18n/es.i18n.json +6 -2
- package/templates/firebase/lib/i18n/pt.i18n.json +6 -2
- package/templates/firebase/lib/router.dart +2 -0
- package/templates/firebase/pubspec.yaml +1 -1
- package/templates/firebase/test/app_bar_config_test.dart +70 -0
- 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
|
|
986
|
-
|
|
987
|
-
//
|
|
988
|
-
//
|
|
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) - (
|
|
992
|
-
//
|
|
993
|
-
//
|
|
994
|
-
//
|
|
995
|
-
//
|
|
996
|
-
final double logoTopInset =
|
|
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.
|
|
@@ -41,6 +41,20 @@ class _WebContentWrapperState extends State<WebContentWrapper> {
|
|
|
41
41
|
skipTraversal: true,
|
|
42
42
|
);
|
|
43
43
|
|
|
44
|
+
/// Shell-wide default for the desktop application bar: empty search, theme
|
|
45
|
+
/// toggle, the data-aware notifications bell, quick-create, and no avatar (the
|
|
46
|
+
/// sidebar owns the profile). Screens adapt this via [KasyAppBarConfigurator].
|
|
47
|
+
late final KasyAppBarConfig _defaultAppBarConfig = KasyAppBarConfig(
|
|
48
|
+
onToggleTheme: () => ThemeProvider.of(context).toggle(),
|
|
49
|
+
notifications: const WebNotificationsBell(),
|
|
50
|
+
onCreate: () {},
|
|
51
|
+
showAvatar: false,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
/// Live config the bar renders; starts at the default, screens publish into it.
|
|
55
|
+
late final ValueNotifier<KasyAppBarConfig> _appBarConfig =
|
|
56
|
+
ValueNotifier<KasyAppBarConfig>(_defaultAppBarConfig);
|
|
57
|
+
|
|
44
58
|
@override
|
|
45
59
|
void initState() {
|
|
46
60
|
super.initState();
|
|
@@ -53,6 +67,7 @@ class _WebContentWrapperState extends State<WebContentWrapper> {
|
|
|
53
67
|
kasyContentFocusTarget = null;
|
|
54
68
|
}
|
|
55
69
|
_contentFocus.dispose();
|
|
70
|
+
_appBarConfig.dispose();
|
|
56
71
|
super.dispose();
|
|
57
72
|
}
|
|
58
73
|
|
|
@@ -71,21 +86,22 @@ class _WebContentWrapperState extends State<WebContentWrapper> {
|
|
|
71
86
|
// page-level KasyAppBar hides on desktop here (the application bar owns the
|
|
72
87
|
// chrome). Outside this scope (full-screen pushed routes) the bar stays visible.
|
|
73
88
|
return KasyAppBarScope(
|
|
89
|
+
config: _appBarConfig,
|
|
90
|
+
defaultConfig: _defaultAppBarConfig,
|
|
74
91
|
child: ColoredBox(
|
|
75
92
|
color: context.colors.background,
|
|
76
93
|
child: Column(
|
|
77
94
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
78
95
|
children: [
|
|
79
|
-
//
|
|
80
|
-
//
|
|
96
|
+
// One application bar for the whole shell; each screen tunes it through
|
|
97
|
+
// a KasyAppBarConfigurator that publishes into [_appBarConfig].
|
|
81
98
|
FocusTraversalOrder(
|
|
82
99
|
order: const NumericFocusOrder(2),
|
|
83
100
|
child: FocusTraversalGroup(
|
|
84
|
-
child:
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
showAvatar: false,
|
|
101
|
+
child: ValueListenableBuilder<KasyAppBarConfig>(
|
|
102
|
+
valueListenable: _appBarConfig,
|
|
103
|
+
builder: (context, config, _) =>
|
|
104
|
+
KasyAppBar.fromConfig(config),
|
|
89
105
|
),
|
|
90
106
|
),
|
|
91
107
|
),
|
|
@@ -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>>();
|
|
@@ -1,21 +1,28 @@
|
|
|
1
1
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
2
2
|
import 'package:flutter/material.dart';
|
|
3
3
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
4
|
+
import 'package:go_router/go_router.dart';
|
|
4
5
|
import 'package:kasy_kit/components/components.dart';
|
|
5
6
|
import 'package:kasy_kit/core/data/api/analytics_api.dart';
|
|
7
|
+
import 'package:kasy_kit/core/rating/models/review.dart';
|
|
6
8
|
import 'package:kasy_kit/core/rating/providers/rating_repository.dart';
|
|
7
9
|
import 'package:kasy_kit/core/states/user_state_notifier.dart';
|
|
8
10
|
import 'package:kasy_kit/core/theme/theme.dart';
|
|
9
11
|
import 'package:kasy_kit/i18n/translations.g.dart';
|
|
10
12
|
import 'package:logger/logger.dart';
|
|
11
13
|
|
|
12
|
-
/// Shows the in-app review
|
|
14
|
+
/// Shows the in-app review funnel. Prefer a stable [context] (not a sheet that
|
|
13
15
|
/// will be popped before the async work finishes).
|
|
14
16
|
///
|
|
15
|
-
///
|
|
16
|
-
///
|
|
17
|
-
///
|
|
18
|
-
///
|
|
17
|
+
/// Two-step "rating protection" funnel:
|
|
18
|
+
/// 1. A sentiment gate ("Enjoying the app?") with a positive / negative pick.
|
|
19
|
+
/// 2a. Positive → a gold-star [KasyDialog] that sends the user to the store
|
|
20
|
+
/// (App Store / Play Store) to write a public review.
|
|
21
|
+
/// 2b. Negative → the private `/feedback` screen, so an unhappy user vents to
|
|
22
|
+
/// us instead of leaving a low public rating.
|
|
23
|
+
///
|
|
24
|
+
/// Dismissing any step just defers the next ask and keeps the user where they
|
|
25
|
+
/// are. Returns true once the funnel was shown (regardless of the branch taken).
|
|
19
26
|
Future<bool> showReviewDialog(
|
|
20
27
|
BuildContext context,
|
|
21
28
|
WidgetRef ref, {
|
|
@@ -23,7 +30,7 @@ Future<bool> showReviewDialog(
|
|
|
23
30
|
}) async {
|
|
24
31
|
// Store reviews are native-only (App Store / Play Store). In production the
|
|
25
32
|
// auto prompt never shows on web (nowhere to send the user). But a forced
|
|
26
|
-
// call — the admin preview — still shows the
|
|
33
|
+
// call — the admin preview — still shows the funnel on web so its design can
|
|
27
34
|
// be reviewed there; only the store action won't do anything.
|
|
28
35
|
if (kIsWeb && !force) {
|
|
29
36
|
return false;
|
|
@@ -46,26 +53,102 @@ Future<bool> showReviewDialog(
|
|
|
46
53
|
return false;
|
|
47
54
|
}
|
|
48
55
|
|
|
49
|
-
|
|
56
|
+
// Defer the next ask up front, so any outcome (dismiss, positive, negative)
|
|
57
|
+
// counts as "asked" and we don't pester the user again right away.
|
|
58
|
+
await rating.delay();
|
|
59
|
+
analytics.logEvent('rating_funnel_open', {});
|
|
60
|
+
if (!context.mounted) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Step 1 — sentiment gate.
|
|
65
|
+
final bool? enjoying = await _askEnjoying(context, analytics);
|
|
66
|
+
if (enjoying == null) {
|
|
67
|
+
return true; // dismissed without choosing
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Step 2b — negative branch: keep the bad review private.
|
|
71
|
+
if (!enjoying) {
|
|
72
|
+
analytics.logEvent('rating_funnel_negative', {});
|
|
73
|
+
if (context.mounted) {
|
|
74
|
+
await context.push('/feedback');
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Step 2a — positive branch: send the happy user to the store.
|
|
80
|
+
analytics.logEvent('rating_funnel_positive', {});
|
|
81
|
+
if (!context.mounted) {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
await _askStoreReview(context, ratingRepository, rating, analytics);
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/// Step 1: the sentiment gate. Returns true (enjoying), false (could be better)
|
|
89
|
+
/// or null (dismissed without choosing).
|
|
90
|
+
Future<bool?> _askEnjoying(BuildContext context, AnalyticsApi analytics) {
|
|
91
|
+
return showKasyDialog<bool>(
|
|
50
92
|
context: context,
|
|
51
93
|
builder: (dialogContext) {
|
|
52
|
-
|
|
53
|
-
|
|
94
|
+
final t = Translations.of(dialogContext).review_popup;
|
|
95
|
+
return KasyDialog(
|
|
96
|
+
leadingIcon: KasyIcons.favorite,
|
|
97
|
+
iconTone: KasyDialogIconTone.info,
|
|
98
|
+
title: t.question_title,
|
|
99
|
+
titleCentered: true,
|
|
100
|
+
message: t.question_description,
|
|
101
|
+
onClose: () {
|
|
102
|
+
analytics.logEvent('rating_funnel_dismiss', {});
|
|
103
|
+
Navigator.of(dialogContext).pop();
|
|
104
|
+
},
|
|
105
|
+
footer: Column(
|
|
106
|
+
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
107
|
+
children: [
|
|
108
|
+
KasyButton(
|
|
109
|
+
label: t.question_positive,
|
|
110
|
+
expand: true,
|
|
111
|
+
onPressed: () => Navigator.of(dialogContext).pop(true),
|
|
112
|
+
),
|
|
113
|
+
const SizedBox(height: KasySpacing.sm),
|
|
114
|
+
KasyButton(
|
|
115
|
+
label: t.question_negative,
|
|
116
|
+
variant: KasyButtonVariant.soft,
|
|
117
|
+
expand: true,
|
|
118
|
+
onPressed: () => Navigator.of(dialogContext).pop(false),
|
|
119
|
+
),
|
|
120
|
+
],
|
|
121
|
+
),
|
|
122
|
+
);
|
|
123
|
+
},
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/// Step 2a (positive branch): the celebratory store-review dialog.
|
|
128
|
+
Future<void> _askStoreReview(
|
|
129
|
+
BuildContext context,
|
|
130
|
+
RatingRepository ratingRepository,
|
|
131
|
+
Review rating,
|
|
132
|
+
AnalyticsApi analytics,
|
|
133
|
+
) {
|
|
134
|
+
return showKasyDialog<void>(
|
|
135
|
+
context: context,
|
|
136
|
+
builder: (dialogContext) {
|
|
137
|
+
final t = Translations.of(dialogContext).review_popup;
|
|
54
138
|
return KasyDialog(
|
|
55
139
|
leadingIcon: KasyIcons.star,
|
|
56
140
|
// Gold/amber star via KasyDialog's default `warning` tone — the design
|
|
57
141
|
// system's amber (#F5A524 light / #F7B750 dark), the natural colour for
|
|
58
142
|
// a rating star.
|
|
59
|
-
title:
|
|
143
|
+
title: t.title,
|
|
60
144
|
titleCentered: true,
|
|
61
|
-
message:
|
|
145
|
+
message: t.description,
|
|
62
146
|
onClose: () {
|
|
63
147
|
analytics.logEvent('rating_popup_close', {});
|
|
64
|
-
rating.delay();
|
|
65
148
|
Navigator.of(dialogContext).pop();
|
|
66
149
|
},
|
|
67
150
|
footer: KasyButton(
|
|
68
|
-
label:
|
|
151
|
+
label: t.rate_button,
|
|
69
152
|
expand: true,
|
|
70
153
|
onPressed: () {
|
|
71
154
|
analytics.logEvent('rating_popup_show', {});
|
|
@@ -78,5 +161,4 @@ Future<bool> showReviewDialog(
|
|
|
78
161
|
);
|
|
79
162
|
},
|
|
80
163
|
);
|
|
81
|
-
return true;
|
|
82
164
|
}
|
|
@@ -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
|
|
@@ -7,11 +7,12 @@ import 'package:kasy_kit/core/web_screen_width.dart';
|
|
|
7
7
|
///
|
|
8
8
|
/// Flutter web renders ~10% larger than an equivalent native/HTML app at the
|
|
9
9
|
/// browser's 100% zoom, at any width — so the whole web UI feels oversized.
|
|
10
|
-
/// `0.
|
|
11
|
-
/// without the user touching browser zoom — a gentle correction (
|
|
12
|
-
/// midpoint between full size and a total undo
|
|
13
|
-
///
|
|
14
|
-
///
|
|
10
|
+
/// `0.93` walks back ~7% so the web app reads close to the native baseline
|
|
11
|
+
/// without the user touching browser zoom — a gentle correction (a bit stronger
|
|
12
|
+
/// than the midpoint between full size and a total undo, but still short of
|
|
13
|
+
/// fully undoing the 10%) that takes the "oversized" edge off Flutter web
|
|
14
|
+
/// without making things feel small (0.90 fully undoes the 10% but reads small
|
|
15
|
+
/// on a monitor — one number to nudge). On desktop it also acts
|
|
15
16
|
/// as a cap: a
|
|
16
17
|
/// screen below the design target (high OS scale) reduces it further to pin the
|
|
17
18
|
/// layout (see [kWebViewportScaleTargetWidth]); phone/tablet take the flat cap
|
|
@@ -24,11 +25,11 @@ import 'package:kasy_kit/core/web_screen_width.dart';
|
|
|
24
25
|
/// - The in-app DEVICE PREVIEW — it simulates a native device, so it must show
|
|
25
26
|
/// the native 1.0 truth; the scale is skipped once the preview frame is up
|
|
26
27
|
/// (see main.dart, gated on `webDevicePreviewActiveNotifier`).
|
|
27
|
-
const double kWebViewportScale = 0.
|
|
28
|
+
const double kWebViewportScale = 0.93;
|
|
28
29
|
|
|
29
30
|
/// Master on/off for the web render scale — the single knob.
|
|
30
31
|
///
|
|
31
|
-
/// `true` (default): the web app is rendered ~
|
|
32
|
+
/// `true` (default): the web app is rendered ~7% smaller than native via
|
|
32
33
|
/// [kWebViewportScale], correcting Flutter web's oversized feel on desktop while
|
|
33
34
|
/// native stays at true 1.0. This is a deliberate, web-only density correction
|
|
34
35
|
/// (the same technique `responsive_framework`'s autoScale productizes), applied
|