kasy-cli 1.19.3 → 1.20.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/bin/kasy.js +1 -0
- package/lib/commands/new.js +9 -0
- package/lib/commands/run.js +7 -0
- package/lib/scaffold/backends/firebase/setup-from-scratch.js +91 -2
- package/lib/scaffold/engine.js +5 -0
- package/lib/scaffold/generate.js +4 -0
- package/lib/scaffold/shared/generator-utils.js +38 -1
- package/lib/utils/i18n/messages-en.js +1 -0
- package/lib/utils/i18n/messages-es.js +1 -0
- package/lib/utils/i18n/messages-pt.js +1 -0
- package/package.json +1 -1
- package/templates/firebase/android/app/src/main/res/drawable/background.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-night/background.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-night-v21/background.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-v21/background.png +0 -0
- package/templates/firebase/android/app/src/main/res/values-night-v31/styles.xml +1 -1
- package/templates/firebase/android/app/src/main/res/values-v31/styles.xml +1 -1
- package/templates/firebase/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png +0 -0
- package/templates/firebase/lib/components/kasy_date_picker.dart +14 -8
- package/templates/firebase/lib/components/kasy_sidebar_pro.dart +1148 -0
- package/templates/firebase/lib/components/kasy_tabs.dart +156 -43
- package/templates/firebase/lib/components/kasy_text_field.dart +37 -34
- package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +90 -69
- package/templates/firebase/lib/core/bottom_menu/bottom_router.dart +30 -7
- package/templates/firebase/lib/core/bottom_menu/kasy_bottom_bar_factory.dart +8 -1
- package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +433 -243
- package/templates/firebase/lib/core/dev_inspector/dev_inspector_service.dart +198 -83
- package/templates/firebase/lib/core/icons/kasy_icons.dart +1 -0
- package/templates/firebase/lib/core/theme/colors.dart +6 -2
- package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +119 -19
- package/templates/firebase/lib/core/widgets/kasy_hover.dart +68 -27
- package/templates/firebase/lib/features/home/home_components_page.dart +3 -0
- package/templates/firebase/lib/features/home/home_components_preview_registry.dart +121 -66
- package/templates/firebase/lib/features/settings/settings_page.dart +27 -146
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_bottom_sheet.dart +16 -3
- package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +22 -5
- package/templates/firebase/lib/i18n/en.i18n.json +3 -1
- package/templates/firebase/lib/i18n/es.i18n.json +3 -1
- package/templates/firebase/lib/i18n/pt.i18n.json +3 -1
- package/templates/firebase/pubspec.yaml +6 -4
- package/templates/firebase/web/index.html +7 -17
- package/templates/firebase/lib/firebase_options.dart +0 -75
|
@@ -0,0 +1,1148 @@
|
|
|
1
|
+
import 'dart:async';
|
|
2
|
+
|
|
3
|
+
import 'package:flutter/material.dart';
|
|
4
|
+
import 'package:kasy_kit/core/icons/kasy_icons.dart';
|
|
5
|
+
import 'package:kasy_kit/core/widgets/kasy_hover.dart';
|
|
6
|
+
|
|
7
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
8
|
+
// Design tokens — from Figma (node 2:1331 / 6:114)
|
|
9
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
const double _kWidthOpen = 256.0;
|
|
12
|
+
const double _kWidthCollapsed = 92.0;
|
|
13
|
+
const double _kPad = 24.0;
|
|
14
|
+
const double _kItemRadius = 8.0;
|
|
15
|
+
const double _kItemVPad = 10.0;
|
|
16
|
+
const double _kItemHPad = 12.0;
|
|
17
|
+
const double _kItemGap = 8.0;
|
|
18
|
+
const double _kSubItemH = 32.0;
|
|
19
|
+
const double _kSubItemGap = 4.0;
|
|
20
|
+
const double _kSubIndent = 36.0;
|
|
21
|
+
const double _kTreeConnectorW = 13.0;
|
|
22
|
+
const double _kCollapseButtonSize = 28.0;
|
|
23
|
+
|
|
24
|
+
/// Returns the height of the vertical tree line for [n] sub-items.
|
|
25
|
+
double _treeLineHeight(int n) =>
|
|
26
|
+
(n * (_kSubItemH + _kSubItemGap) - _kSubItemGap) * 0.8627;
|
|
27
|
+
|
|
28
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
// Color palette
|
|
30
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
enum _SidebarColors {
|
|
33
|
+
light(
|
|
34
|
+
bg: Color(0xFFFFFFFF),
|
|
35
|
+
border: Color(0x1A000000),
|
|
36
|
+
divider: Color(0xFFF6F6F6),
|
|
37
|
+
activeBg: Color(0xFFF6F6F6),
|
|
38
|
+
textMuted: Color(0xFF757575),
|
|
39
|
+
textActive: Color(0xFF000000),
|
|
40
|
+
logout: Color(0xFFD55F5A),
|
|
41
|
+
),
|
|
42
|
+
dark(
|
|
43
|
+
bg: Color(0xFF161A23),
|
|
44
|
+
border: Color(0x1AFFFFFF),
|
|
45
|
+
divider: Color(0xFF2D2F39),
|
|
46
|
+
activeBg: Color(0xFF2D2F39),
|
|
47
|
+
textMuted: Color(0x80FFFFFF),
|
|
48
|
+
textActive: Color(0xCCFFFFFF),
|
|
49
|
+
logout: Color(0xFFCC8889),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const _SidebarColors({
|
|
53
|
+
required this.bg,
|
|
54
|
+
required this.border,
|
|
55
|
+
required this.divider,
|
|
56
|
+
required this.activeBg,
|
|
57
|
+
required this.textMuted,
|
|
58
|
+
required this.textActive,
|
|
59
|
+
required this.logout,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
final Color bg;
|
|
63
|
+
final Color border;
|
|
64
|
+
final Color divider;
|
|
65
|
+
final Color activeBg;
|
|
66
|
+
final Color textMuted;
|
|
67
|
+
final Color textActive;
|
|
68
|
+
final Color logout;
|
|
69
|
+
|
|
70
|
+
/// True when this palette is the dark-mode variant.
|
|
71
|
+
/// Used by overlay widgets (tooltips, popups) that cannot rely on
|
|
72
|
+
/// Theme.of(overlayContext) since overlay contexts may not inherit the
|
|
73
|
+
/// app theme correctly.
|
|
74
|
+
bool get isDark => identical(this, dark) || bg == dark.bg;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
78
|
+
// Popup shadow — simplified from 6-layer Figma spec
|
|
79
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
const List<BoxShadow> _kPopupShadow = [
|
|
82
|
+
BoxShadow(color: Color(0x12000000), blurRadius: 40, offset: Offset(0, 16)),
|
|
83
|
+
BoxShadow(color: Color(0x0D000000), blurRadius: 17, offset: Offset(0, 8)),
|
|
84
|
+
BoxShadow(color: Color(0x08000000), blurRadius: 5, offset: Offset(0, 2)),
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
88
|
+
// Nav item model
|
|
89
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
class _NavItem {
|
|
92
|
+
const _NavItem({
|
|
93
|
+
required this.id,
|
|
94
|
+
required this.icon,
|
|
95
|
+
required this.label,
|
|
96
|
+
this.subItems = const [],
|
|
97
|
+
this.isLogout = false,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
final String id;
|
|
101
|
+
final IconData icon;
|
|
102
|
+
final String label;
|
|
103
|
+
final List<String> subItems;
|
|
104
|
+
final bool isLogout;
|
|
105
|
+
|
|
106
|
+
bool get hasSubmenu => subItems.isNotEmpty;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
110
|
+
// Static nav data (mirrors the Figma)
|
|
111
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
const List<_NavItem> _kMainItems = [
|
|
114
|
+
_NavItem(id: 'dashboard', icon: KasyIcons.home, label: 'Dashboard'),
|
|
115
|
+
_NavItem(id: 'audience', icon: KasyIcons.person, label: 'Audience'),
|
|
116
|
+
_NavItem(id: 'posts', icon: KasyIcons.note, label: 'Posts'),
|
|
117
|
+
_NavItem(id: 'schedules', icon: KasyIcons.calendar, label: 'Schedules'),
|
|
118
|
+
_NavItem(
|
|
119
|
+
id: 'income',
|
|
120
|
+
icon: KasyIcons.book,
|
|
121
|
+
label: 'Income',
|
|
122
|
+
subItems: ['Earnings', 'Refunds', 'Declines', 'Payouts'],
|
|
123
|
+
),
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
const _NavItem _kSettingsItem = _NavItem(
|
|
127
|
+
id: 'settings',
|
|
128
|
+
icon: KasyIcons.settings,
|
|
129
|
+
label: 'Settings',
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const List<_NavItem> _kBottomItems = [
|
|
133
|
+
_NavItem(id: 'help', icon: KasyIcons.help, label: 'Help'),
|
|
134
|
+
_NavItem(
|
|
135
|
+
id: 'logout',
|
|
136
|
+
icon: KasyIcons.logout,
|
|
137
|
+
label: 'Logout Account',
|
|
138
|
+
isLogout: true,
|
|
139
|
+
),
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
143
|
+
// KasySidebarPro — public widget
|
|
144
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
/// Which screen edge the sidebar is anchored to.
|
|
147
|
+
///
|
|
148
|
+
/// Controls which corners receive [BorderRadius]: the edge touching the screen
|
|
149
|
+
/// has flat (zero) corners; the opposite edge gets the rounded corners.
|
|
150
|
+
enum KasySidebarProSide { left, right }
|
|
151
|
+
|
|
152
|
+
/// A pro-style collapsible sidebar with expandable submenu tree, hover popups
|
|
153
|
+
/// in collapsed mode, and full light / dark mode support.
|
|
154
|
+
///
|
|
155
|
+
/// Pass [onSettingsTap] to respond when the user taps Settings.
|
|
156
|
+
/// The sidebar manages its own collapsed state and sub-menu expansion.
|
|
157
|
+
class KasySidebarPro extends StatefulWidget {
|
|
158
|
+
const KasySidebarPro({
|
|
159
|
+
super.key,
|
|
160
|
+
this.onSettingsTap,
|
|
161
|
+
this.initiallyCollapsed = false,
|
|
162
|
+
this.side = KasySidebarProSide.left,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
final VoidCallback? onSettingsTap;
|
|
166
|
+
|
|
167
|
+
/// Whether the sidebar starts in the narrow (icon-only) mode.
|
|
168
|
+
final bool initiallyCollapsed;
|
|
169
|
+
|
|
170
|
+
/// The screen edge this sidebar is anchored to.
|
|
171
|
+
///
|
|
172
|
+
/// [KasySidebarProSide.left] → flat left corners, rounded right corners.
|
|
173
|
+
/// [KasySidebarProSide.right] → flat right corners, rounded left corners.
|
|
174
|
+
final KasySidebarProSide side;
|
|
175
|
+
|
|
176
|
+
@override
|
|
177
|
+
State<KasySidebarPro> createState() => _KasySidebarProState();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
class _KasySidebarProState extends State<KasySidebarPro> {
|
|
181
|
+
// User's explicit open/close preference (set by tapping the toggle button).
|
|
182
|
+
late bool _userChoseCollapsed;
|
|
183
|
+
|
|
184
|
+
// Computed at the start of build() — true when user chose narrow OR viewport
|
|
185
|
+
// is below the breakpoint. All submethods read this field directly.
|
|
186
|
+
bool _collapsed = false;
|
|
187
|
+
|
|
188
|
+
bool _incomeExpanded = false;
|
|
189
|
+
String _activeItemId = 'dashboard';
|
|
190
|
+
String _activeSubItem = '';
|
|
191
|
+
|
|
192
|
+
/// Viewport width below which the sidebar auto-collapses (tablet breakpoint).
|
|
193
|
+
static const double _kBreakpoint = 1024.0;
|
|
194
|
+
|
|
195
|
+
@override
|
|
196
|
+
void initState() {
|
|
197
|
+
super.initState();
|
|
198
|
+
_userChoseCollapsed = widget.initiallyCollapsed;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
bool _isViewportNarrow(BuildContext context) =>
|
|
202
|
+
MediaQuery.sizeOf(context).width < _kBreakpoint;
|
|
203
|
+
|
|
204
|
+
// ── Color helpers ──────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
_SidebarColors get _colors =>
|
|
207
|
+
Theme.of(context).brightness == Brightness.dark
|
|
208
|
+
? _SidebarColors.dark
|
|
209
|
+
: _SidebarColors.light;
|
|
210
|
+
|
|
211
|
+
// ── Actions ───────────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
// Only the user's preference toggles — never the viewport-forced state.
|
|
214
|
+
void _toggleCollapse() =>
|
|
215
|
+
setState(() => _userChoseCollapsed = !_userChoseCollapsed);
|
|
216
|
+
|
|
217
|
+
void _activateItem(String id) {
|
|
218
|
+
if (id == 'settings') {
|
|
219
|
+
widget.onSettingsTap?.call();
|
|
220
|
+
} else if (id == 'income') {
|
|
221
|
+
setState(() => _incomeExpanded = !_incomeExpanded);
|
|
222
|
+
}
|
|
223
|
+
setState(() {
|
|
224
|
+
_activeItemId = id;
|
|
225
|
+
// Leaving Income → clear the active sub-item so it doesn't stay
|
|
226
|
+
// highlighted when the user is on a completely different page.
|
|
227
|
+
if (id != 'income') _activeSubItem = '';
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
void _activateSubItem(String label) => setState(() {
|
|
232
|
+
_activeSubItem = label;
|
|
233
|
+
_activeItemId = 'income'; // sub-item belongs to Income → highlight it
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// ── Build ─────────────────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
@override
|
|
239
|
+
Widget build(BuildContext context) {
|
|
240
|
+
// Recompute effective collapsed state every build so viewport changes
|
|
241
|
+
// are picked up automatically without any extra listener.
|
|
242
|
+
_collapsed = _userChoseCollapsed || _isViewportNarrow(context);
|
|
243
|
+
|
|
244
|
+
final c = _colors;
|
|
245
|
+
|
|
246
|
+
final bool anchoredLeft = widget.side == KasySidebarProSide.left;
|
|
247
|
+
const Radius r = Radius.circular(12);
|
|
248
|
+
final BorderRadius borderRadius = anchoredLeft
|
|
249
|
+
? const BorderRadius.only(topRight: r, bottomRight: r)
|
|
250
|
+
: const BorderRadius.only(topLeft: r, bottomLeft: r);
|
|
251
|
+
final Border border = anchoredLeft
|
|
252
|
+
? Border(right: BorderSide(color: c.border))
|
|
253
|
+
: Border(left: BorderSide(color: c.border));
|
|
254
|
+
|
|
255
|
+
// The outer AnimatedContainer is extended by half the button width (14px)
|
|
256
|
+
// on the side where the collapse button protrudes. This makes the button
|
|
257
|
+
// visually straddle the edge AND stay within parent bounds — Flutter only
|
|
258
|
+
// delivers hit tests to widgets inside their parent's bounds.
|
|
259
|
+
//
|
|
260
|
+
// Layout: left-anchored example
|
|
261
|
+
// ┌── outer (256+14) ─────────────────────────┐
|
|
262
|
+
// │ ┌── sidebar box (256) ──────────┐ │
|
|
263
|
+
// │ │ content │ [button] │
|
|
264
|
+
// │ └───────────────────────────────┘ │
|
|
265
|
+
// └────────────────────────────────────────────┘
|
|
266
|
+
//
|
|
267
|
+
// The 14px transparent strip on the right holds the outer half of the
|
|
268
|
+
// button, giving it a full 28×28 hittable area.
|
|
269
|
+
const double halfBtn = _kCollapseButtonSize / 2; // 14px
|
|
270
|
+
|
|
271
|
+
return Material(
|
|
272
|
+
type: MaterialType.transparency,
|
|
273
|
+
child: AnimatedContainer(
|
|
274
|
+
duration: const Duration(milliseconds: 220),
|
|
275
|
+
curve: Curves.easeInOut,
|
|
276
|
+
width: (_collapsed ? _kWidthCollapsed : _kWidthOpen) + halfBtn,
|
|
277
|
+
child: Stack(
|
|
278
|
+
children: [
|
|
279
|
+
// Sidebar background + content (leaves halfBtn space for button).
|
|
280
|
+
Positioned(
|
|
281
|
+
left: anchoredLeft ? 0 : halfBtn,
|
|
282
|
+
right: anchoredLeft ? halfBtn : 0,
|
|
283
|
+
top: 0,
|
|
284
|
+
bottom: 0,
|
|
285
|
+
child: ClipRRect(
|
|
286
|
+
borderRadius: borderRadius,
|
|
287
|
+
child: DecoratedBox(
|
|
288
|
+
decoration: BoxDecoration(
|
|
289
|
+
color: c.bg,
|
|
290
|
+
border: border,
|
|
291
|
+
borderRadius: borderRadius,
|
|
292
|
+
),
|
|
293
|
+
child: _buildScrollableContent(context, c),
|
|
294
|
+
),
|
|
295
|
+
),
|
|
296
|
+
),
|
|
297
|
+
// Collapse button — centered on the sidebar edge, fully within
|
|
298
|
+
// the outer container so hit tests work correctly.
|
|
299
|
+
_buildCollapseButton(context, c),
|
|
300
|
+
],
|
|
301
|
+
),
|
|
302
|
+
),
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── Scrollable content ────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
Widget _buildScrollableContent(BuildContext context, _SidebarColors c) {
|
|
309
|
+
// Use a Column that fills the sidebar height.
|
|
310
|
+
// Top section scrolls if content overflows; bottom items (Help + Logout)
|
|
311
|
+
// stay pinned to the bottom via Spacer in the outer Column.
|
|
312
|
+
return Positioned.fill(
|
|
313
|
+
child: Column(
|
|
314
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
315
|
+
children: [
|
|
316
|
+
// ── Scrollable nav area ──────────────────────────────────────────
|
|
317
|
+
Expanded(
|
|
318
|
+
child: SingleChildScrollView(
|
|
319
|
+
padding: const EdgeInsets.fromLTRB(
|
|
320
|
+
_kPad, _kPad, _kPad, 0,
|
|
321
|
+
),
|
|
322
|
+
child: Column(
|
|
323
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
324
|
+
children: [
|
|
325
|
+
_buildProfile(context, c),
|
|
326
|
+
const SizedBox(height: _kPad),
|
|
327
|
+
_buildDivider(c),
|
|
328
|
+
const SizedBox(height: _kPad),
|
|
329
|
+
_buildSectionLabel('MAIN', c),
|
|
330
|
+
const SizedBox(height: _kItemGap),
|
|
331
|
+
..._kMainItems.map((item) => _buildNavItem(context, item, c)),
|
|
332
|
+
const SizedBox(height: _kPad),
|
|
333
|
+
_buildDivider(c),
|
|
334
|
+
const SizedBox(height: _kPad),
|
|
335
|
+
_buildSectionLabel('SETTINGS', c),
|
|
336
|
+
const SizedBox(height: _kItemGap),
|
|
337
|
+
_buildNavItem(context, _kSettingsItem, c),
|
|
338
|
+
],
|
|
339
|
+
),
|
|
340
|
+
),
|
|
341
|
+
),
|
|
342
|
+
// ── Bottom items pinned ──────────────────────────────────────────
|
|
343
|
+
Padding(
|
|
344
|
+
padding: const EdgeInsets.fromLTRB(
|
|
345
|
+
_kPad, _kItemGap, _kPad, _kPad,
|
|
346
|
+
),
|
|
347
|
+
child: Column(
|
|
348
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
349
|
+
children: _kBottomItems
|
|
350
|
+
.map((item) => _buildNavItem(context, item, c))
|
|
351
|
+
.toList(),
|
|
352
|
+
),
|
|
353
|
+
),
|
|
354
|
+
],
|
|
355
|
+
),
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── Profile ───────────────────────────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
Widget _buildProfile(BuildContext context, _SidebarColors c) {
|
|
362
|
+
const double avatarSize = 44;
|
|
363
|
+
final Widget avatar = Container(
|
|
364
|
+
width: avatarSize,
|
|
365
|
+
height: avatarSize,
|
|
366
|
+
decoration: BoxDecoration(
|
|
367
|
+
color: c.divider,
|
|
368
|
+
shape: BoxShape.circle,
|
|
369
|
+
),
|
|
370
|
+
child: Icon(KasyIcons.person, size: 20, color: c.textMuted),
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
return Row(
|
|
374
|
+
children: [
|
|
375
|
+
avatar,
|
|
376
|
+
AnimatedSize(
|
|
377
|
+
duration: const Duration(milliseconds: 220),
|
|
378
|
+
curve: Curves.easeInOut,
|
|
379
|
+
child: _collapsed
|
|
380
|
+
? const SizedBox.shrink()
|
|
381
|
+
: Row(
|
|
382
|
+
mainAxisSize: MainAxisSize.min,
|
|
383
|
+
children: [
|
|
384
|
+
const SizedBox(width: 12),
|
|
385
|
+
Flexible(
|
|
386
|
+
child: Column(
|
|
387
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
388
|
+
mainAxisSize: MainAxisSize.min,
|
|
389
|
+
children: [
|
|
390
|
+
Text(
|
|
391
|
+
'PRODUCT MANAGER',
|
|
392
|
+
style: TextStyle(
|
|
393
|
+
fontSize: 10,
|
|
394
|
+
fontWeight: FontWeight.w500,
|
|
395
|
+
color: c.textMuted,
|
|
396
|
+
letterSpacing: 0.4,
|
|
397
|
+
),
|
|
398
|
+
),
|
|
399
|
+
const SizedBox(height: 4),
|
|
400
|
+
Text(
|
|
401
|
+
'Andrew Smith',
|
|
402
|
+
style: TextStyle(
|
|
403
|
+
fontSize: 14,
|
|
404
|
+
fontWeight: FontWeight.w500,
|
|
405
|
+
color: c.textActive,
|
|
406
|
+
),
|
|
407
|
+
),
|
|
408
|
+
],
|
|
409
|
+
),
|
|
410
|
+
),
|
|
411
|
+
],
|
|
412
|
+
),
|
|
413
|
+
),
|
|
414
|
+
],
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ── Divider ───────────────────────────────────────────────────────────────
|
|
419
|
+
|
|
420
|
+
Widget _buildDivider(_SidebarColors c) {
|
|
421
|
+
return Container(
|
|
422
|
+
height: 2,
|
|
423
|
+
decoration: BoxDecoration(
|
|
424
|
+
color: c.divider,
|
|
425
|
+
borderRadius: BorderRadius.circular(2),
|
|
426
|
+
),
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ── Section label ─────────────────────────────────────────────────────────
|
|
431
|
+
|
|
432
|
+
Widget _buildSectionLabel(String label, _SidebarColors c) {
|
|
433
|
+
final TextStyle style = TextStyle(
|
|
434
|
+
fontSize: 10,
|
|
435
|
+
fontWeight: FontWeight.w500,
|
|
436
|
+
color: c.textMuted,
|
|
437
|
+
letterSpacing: 0.4,
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
if (_collapsed) {
|
|
441
|
+
// Centered + clipped so long labels (e.g. SETTINGS) don't overflow.
|
|
442
|
+
return Center(
|
|
443
|
+
child: Text(
|
|
444
|
+
label,
|
|
445
|
+
style: style,
|
|
446
|
+
overflow: TextOverflow.clip,
|
|
447
|
+
softWrap: false,
|
|
448
|
+
textAlign: TextAlign.center,
|
|
449
|
+
),
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return Padding(
|
|
454
|
+
padding: const EdgeInsets.only(left: _kItemHPad),
|
|
455
|
+
child: Text(label, style: style),
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ── Nav item ──────────────────────────────────────────────────────────────
|
|
460
|
+
|
|
461
|
+
Widget _buildNavItem(
|
|
462
|
+
BuildContext context,
|
|
463
|
+
_NavItem item,
|
|
464
|
+
_SidebarColors c,
|
|
465
|
+
) {
|
|
466
|
+
if (item.hasSubmenu) {
|
|
467
|
+
return _buildDropdownItem(context, item, c);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
final bool isActive = _activeItemId == item.id;
|
|
471
|
+
// Active bg is explicit; hover bg comes from KasyHover.hoverColor.
|
|
472
|
+
final Color bg = isActive ? c.activeBg : Colors.transparent;
|
|
473
|
+
final Color iconColor = item.isLogout
|
|
474
|
+
? c.logout
|
|
475
|
+
: (isActive ? c.textActive : c.textMuted);
|
|
476
|
+
final Color textColor = item.isLogout ? c.logout : iconColor;
|
|
477
|
+
|
|
478
|
+
// Collapsed: icon only, optionally with hover popup for sub-items
|
|
479
|
+
if (_collapsed) {
|
|
480
|
+
return _buildCollapsedIcon(context, item, bg, iconColor, c);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return Padding(
|
|
484
|
+
padding: const EdgeInsets.only(bottom: _kItemGap),
|
|
485
|
+
child: KasyHover(
|
|
486
|
+
borderRadius: BorderRadius.circular(_kItemRadius),
|
|
487
|
+
hoverColor: c.activeBg,
|
|
488
|
+
pressColor: c.activeBg,
|
|
489
|
+
onTap: () => _activateItem(item.id),
|
|
490
|
+
child: Container(
|
|
491
|
+
padding: const EdgeInsets.symmetric(
|
|
492
|
+
horizontal: _kItemHPad,
|
|
493
|
+
vertical: _kItemVPad,
|
|
494
|
+
),
|
|
495
|
+
decoration: BoxDecoration(
|
|
496
|
+
color: bg,
|
|
497
|
+
borderRadius: BorderRadius.circular(_kItemRadius),
|
|
498
|
+
),
|
|
499
|
+
child: Row(
|
|
500
|
+
children: [
|
|
501
|
+
_ProIcon(icon: item.icon, color: iconColor, active: isActive),
|
|
502
|
+
const SizedBox(width: 12),
|
|
503
|
+
Expanded(
|
|
504
|
+
child: Text(
|
|
505
|
+
item.label,
|
|
506
|
+
style: TextStyle(
|
|
507
|
+
fontSize: 14,
|
|
508
|
+
fontWeight: isActive ? FontWeight.w600 : FontWeight.w500,
|
|
509
|
+
color: textColor,
|
|
510
|
+
letterSpacing: -0.28,
|
|
511
|
+
),
|
|
512
|
+
),
|
|
513
|
+
),
|
|
514
|
+
],
|
|
515
|
+
),
|
|
516
|
+
),
|
|
517
|
+
),
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ── Collapsed icon button ─────────────────────────────────────────────────
|
|
522
|
+
|
|
523
|
+
Widget _buildCollapsedIcon(
|
|
524
|
+
BuildContext context,
|
|
525
|
+
_NavItem item,
|
|
526
|
+
Color bg,
|
|
527
|
+
Color iconColor,
|
|
528
|
+
_SidebarColors c,
|
|
529
|
+
) {
|
|
530
|
+
return Padding(
|
|
531
|
+
padding: const EdgeInsets.only(bottom: _kItemGap),
|
|
532
|
+
child: _ProTooltipIcon(
|
|
533
|
+
icon: item.icon,
|
|
534
|
+
label: item.label,
|
|
535
|
+
iconBg: bg,
|
|
536
|
+
iconColor: iconColor,
|
|
537
|
+
activeBg: c.activeBg,
|
|
538
|
+
colors: c,
|
|
539
|
+
onTap: () => _activateItem(item.id),
|
|
540
|
+
),
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ── Dropdown item (Income) ────────────────────────────────────────────────
|
|
545
|
+
|
|
546
|
+
Widget _buildDropdownItem(
|
|
547
|
+
BuildContext context,
|
|
548
|
+
_NavItem item,
|
|
549
|
+
_SidebarColors c,
|
|
550
|
+
) {
|
|
551
|
+
final bool isActive = _activeItemId == item.id;
|
|
552
|
+
final Color bg = isActive ? c.activeBg : Colors.transparent;
|
|
553
|
+
final Color iconColor = isActive ? c.textActive : c.textMuted;
|
|
554
|
+
|
|
555
|
+
if (_collapsed) {
|
|
556
|
+
// In collapsed mode: icon that shows a floating popup on hover
|
|
557
|
+
return Padding(
|
|
558
|
+
padding: const EdgeInsets.only(bottom: _kItemGap),
|
|
559
|
+
child: _ProHoverPopupIcon(
|
|
560
|
+
icon: item.icon,
|
|
561
|
+
iconBg: bg,
|
|
562
|
+
iconColor: iconColor,
|
|
563
|
+
subItems: item.subItems,
|
|
564
|
+
activeSubItem: _activeSubItem,
|
|
565
|
+
colors: c,
|
|
566
|
+
onSubItemTap: _activateSubItem,
|
|
567
|
+
),
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return Column(
|
|
572
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
573
|
+
children: [
|
|
574
|
+
// Header row — no bottom padding here; the SizedBox at the bottom of
|
|
575
|
+
// this Column provides the single trailing gap (same as other items).
|
|
576
|
+
KasyHover(
|
|
577
|
+
borderRadius: BorderRadius.circular(_kItemRadius),
|
|
578
|
+
hoverColor: c.activeBg,
|
|
579
|
+
pressColor: c.activeBg,
|
|
580
|
+
onTap: () => _activateItem(item.id),
|
|
581
|
+
child: Container(
|
|
582
|
+
padding: const EdgeInsets.symmetric(
|
|
583
|
+
horizontal: _kItemHPad,
|
|
584
|
+
vertical: _kItemVPad,
|
|
585
|
+
),
|
|
586
|
+
decoration: BoxDecoration(
|
|
587
|
+
color: bg,
|
|
588
|
+
borderRadius: BorderRadius.circular(_kItemRadius),
|
|
589
|
+
),
|
|
590
|
+
child: Row(
|
|
591
|
+
children: [
|
|
592
|
+
_ProIcon(icon: item.icon, color: iconColor, active: isActive),
|
|
593
|
+
const SizedBox(width: 12),
|
|
594
|
+
Expanded(
|
|
595
|
+
child: Text(
|
|
596
|
+
item.label,
|
|
597
|
+
style: TextStyle(
|
|
598
|
+
fontSize: 14,
|
|
599
|
+
fontWeight: isActive
|
|
600
|
+
? FontWeight.w600
|
|
601
|
+
: FontWeight.w500,
|
|
602
|
+
color: iconColor,
|
|
603
|
+
letterSpacing: -0.28,
|
|
604
|
+
),
|
|
605
|
+
),
|
|
606
|
+
),
|
|
607
|
+
AnimatedRotation(
|
|
608
|
+
turns: _incomeExpanded ? 0.5 : 0,
|
|
609
|
+
duration: const Duration(milliseconds: 200),
|
|
610
|
+
child: Icon(
|
|
611
|
+
KasyIcons.chevronDown,
|
|
612
|
+
size: 16,
|
|
613
|
+
color: iconColor,
|
|
614
|
+
),
|
|
615
|
+
),
|
|
616
|
+
],
|
|
617
|
+
),
|
|
618
|
+
),
|
|
619
|
+
),
|
|
620
|
+
// Expandable sub-items tree
|
|
621
|
+
AnimatedCrossFade(
|
|
622
|
+
duration: const Duration(milliseconds: 200),
|
|
623
|
+
crossFadeState: _incomeExpanded
|
|
624
|
+
? CrossFadeState.showFirst
|
|
625
|
+
: CrossFadeState.showSecond,
|
|
626
|
+
firstChild: _buildSubItemsTree(c),
|
|
627
|
+
secondChild: const SizedBox.shrink(),
|
|
628
|
+
),
|
|
629
|
+
const SizedBox(height: _kItemGap),
|
|
630
|
+
],
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// ── Sub-items tree ────────────────────────────────────────────────────────
|
|
635
|
+
|
|
636
|
+
Widget _buildSubItemsTree(_SidebarColors c) {
|
|
637
|
+
final subItems = _kMainItems
|
|
638
|
+
.firstWhere((i) => i.id == 'income')
|
|
639
|
+
.subItems;
|
|
640
|
+
final double lineH = _treeLineHeight(subItems.length);
|
|
641
|
+
|
|
642
|
+
return Padding(
|
|
643
|
+
padding: const EdgeInsets.only(left: _kSubIndent),
|
|
644
|
+
child: SizedBox(
|
|
645
|
+
width: 172,
|
|
646
|
+
child: Stack(
|
|
647
|
+
clipBehavior: Clip.none,
|
|
648
|
+
children: [
|
|
649
|
+
// Vertical tree line
|
|
650
|
+
Positioned(
|
|
651
|
+
left: -_kTreeConnectorW,
|
|
652
|
+
top: 0,
|
|
653
|
+
child: Container(
|
|
654
|
+
width: 2,
|
|
655
|
+
height: lineH,
|
|
656
|
+
decoration: BoxDecoration(
|
|
657
|
+
color: c.divider,
|
|
658
|
+
borderRadius: BorderRadius.circular(2),
|
|
659
|
+
),
|
|
660
|
+
),
|
|
661
|
+
),
|
|
662
|
+
// Sub-items
|
|
663
|
+
Column(
|
|
664
|
+
children: subItems.asMap().entries.map((entry) {
|
|
665
|
+
final i = entry.key;
|
|
666
|
+
final label = entry.value;
|
|
667
|
+
final isLast = i == subItems.length - 1;
|
|
668
|
+
return _buildSubItem(label, isLast, c);
|
|
669
|
+
}).toList(),
|
|
670
|
+
),
|
|
671
|
+
],
|
|
672
|
+
),
|
|
673
|
+
),
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
Widget _buildSubItem(String label, bool isLast, _SidebarColors c) {
|
|
678
|
+
final bool isActive = _activeSubItem == label;
|
|
679
|
+
// Sub-items: active state = bolder text only, no background fill.
|
|
680
|
+
// Hover still shows the fill via KasyHover.hoverColor for interactivity.
|
|
681
|
+
const Color bg = Colors.transparent;
|
|
682
|
+
final Color textColor = isActive ? c.textActive : c.textMuted;
|
|
683
|
+
|
|
684
|
+
return Padding(
|
|
685
|
+
padding: EdgeInsets.only(bottom: isLast ? 0 : _kSubItemGap),
|
|
686
|
+
child: Stack(
|
|
687
|
+
clipBehavior: Clip.none,
|
|
688
|
+
children: [
|
|
689
|
+
// L-connector: bottom-left rounded border
|
|
690
|
+
Positioned(
|
|
691
|
+
left: -_kTreeConnectorW,
|
|
692
|
+
top: _kSubItemH / 2 - 4,
|
|
693
|
+
child: Container(
|
|
694
|
+
width: _kTreeConnectorW,
|
|
695
|
+
height: 8,
|
|
696
|
+
decoration: BoxDecoration(
|
|
697
|
+
border: Border(
|
|
698
|
+
left: BorderSide(color: c.divider, width: 2),
|
|
699
|
+
bottom: BorderSide(color: c.divider, width: 2),
|
|
700
|
+
),
|
|
701
|
+
borderRadius: const BorderRadius.only(
|
|
702
|
+
bottomLeft: Radius.circular(8),
|
|
703
|
+
),
|
|
704
|
+
),
|
|
705
|
+
),
|
|
706
|
+
),
|
|
707
|
+
// Item row
|
|
708
|
+
KasyHover(
|
|
709
|
+
borderRadius: BorderRadius.circular(_kItemRadius),
|
|
710
|
+
hoverColor: c.activeBg,
|
|
711
|
+
pressColor: c.activeBg,
|
|
712
|
+
onTap: () => _activateSubItem(label),
|
|
713
|
+
child: Container(
|
|
714
|
+
height: _kSubItemH,
|
|
715
|
+
padding: const EdgeInsets.symmetric(
|
|
716
|
+
horizontal: _kItemHPad,
|
|
717
|
+
vertical: 8,
|
|
718
|
+
),
|
|
719
|
+
decoration: BoxDecoration(
|
|
720
|
+
color: bg,
|
|
721
|
+
borderRadius: BorderRadius.circular(_kItemRadius),
|
|
722
|
+
),
|
|
723
|
+
child: Align(
|
|
724
|
+
alignment: Alignment.centerLeft,
|
|
725
|
+
child: Text(
|
|
726
|
+
label,
|
|
727
|
+
style: TextStyle(
|
|
728
|
+
fontSize: 12,
|
|
729
|
+
fontWeight: isActive ? FontWeight.w600 : FontWeight.w500,
|
|
730
|
+
color: textColor,
|
|
731
|
+
letterSpacing: -0.24,
|
|
732
|
+
),
|
|
733
|
+
),
|
|
734
|
+
),
|
|
735
|
+
),
|
|
736
|
+
),
|
|
737
|
+
],
|
|
738
|
+
),
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// ── Collapse toggle button ────────────────────────────────────────────────
|
|
743
|
+
|
|
744
|
+
Widget _buildCollapseButton(BuildContext context, _SidebarColors c) {
|
|
745
|
+
final bool anchoredLeft = widget.side == KasySidebarProSide.left;
|
|
746
|
+
|
|
747
|
+
// Left sidebar: button at the right edge; chevron ← when open, → when collapsed.
|
|
748
|
+
// Right sidebar: button at the left edge; chevron → when open, ← when collapsed.
|
|
749
|
+
// The button sits inside the AnimatedContainer bounds so it receives full
|
|
750
|
+
// hit tests (Flutter clips hit testing to the parent's bounds).
|
|
751
|
+
final double turns = anchoredLeft
|
|
752
|
+
? (_collapsed ? -0.25 : 0.25) // ← open, → collapsed
|
|
753
|
+
: (_collapsed ? 0.25 : -0.25); // → open, ← collapsed
|
|
754
|
+
|
|
755
|
+
return Positioned(
|
|
756
|
+
// The outer container is sidebar_width + halfBtn wide.
|
|
757
|
+
// right: 0 → button right edge at outer container right edge.
|
|
758
|
+
// Button spans outer_width-28 to outer_width, center = sidebar_width. ✓
|
|
759
|
+
// Same logic mirrored for right-anchored sidebar.
|
|
760
|
+
left: anchoredLeft ? null : 0,
|
|
761
|
+
right: anchoredLeft ? 0 : null,
|
|
762
|
+
top: 34,
|
|
763
|
+
child: GestureDetector(
|
|
764
|
+
onTap: _toggleCollapse,
|
|
765
|
+
child: AnimatedContainer(
|
|
766
|
+
duration: const Duration(milliseconds: 220),
|
|
767
|
+
width: _kCollapseButtonSize,
|
|
768
|
+
height: _kCollapseButtonSize,
|
|
769
|
+
decoration: BoxDecoration(
|
|
770
|
+
color: c.bg,
|
|
771
|
+
border: Border.all(color: c.divider),
|
|
772
|
+
borderRadius: BorderRadius.circular(8),
|
|
773
|
+
boxShadow: [
|
|
774
|
+
BoxShadow(
|
|
775
|
+
color: const Color(0xFF000000).withValues(alpha: 0.06),
|
|
776
|
+
blurRadius: 4,
|
|
777
|
+
offset: const Offset(0, 1),
|
|
778
|
+
),
|
|
779
|
+
],
|
|
780
|
+
),
|
|
781
|
+
child: Center(
|
|
782
|
+
child: AnimatedRotation(
|
|
783
|
+
turns: turns,
|
|
784
|
+
duration: const Duration(milliseconds: 220),
|
|
785
|
+
child: Icon(
|
|
786
|
+
KasyIcons.chevronDown,
|
|
787
|
+
size: 16,
|
|
788
|
+
color: c.textMuted,
|
|
789
|
+
),
|
|
790
|
+
),
|
|
791
|
+
),
|
|
792
|
+
),
|
|
793
|
+
),
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
799
|
+
// _ProIcon — nav icon with subtle shadow weight when active
|
|
800
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
801
|
+
|
|
802
|
+
/// Renders a nav icon at 20px. When [active], adds a soft shadow using the
|
|
803
|
+
/// same [color] to simulate a slightly heavier stroke — consistent with the
|
|
804
|
+
/// bolder text weight used on selected items.
|
|
805
|
+
class _ProIcon extends StatelessWidget {
|
|
806
|
+
const _ProIcon({
|
|
807
|
+
required this.icon,
|
|
808
|
+
required this.color,
|
|
809
|
+
required this.active,
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
final IconData icon;
|
|
813
|
+
final Color color;
|
|
814
|
+
final bool active;
|
|
815
|
+
|
|
816
|
+
@override
|
|
817
|
+
Widget build(BuildContext context) {
|
|
818
|
+
return Icon(
|
|
819
|
+
icon,
|
|
820
|
+
size: 20,
|
|
821
|
+
color: color,
|
|
822
|
+
shadows: active
|
|
823
|
+
? [
|
|
824
|
+
Shadow(color: color.withValues(alpha: 0.55), blurRadius: 0.6),
|
|
825
|
+
Shadow(color: color.withValues(alpha: 0.35), blurRadius: 1.4),
|
|
826
|
+
]
|
|
827
|
+
: null,
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
833
|
+
// _ProHoverPopupIcon — collapsed icon that shows a floating submenu on hover
|
|
834
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
835
|
+
|
|
836
|
+
class _ProHoverPopupIcon extends StatefulWidget {
|
|
837
|
+
const _ProHoverPopupIcon({
|
|
838
|
+
required this.icon,
|
|
839
|
+
required this.iconBg,
|
|
840
|
+
required this.iconColor,
|
|
841
|
+
required this.subItems,
|
|
842
|
+
required this.activeSubItem,
|
|
843
|
+
required this.colors,
|
|
844
|
+
required this.onSubItemTap,
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
final IconData icon;
|
|
848
|
+
final Color iconBg;
|
|
849
|
+
final Color iconColor;
|
|
850
|
+
final List<String> subItems;
|
|
851
|
+
final String activeSubItem;
|
|
852
|
+
final _SidebarColors colors;
|
|
853
|
+
final ValueChanged<String> onSubItemTap;
|
|
854
|
+
|
|
855
|
+
@override
|
|
856
|
+
State<_ProHoverPopupIcon> createState() => _ProHoverPopupIconState();
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
class _ProHoverPopupIconState extends State<_ProHoverPopupIcon> {
|
|
860
|
+
final _overlayController = OverlayPortalController();
|
|
861
|
+
final _layerLink = LayerLink();
|
|
862
|
+
bool _onTrigger = false;
|
|
863
|
+
bool _onPopup = false;
|
|
864
|
+
|
|
865
|
+
void _scheduleHide() {
|
|
866
|
+
Future.delayed(const Duration(milliseconds: 120), () {
|
|
867
|
+
if (!_onTrigger && !_onPopup && mounted) {
|
|
868
|
+
_overlayController.hide();
|
|
869
|
+
}
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
@override
|
|
874
|
+
Widget build(BuildContext context) {
|
|
875
|
+
return OverlayPortal(
|
|
876
|
+
controller: _overlayController,
|
|
877
|
+
overlayChildBuilder: _buildPopup,
|
|
878
|
+
child: CompositedTransformTarget(
|
|
879
|
+
link: _layerLink,
|
|
880
|
+
child: MouseRegion(
|
|
881
|
+
onEnter: (_) {
|
|
882
|
+
_onTrigger = true;
|
|
883
|
+
_overlayController.show();
|
|
884
|
+
},
|
|
885
|
+
onExit: (_) {
|
|
886
|
+
_onTrigger = false;
|
|
887
|
+
_scheduleHide();
|
|
888
|
+
},
|
|
889
|
+
child: KasyHover(
|
|
890
|
+
borderRadius: BorderRadius.circular(_kItemRadius),
|
|
891
|
+
onTap: () {},
|
|
892
|
+
child: AnimatedContainer(
|
|
893
|
+
duration: const Duration(milliseconds: 150),
|
|
894
|
+
padding: const EdgeInsets.symmetric(
|
|
895
|
+
horizontal: _kItemHPad,
|
|
896
|
+
vertical: _kItemVPad,
|
|
897
|
+
),
|
|
898
|
+
decoration: BoxDecoration(
|
|
899
|
+
color: widget.iconBg,
|
|
900
|
+
borderRadius: BorderRadius.circular(_kItemRadius),
|
|
901
|
+
),
|
|
902
|
+
child: Icon(widget.icon, size: 20, color: widget.iconColor),
|
|
903
|
+
),
|
|
904
|
+
),
|
|
905
|
+
),
|
|
906
|
+
),
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
Widget _buildPopup(BuildContext context) {
|
|
911
|
+
return CompositedTransformFollower(
|
|
912
|
+
link: _layerLink,
|
|
913
|
+
showWhenUnlinked: false,
|
|
914
|
+
targetAnchor: Alignment.centerRight,
|
|
915
|
+
followerAnchor: Alignment.centerLeft,
|
|
916
|
+
offset: const Offset(12, 0),
|
|
917
|
+
child: Align(
|
|
918
|
+
alignment: Alignment.centerLeft,
|
|
919
|
+
child: MouseRegion(
|
|
920
|
+
onEnter: (_) => setState(() => _onPopup = true),
|
|
921
|
+
onExit: (_) {
|
|
922
|
+
setState(() => _onPopup = false);
|
|
923
|
+
_scheduleHide();
|
|
924
|
+
},
|
|
925
|
+
child: _buildPopupCard(context),
|
|
926
|
+
),
|
|
927
|
+
),
|
|
928
|
+
);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
Widget _buildPopupCard(BuildContext context) {
|
|
932
|
+
final c = widget.colors;
|
|
933
|
+
|
|
934
|
+
return Material(
|
|
935
|
+
color: Colors.transparent,
|
|
936
|
+
child: Container(
|
|
937
|
+
width: 172,
|
|
938
|
+
padding: const EdgeInsets.all(8),
|
|
939
|
+
decoration: BoxDecoration(
|
|
940
|
+
color: c.bg,
|
|
941
|
+
border: Border.all(color: c.border),
|
|
942
|
+
borderRadius: BorderRadius.circular(12),
|
|
943
|
+
boxShadow: _kPopupShadow,
|
|
944
|
+
),
|
|
945
|
+
child: Column(
|
|
946
|
+
mainAxisSize: MainAxisSize.min,
|
|
947
|
+
children: widget.subItems.map((label) {
|
|
948
|
+
final bool isActive = label == widget.activeSubItem;
|
|
949
|
+
final Color bg = isActive ? c.activeBg : Colors.transparent;
|
|
950
|
+
final Color textColor = isActive ? c.textActive : c.textMuted;
|
|
951
|
+
|
|
952
|
+
return KasyHover(
|
|
953
|
+
borderRadius: BorderRadius.circular(_kItemRadius),
|
|
954
|
+
onTap: () => widget.onSubItemTap(label),
|
|
955
|
+
child: Container(
|
|
956
|
+
width: double.infinity,
|
|
957
|
+
padding: const EdgeInsets.symmetric(
|
|
958
|
+
horizontal: _kItemHPad,
|
|
959
|
+
vertical: 8,
|
|
960
|
+
),
|
|
961
|
+
decoration: BoxDecoration(
|
|
962
|
+
color: bg,
|
|
963
|
+
borderRadius: BorderRadius.circular(_kItemRadius),
|
|
964
|
+
),
|
|
965
|
+
child: Text(
|
|
966
|
+
label,
|
|
967
|
+
style: TextStyle(
|
|
968
|
+
fontSize: 12,
|
|
969
|
+
fontWeight: isActive ? FontWeight.w600 : FontWeight.w500,
|
|
970
|
+
color: textColor,
|
|
971
|
+
letterSpacing: -0.24,
|
|
972
|
+
),
|
|
973
|
+
),
|
|
974
|
+
),
|
|
975
|
+
);
|
|
976
|
+
}).toList(),
|
|
977
|
+
),
|
|
978
|
+
),
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
984
|
+
// _ProTooltipIcon — collapsed icon with Figma-matched name tooltip on hover
|
|
985
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
986
|
+
|
|
987
|
+
class _ProTooltipIcon extends StatefulWidget {
|
|
988
|
+
const _ProTooltipIcon({
|
|
989
|
+
required this.icon,
|
|
990
|
+
required this.label,
|
|
991
|
+
required this.iconBg,
|
|
992
|
+
required this.iconColor,
|
|
993
|
+
required this.activeBg,
|
|
994
|
+
required this.colors,
|
|
995
|
+
required this.onTap,
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
final IconData icon;
|
|
999
|
+
final String label;
|
|
1000
|
+
final Color iconBg;
|
|
1001
|
+
final Color iconColor;
|
|
1002
|
+
final Color activeBg;
|
|
1003
|
+
final _SidebarColors colors;
|
|
1004
|
+
final VoidCallback onTap;
|
|
1005
|
+
|
|
1006
|
+
@override
|
|
1007
|
+
State<_ProTooltipIcon> createState() => _ProTooltipIconState();
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
class _ProTooltipIconState extends State<_ProTooltipIcon> {
|
|
1011
|
+
final _overlayController = OverlayPortalController();
|
|
1012
|
+
final _layerLink = LayerLink();
|
|
1013
|
+
|
|
1014
|
+
@override
|
|
1015
|
+
Widget build(BuildContext context) {
|
|
1016
|
+
return OverlayPortal(
|
|
1017
|
+
controller: _overlayController,
|
|
1018
|
+
overlayChildBuilder: _buildTooltip,
|
|
1019
|
+
child: CompositedTransformTarget(
|
|
1020
|
+
link: _layerLink,
|
|
1021
|
+
child: MouseRegion(
|
|
1022
|
+
onEnter: (_) => _overlayController.show(),
|
|
1023
|
+
onExit: (_) => _overlayController.hide(),
|
|
1024
|
+
child: KasyHover(
|
|
1025
|
+
borderRadius: BorderRadius.circular(_kItemRadius),
|
|
1026
|
+
hoverColor: widget.activeBg,
|
|
1027
|
+
pressColor: widget.activeBg,
|
|
1028
|
+
onTap: widget.onTap,
|
|
1029
|
+
child: Container(
|
|
1030
|
+
padding: const EdgeInsets.symmetric(
|
|
1031
|
+
horizontal: _kItemHPad,
|
|
1032
|
+
vertical: _kItemVPad,
|
|
1033
|
+
),
|
|
1034
|
+
decoration: BoxDecoration(
|
|
1035
|
+
color: widget.iconBg,
|
|
1036
|
+
borderRadius: BorderRadius.circular(_kItemRadius),
|
|
1037
|
+
),
|
|
1038
|
+
child: Icon(widget.icon, size: 20, color: widget.iconColor),
|
|
1039
|
+
),
|
|
1040
|
+
),
|
|
1041
|
+
),
|
|
1042
|
+
),
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
Widget _buildTooltip(BuildContext context) {
|
|
1047
|
+
return CompositedTransformFollower(
|
|
1048
|
+
link: _layerLink,
|
|
1049
|
+
showWhenUnlinked: false,
|
|
1050
|
+
targetAnchor: Alignment.centerRight,
|
|
1051
|
+
followerAnchor: Alignment.centerLeft,
|
|
1052
|
+
offset: const Offset(4, 0),
|
|
1053
|
+
child: Align(
|
|
1054
|
+
alignment: Alignment.centerLeft,
|
|
1055
|
+
child: _TooltipCard(label: widget.label, colors: widget.colors),
|
|
1056
|
+
),
|
|
1057
|
+
);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1062
|
+
// _TooltipCard — floating label card with left-pointing arrow (Figma spec)
|
|
1063
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1064
|
+
|
|
1065
|
+
class _TooltipCard extends StatelessWidget {
|
|
1066
|
+
const _TooltipCard({required this.label, required this.colors});
|
|
1067
|
+
|
|
1068
|
+
final String label;
|
|
1069
|
+
final _SidebarColors colors;
|
|
1070
|
+
|
|
1071
|
+
static const double _arrowW = 13.0;
|
|
1072
|
+
static const double _arrowH = 26.0;
|
|
1073
|
+
static const double _arrowOverlap = 8.0;
|
|
1074
|
+
|
|
1075
|
+
@override
|
|
1076
|
+
Widget build(BuildContext context) {
|
|
1077
|
+
// Use colors.isDark instead of Theme.of(context) — the overlay context
|
|
1078
|
+
// does not always inherit the app theme correctly.
|
|
1079
|
+
// Dark → bg: colors.divider (#2D2F39), text: rgba(255,255,255,0.8)
|
|
1080
|
+
// Light → bg: black, text: white — inverted tooltip (Figma spec / industry standard)
|
|
1081
|
+
final Color bg = colors.isDark ? colors.divider : Colors.black;
|
|
1082
|
+
final Color textColor =
|
|
1083
|
+
colors.isDark ? const Color(0xCCFFFFFF) : Colors.white;
|
|
1084
|
+
|
|
1085
|
+
return Material(
|
|
1086
|
+
color: Colors.transparent,
|
|
1087
|
+
child: Row(
|
|
1088
|
+
mainAxisSize: MainAxisSize.min,
|
|
1089
|
+
children: [
|
|
1090
|
+
SizedBox(
|
|
1091
|
+
width: _arrowW,
|
|
1092
|
+
height: _arrowH,
|
|
1093
|
+
child: CustomPaint(painter: _TooltipArrowPainter(color: bg)),
|
|
1094
|
+
),
|
|
1095
|
+
Transform.translate(
|
|
1096
|
+
offset: const Offset(-_arrowOverlap, 0),
|
|
1097
|
+
child: Container(
|
|
1098
|
+
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
|
1099
|
+
decoration: BoxDecoration(
|
|
1100
|
+
color: bg,
|
|
1101
|
+
borderRadius: BorderRadius.circular(4),
|
|
1102
|
+
boxShadow: const [
|
|
1103
|
+
BoxShadow(
|
|
1104
|
+
color: Color(0x40000000),
|
|
1105
|
+
blurRadius: 5,
|
|
1106
|
+
offset: Offset(0, 5),
|
|
1107
|
+
),
|
|
1108
|
+
],
|
|
1109
|
+
),
|
|
1110
|
+
child: Text(
|
|
1111
|
+
label,
|
|
1112
|
+
style: TextStyle(
|
|
1113
|
+
fontSize: 14,
|
|
1114
|
+
fontWeight: FontWeight.w400,
|
|
1115
|
+
color: textColor,
|
|
1116
|
+
height: 20 / 14,
|
|
1117
|
+
),
|
|
1118
|
+
),
|
|
1119
|
+
),
|
|
1120
|
+
),
|
|
1121
|
+
],
|
|
1122
|
+
),
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1128
|
+
// _TooltipArrowPainter — filled left-pointing triangle
|
|
1129
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1130
|
+
|
|
1131
|
+
class _TooltipArrowPainter extends CustomPainter {
|
|
1132
|
+
const _TooltipArrowPainter({required this.color});
|
|
1133
|
+
final Color color;
|
|
1134
|
+
|
|
1135
|
+
@override
|
|
1136
|
+
void paint(Canvas canvas, Size size) {
|
|
1137
|
+
final paint = Paint()..color = color..style = PaintingStyle.fill;
|
|
1138
|
+
final path = Path()
|
|
1139
|
+
..moveTo(size.width, 0)
|
|
1140
|
+
..lineTo(0, size.height / 2)
|
|
1141
|
+
..lineTo(size.width, size.height)
|
|
1142
|
+
..close();
|
|
1143
|
+
canvas.drawPath(path, paint);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
@override
|
|
1147
|
+
bool shouldRepaint(_TooltipArrowPainter old) => old.color != color;
|
|
1148
|
+
}
|