kasy-cli 1.37.1 → 1.39.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 (120) hide show
  1. package/lib/scaffold/CHANGELOG.json +23 -0
  2. package/lib/scaffold/backends/api/patch/README.md +15 -0
  3. package/lib/scaffold/backends/api/patch/lib/core/data/api/user_api.dart +18 -0
  4. package/lib/scaffold/backends/api/patch/lib/features/notifications/api/device_api.dart +11 -6
  5. package/lib/scaffold/backends/api/patch/lib/features/settings/ui/components/admin/admin_users_api.dart +9 -1
  6. package/lib/scaffold/backends/api/pubspec.yaml.tpl +1 -0
  7. package/lib/scaffold/backends/patch-base-hashes.json +6 -6
  8. package/lib/scaffold/backends/supabase/edge-functions/admin-list-users/index.ts +3 -1
  9. package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/index.ts +3 -0
  10. package/lib/scaffold/backends/supabase/migrations/20240101000012_welcome_decouple_from_push.sql +62 -0
  11. package/lib/scaffold/backends/supabase/patch/lib/core/data/api/user_api.dart +8 -0
  12. package/lib/scaffold/backends/supabase/patch/lib/features/notifications/api/device_api.dart +11 -6
  13. package/lib/scaffold/backends/supabase/patch/lib/features/settings/ui/components/admin/admin_users_api.dart +7 -0
  14. package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +1 -0
  15. package/lib/scaffold/shared/generator-utils.js +12 -6
  16. package/package.json +1 -1
  17. package/templates/firebase/.firebase/hosting.YnVpbGQvd2Vi.cache +23 -23
  18. package/templates/firebase/AGENTS.md +7 -1
  19. package/templates/firebase/DESIGN_SYSTEM.md +35 -8
  20. package/templates/firebase/assets/icons/apple_black.svg +3 -0
  21. package/templates/firebase/assets/icons/apple_white.svg +4 -0
  22. package/templates/firebase/assets/icons/facebook.svg +49 -0
  23. package/templates/firebase/assets/icons/google.svg +1 -0
  24. package/templates/firebase/functions/src/admin/functions.ts +2 -0
  25. package/templates/firebase/functions/src/authentication/functions.ts +13 -7
  26. package/templates/firebase/functions/src/notifications/triggers.ts +6 -2
  27. package/templates/firebase/lib/components/components.dart +1 -1
  28. package/templates/firebase/lib/components/kasy_app_bar.dart +361 -20
  29. package/templates/firebase/lib/components/kasy_bottom_sheet.dart +283 -66
  30. package/templates/firebase/lib/components/kasy_card.dart +4 -0
  31. package/templates/firebase/lib/components/kasy_date_picker.dart +61 -46
  32. package/templates/firebase/lib/components/kasy_drop_down.dart +584 -0
  33. package/templates/firebase/lib/components/kasy_sidebar.dart +412 -31
  34. package/templates/firebase/lib/components/kasy_tabs.dart +31 -10
  35. package/templates/firebase/lib/components/kasy_text_field.dart +29 -7
  36. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +29 -231
  37. package/templates/firebase/lib/core/bottom_menu/sidebar_focus.dart +224 -0
  38. package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +19 -9
  39. package/templates/firebase/lib/core/chrome/app_bar_config.dart +214 -0
  40. package/templates/firebase/lib/core/chrome/app_bar_scope.dart +102 -0
  41. package/templates/firebase/lib/core/data/api/user_api.dart +15 -0
  42. package/templates/firebase/lib/core/data/repositories/user_repository.dart +5 -0
  43. package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +525 -65
  44. package/templates/firebase/lib/core/dev_inspector/dev_inspector_info.dart +47 -0
  45. package/templates/firebase/lib/core/dev_inspector/dev_inspector_service.dart +55 -15
  46. package/templates/firebase/lib/core/icons/kasy_icons.dart +16 -1
  47. package/templates/firebase/lib/core/rating/widgets/review_popup.dart +18 -35
  48. package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +11 -0
  49. package/templates/firebase/lib/core/states/logout_action.dart +11 -1
  50. package/templates/firebase/lib/core/states/user_state_notifier.dart +69 -1
  51. package/templates/firebase/lib/core/theme/texts.dart +21 -6
  52. package/templates/firebase/lib/core/theme/type_scale.dart +34 -15
  53. package/templates/firebase/lib/core/theme/universal_theme.dart +9 -0
  54. package/templates/firebase/lib/core/web_device_preview/png_clipboard.dart +14 -0
  55. package/templates/firebase/lib/core/web_device_preview/png_clipboard_io.dart +9 -0
  56. package/templates/firebase/lib/core/web_device_preview/png_clipboard_web.dart +36 -0
  57. package/templates/firebase/lib/core/web_device_preview/png_export_result.dart +2 -0
  58. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +547 -483
  59. package/templates/firebase/lib/core/web_viewport_scale.dart +64 -35
  60. package/templates/firebase/lib/core/widgets/kasy_pressable_depth.dart +14 -3
  61. package/templates/firebase/lib/core/widgets/responsive_layout.dart +8 -0
  62. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_composer.dart +1 -1
  63. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_conversation_view.dart +52 -35
  64. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_list.dart +1 -1
  65. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +18 -8
  66. package/templates/firebase/lib/features/authentication/ui/signin_page.dart +11 -61
  67. package/templates/firebase/lib/features/authentication/ui/signup_page.dart +11 -61
  68. package/templates/firebase/lib/features/authentication/ui/widgets/auth_card_scaffold.dart +7 -5
  69. package/templates/firebase/lib/features/authentication/ui/widgets/social_auth_tile.dart +83 -0
  70. package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +4 -4
  71. package/templates/firebase/lib/features/home/home_components_page.dart +264 -126
  72. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +231 -57
  73. package/templates/firebase/lib/features/home/home_feed.dart +2 -2
  74. package/templates/firebase/lib/features/home/home_image_grid.dart +3 -3
  75. package/templates/firebase/lib/features/local_reminders/providers/reminder_notifier.dart +8 -1
  76. package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +118 -57
  77. package/templates/firebase/lib/features/notifications/api/device_api.dart +11 -3
  78. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +19 -4
  79. package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +7 -56
  80. package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +5 -5
  81. package/templates/firebase/lib/features/notifications/ui/widgets/push_permission_banner.dart +163 -0
  82. package/templates/firebase/lib/features/notifications/ui/widgets/web_notifications_bell.dart +262 -0
  83. package/templates/firebase/lib/features/onboarding/providers/onboarding_model.dart +9 -0
  84. package/templates/firebase/lib/features/onboarding/providers/onboarding_provider.dart +28 -0
  85. package/templates/firebase/lib/features/onboarding/ui/onboarding_page.dart +51 -12
  86. package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +90 -32
  87. package/templates/firebase/lib/features/settings/settings_page.dart +99 -65
  88. package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +1379 -422
  89. package/templates/firebase/lib/features/settings/ui/components/admin/admin_routes.dart +14 -4
  90. package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_api.dart +7 -0
  91. package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_tab.dart +445 -233
  92. package/templates/firebase/lib/features/settings/ui/components/admin/send_push_notification_page.dart +404 -149
  93. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +24 -31
  94. package/templates/firebase/lib/features/settings/ui/components/delete_user_component.dart +9 -1
  95. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +77 -95
  96. package/templates/firebase/lib/features/settings/ui/widgets/settings_bottom_sheet_option_tile.dart +21 -18
  97. package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +25 -10
  98. package/templates/firebase/lib/i18n/en.i18n.json +749 -698
  99. package/templates/firebase/lib/i18n/es.i18n.json +749 -698
  100. package/templates/firebase/lib/i18n/pt.i18n.json +749 -698
  101. package/templates/firebase/lib/main.dart +20 -7
  102. package/templates/firebase/lib/router.dart +70 -46
  103. package/templates/firebase/pubspec.yaml +2 -1
  104. package/templates/firebase/test/admin_shell_chrome_test.dart +110 -0
  105. package/templates/firebase/test/components/kasy_text_field_height_test.dart +77 -0
  106. package/templates/firebase/test/core/web_viewport_scale_test.dart +23 -16
  107. package/templates/firebase/test/features/authentication/data/api/user_api_fake.dart +3 -0
  108. package/templates/firebase/tool/design_check.dart +9 -0
  109. package/templates/firebase/assets/icons/apple.png +0 -0
  110. package/templates/firebase/assets/icons/facebook.png +0 -0
  111. package/templates/firebase/assets/icons/google.png +0 -0
  112. package/templates/firebase/assets/icons/google_play_games.png +0 -0
  113. package/templates/firebase/lib/components/kasy_web_header.dart +0 -210
  114. package/templates/firebase/lib/features/authentication/ui/components/apple_signin.dart +0 -19
  115. package/templates/firebase/lib/features/authentication/ui/components/google_signin.dart +0 -32
  116. package/templates/firebase/lib/features/authentication/ui/widgets/round_signin.dart +0 -73
  117. package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +0 -66
  118. package/templates/firebase/lib/features/notifications/ui/components/notification_settings_sheet.dart +0 -169
  119. package/templates/firebase/lib/features/notifications/ui/components/push_notification_switcher.dart +0 -106
  120. package/templates/firebase/lib/features/settings/ui/components/admin/admin_paywalls.dart +0 -53
@@ -3,8 +3,14 @@ import 'dart:math';
3
3
  import 'package:flutter/material.dart';
4
4
  import 'package:flutter_riverpod/flutter_riverpod.dart';
5
5
  import 'package:kasy_kit/components/kasy_avatar.dart';
6
+ import 'package:kasy_kit/components/kasy_button.dart';
7
+ import 'package:kasy_kit/components/kasy_card.dart';
8
+ import 'package:kasy_kit/components/kasy_drop_down.dart';
9
+ import 'package:kasy_kit/components/kasy_skeleton.dart';
6
10
  import 'package:kasy_kit/components/kasy_status_tag.dart';
11
+ import 'package:kasy_kit/components/kasy_text_field.dart';
7
12
  import 'package:kasy_kit/core/theme/theme.dart';
13
+ import 'package:kasy_kit/core/widgets/kasy_hover.dart';
8
14
  import 'package:kasy_kit/features/settings/ui/components/admin/admin_users_api.dart';
9
15
  import 'package:kasy_kit/i18n/translations.g.dart';
10
16
 
@@ -120,50 +126,90 @@ class _AdminUsersTabState extends ConsumerState<AdminUsersTab> {
120
126
  final async = ref.watch(_usersResultProvider);
121
127
 
122
128
  return _HCenter(
123
- child: async.when(
124
- loading: () => const _LoadingState(),
125
- error: (e, _) => _ErrorState(onRetry: _refresh),
126
- data: (result) {
127
- final processed = _process(result.users);
128
- final total = processed.length;
129
- final pageCount = max(1, (total / _pageSize).ceil());
130
- final page = _page.clamp(0, pageCount - 1);
131
- final start = page * _pageSize;
132
- final end = min(start + _pageSize, total);
133
- final visible = processed.sublist(start, end);
134
-
135
- return Column(
136
- crossAxisAlignment: CrossAxisAlignment.stretch,
137
- children: [
138
- _Toolbar(
139
- controller: _searchCtrl,
140
- resultCount: total,
141
- subscribersOnly: _subscribersOnly,
142
- onSearch: _onSearch,
143
- onSubscribersOnly: _setSubscribersOnly,
144
- onRefresh: _refresh,
145
- ),
146
- if (result.truncated)
147
- _TruncatedNote(count: result.users.length),
148
- _TableHeader(
149
- sortCol: _sortCol,
150
- asc: _asc,
151
- onSort: _onSort,
129
+ child: LayoutBuilder(
130
+ builder: (context, constraints) {
131
+ // Mobile: a horizontal table is cramped, so each user becomes a
132
+ // vertical card. Tablet/desktop keep the denser table.
133
+ final bool cards = constraints.maxWidth < 560;
134
+ final Widget content = async.when(
135
+ // Show the skeleton on a manual refresh too, so tapping Refresh
136
+ // gives clear feedback even when nothing actually changed.
137
+ skipLoadingOnRefresh: false,
138
+ loading: () => _LoadingState(cards: cards),
139
+ error: (e, _) => _ErrorState(onRetry: _refresh),
140
+ data: (result) {
141
+ final processed = _process(result.users);
142
+ final total = processed.length;
143
+ final pageCount = max(1, (total / _pageSize).ceil());
144
+ final page = _page.clamp(0, pageCount - 1);
145
+ final start = page * _pageSize;
146
+ final end = min(start + _pageSize, total);
147
+ final visible = processed.sublist(start, end);
148
+
149
+ return Column(
150
+ crossAxisAlignment: CrossAxisAlignment.stretch,
151
+ children: [
152
+ _Toolbar(
153
+ controller: _searchCtrl,
154
+ resultCount: total,
155
+ subscribersOnly: _subscribersOnly,
156
+ onSearch: _onSearch,
157
+ onSubscribersOnly: _setSubscribersOnly,
158
+ onRefresh: _refresh,
159
+ ),
160
+ if (result.truncated)
161
+ _TruncatedNote(count: result.users.length),
162
+ // Column headers only make sense for the table layout.
163
+ if (!cards)
164
+ _TableHeader(sortCol: _sortCol, asc: _asc, onSort: _onSort)
165
+ else
166
+ const SizedBox(height: KasySpacing.md),
167
+ Expanded(
168
+ child: visible.isEmpty
169
+ ? _EmptyState(searching: _search.trim().isNotEmpty)
170
+ : _TableBody(
171
+ users: visible,
172
+ cards: cards,
173
+ rangeFrom: start + 1,
174
+ rangeTo: end,
175
+ total: total,
176
+ page: page,
177
+ pageCount: pageCount,
178
+ onPage: (p) => setState(() => _page = p),
179
+ ),
180
+ ),
181
+ ],
182
+ );
183
+ },
184
+ );
185
+
186
+ // Mobile keeps the borderless cards (they carry their own surface).
187
+ // Tablet/desktop wrap the whole table in a surface panel so it reads
188
+ // as a deliberate card, not loose rows on the page background.
189
+ if (cards) return content;
190
+ return Padding(
191
+ padding: const EdgeInsets.fromLTRB(
192
+ KasySpacing.pageHorizontalGutter,
193
+ KasySpacing.belowChromeContentGap,
194
+ KasySpacing.pageHorizontalGutter,
195
+ KasySpacing.md,
196
+ ),
197
+ child: DecoratedBox(
198
+ decoration: BoxDecoration(
199
+ color: context.colors.surface,
200
+ borderRadius: BorderRadius.circular(18),
201
+ border: Border.all(
202
+ color: context.colors.outline.withValues(
203
+ alpha: context.isDark ? 0.45 : 0.6,
204
+ ),
205
+ ),
206
+ boxShadow: [KasyShadows.component(context)],
152
207
  ),
153
- Expanded(
154
- child: visible.isEmpty
155
- ? _EmptyState(searching: _search.trim().isNotEmpty)
156
- : _TableBody(
157
- users: visible,
158
- rangeFrom: start + 1,
159
- rangeTo: end,
160
- total: total,
161
- page: page,
162
- pageCount: pageCount,
163
- onPage: (p) => setState(() => _page = p),
164
- ),
208
+ child: ClipRRect(
209
+ borderRadius: BorderRadius.circular(18),
210
+ child: content,
165
211
  ),
166
- ],
212
+ ),
167
213
  );
168
214
  },
169
215
  ),
@@ -354,89 +400,20 @@ class _FilterButton extends StatelessWidget {
354
400
  @override
355
401
  Widget build(BuildContext context) {
356
402
  final u = t.admin_console.users;
357
- final active = subscribersOnly;
358
- final accent = active ? context.colors.primary : context.colors.onSurface;
359
-
360
- return PopupMenuButton<bool>(
361
- initialValue: subscribersOnly,
362
- onSelected: onChanged,
363
- tooltip: '', // never a "show menu" tooltip — the label already explains it
364
- position: PopupMenuPosition.under,
365
- constraints: const BoxConstraints(
366
- minWidth: _filterWidth,
367
- maxWidth: _filterWidth,
368
- ),
369
- color: context.colors.surface,
370
- elevation: 6,
371
- shadowColor: Colors.black.withValues(alpha: 0.12),
372
- shape: RoundedRectangleBorder(
373
- borderRadius: BorderRadius.circular(KasyRadius.md),
374
- side: BorderSide(color: context.colors.outline.withValues(alpha: 0.5)),
375
- ),
376
- itemBuilder: (_) => [
377
- _filterItem(context, value: false, label: u.filter_all, selected: !active),
378
- _filterItem(context, value: true, label: u.filter_subscribers, selected: active),
379
- ],
380
- child: Container(
381
- width: _filterWidth,
382
- height: 38,
383
- padding: const EdgeInsets.symmetric(horizontal: 12),
384
- decoration: BoxDecoration(
385
- color: active
386
- ? context.colors.primary.withValues(alpha: 0.10)
387
- : context.colors.surface,
388
- borderRadius: BorderRadius.circular(KasyRadius.sm),
389
- border: Border.all(
390
- color: active
391
- ? context.colors.primary.withValues(alpha: 0.45)
392
- : context.colors.outline.withValues(alpha: 0.5),
393
- ),
394
- ),
395
- child: Row(
396
- children: [
397
- Icon(Icons.tune_rounded, size: 16, color: accent),
398
- const SizedBox(width: 8),
399
- Expanded(
400
- child: Text(
401
- active ? u.filter_subscribers : u.filter_all,
402
- maxLines: 1,
403
- overflow: TextOverflow.ellipsis,
404
- style: context.textTheme.bodySmall?.copyWith(
405
- color: accent,
406
- fontWeight: FontWeight.w600,
407
- ),
408
- ),
409
- ),
410
- Icon(Icons.keyboard_arrow_down_rounded, size: 18, color: accent),
411
- ],
412
- ),
413
- ),
414
- );
415
- }
416
-
417
- PopupMenuItem<bool> _filterItem(
418
- BuildContext context, {
419
- required bool value,
420
- required String label,
421
- required bool selected,
422
- }) {
423
- return PopupMenuItem<bool>(
424
- value: value,
425
- height: 42,
426
- child: Row(
427
- children: [
428
- Expanded(
429
- child: Text(
430
- label,
431
- style: context.textTheme.bodyMedium?.copyWith(
432
- color: context.colors.onSurface,
433
- fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
434
- ),
435
- ),
436
- ),
437
- if (selected)
438
- Icon(Icons.check_rounded, size: 16, color: context.colors.primary),
403
+ // Kit dropdown: the floating panel lives under the app Overlay so it always
404
+ // follows the real light/dark theme (the old PopupMenuButton rendered the
405
+ // menu light even in dark mode).
406
+ return SizedBox(
407
+ width: _filterWidth,
408
+ child: KasyDropDown<bool>(
409
+ value: subscribersOnly,
410
+ leadingIcon: Icons.tune_rounded,
411
+ variant: KasyTextFieldVariant.flat,
412
+ items: [
413
+ KasyDropDownItem(value: false, label: u.filter_all),
414
+ KasyDropDownItem(value: true, label: u.filter_subscribers),
439
415
  ],
416
+ onChanged: onChanged,
440
417
  ),
441
418
  );
442
419
  }
@@ -455,23 +432,18 @@ class _IconAction extends StatelessWidget {
455
432
  @override
456
433
  Widget build(BuildContext context) {
457
434
  // Icon-only control: a tooltip is the right call here (no visible label).
435
+ // KasyButton.iconOnly gives the kit's outlined icon button with proper web
436
+ // hover, focus ring and cursor (no Material ripple).
458
437
  return Tooltip(
459
438
  message: tooltip,
460
- child: InkWell(
461
- onTap: onTap,
462
- borderRadius: BorderRadius.circular(KasyRadius.sm),
463
- child: Container(
464
- width: 38,
465
- height: 38,
466
- decoration: BoxDecoration(
467
- color: context.colors.surface,
468
- borderRadius: BorderRadius.circular(KasyRadius.sm),
469
- border: Border.all(
470
- color: context.colors.outline.withValues(alpha: 0.5),
471
- ),
472
- ),
473
- child: Icon(icon, size: 18, color: context.colors.onSurface),
474
- ),
439
+ child: KasyButton.iconOnly(
440
+ icon: icon,
441
+ variant: KasyButtonVariant.outline,
442
+ size: KasyButtonSize.small,
443
+ iconOnlyLayoutExtent: 38,
444
+ iconGlyphSize: 18,
445
+ onPressed: onTap,
446
+ semanticLabel: tooltip,
475
447
  ),
476
448
  );
477
449
  }
@@ -533,9 +505,9 @@ class _TableHeader extends StatelessWidget {
533
505
  bottom:
534
506
  BorderSide(color: context.colors.outline.withValues(alpha: 0.3)),
535
507
  ),
536
- color: context.isDark
537
- ? context.colors.surface.withValues(alpha: 0.5)
538
- : context.colors.surfaceNeutralSoft.withValues(alpha: 0.4),
508
+ // Design-system neutral surface (solid) so the header band reads as a
509
+ // distinct table head instead of blending into the white panel.
510
+ color: context.colors.surfaceNeutralSoft,
539
511
  ),
540
512
  padding: const EdgeInsets.symmetric(
541
513
  horizontal: KasySpacing.pageHorizontalGutter,
@@ -600,8 +572,9 @@ class _HeaderCell extends StatelessWidget {
600
572
  @override
601
573
  Widget build(BuildContext context) {
602
574
  final color = active ? context.colors.onSurface : context.colors.muted;
603
- return InkWell(
575
+ return KasyHover(
604
576
  onTap: onTap,
577
+ focusable: true,
605
578
  borderRadius: BorderRadius.circular(KasyRadius.xs),
606
579
  child: Padding(
607
580
  padding: const EdgeInsets.symmetric(vertical: 6),
@@ -640,6 +613,7 @@ class _HeaderCell extends StatelessWidget {
640
613
 
641
614
  class _TableBody extends StatelessWidget {
642
615
  final List<AdminUser> users;
616
+ final bool cards;
643
617
  final int rangeFrom;
644
618
  final int rangeTo;
645
619
  final int total;
@@ -649,6 +623,7 @@ class _TableBody extends StatelessWidget {
649
623
 
650
624
  const _TableBody({
651
625
  required this.users,
626
+ required this.cards,
652
627
  required this.rangeFrom,
653
628
  required this.rangeTo,
654
629
  required this.total,
@@ -663,49 +638,77 @@ class _TableBody extends StatelessWidget {
663
638
  return Column(
664
639
  children: [
665
640
  Expanded(
666
- child: ListView.separated(
667
- // Scrolling the results means the user stopped typing in the search
668
- // field above, so dismiss the keyboard to show more of the list.
669
- keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
670
- padding: const EdgeInsets.only(bottom: KasySpacing.xs),
671
- itemCount: users.length,
672
- separatorBuilder: (_, _) => Divider(
673
- height: 1,
674
- thickness: 1,
675
- indent: KasySpacing.pageHorizontalGutter,
676
- endIndent: KasySpacing.pageHorizontalGutter,
677
- color: context.colors.outline.withValues(alpha: 0.18),
641
+ // Soft fade at the bottom edge so rows/cards dissolve into the pager
642
+ // instead of being sliced by a hard line reads far cleaner when the
643
+ // last item is mid-scroll behind the pinned footer.
644
+ child: ShaderMask(
645
+ shaderCallback: (rect) => const LinearGradient(
646
+ begin: Alignment.bottomCenter,
647
+ end: Alignment.topCenter,
648
+ colors: [Colors.transparent, Colors.black],
649
+ stops: [0.0, 0.045],
650
+ ).createShader(rect),
651
+ blendMode: BlendMode.dstIn,
652
+ child: ListView.separated(
653
+ // Scrolling the results means the user stopped typing in the
654
+ // search field above, so dismiss the keyboard to show more rows.
655
+ keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
656
+ padding: cards
657
+ ? const EdgeInsets.fromLTRB(
658
+ KasySpacing.pageHorizontalGutter,
659
+ 0,
660
+ KasySpacing.pageHorizontalGutter,
661
+ KasySpacing.lg,
662
+ )
663
+ : const EdgeInsets.only(bottom: KasySpacing.md),
664
+ itemCount: users.length,
665
+ separatorBuilder: cards
666
+ ? (_, _) => const SizedBox(height: KasySpacing.sm)
667
+ : (_, _) => Divider(
668
+ height: 1,
669
+ thickness: 1,
670
+ indent: KasySpacing.pageHorizontalGutter,
671
+ endIndent: KasySpacing.pageHorizontalGutter,
672
+ color: context.colors.outline.withValues(alpha: 0.18),
673
+ ),
674
+ itemBuilder: (_, i) =>
675
+ cards ? _UserCard(user: users[i]) : _UserRow(user: users[i]),
678
676
  ),
679
- itemBuilder: (_, i) => _UserRow(user: users[i]),
680
677
  ),
681
678
  ),
682
679
  Container(
683
680
  decoration: BoxDecoration(
684
681
  border: Border(
685
682
  top: BorderSide(
686
- color: context.colors.outline.withValues(alpha: 0.3),
683
+ color: context.colors.outline.withValues(alpha: 0.15),
687
684
  ),
688
685
  ),
689
686
  ),
690
687
  padding: EdgeInsets.fromLTRB(
691
688
  KasySpacing.pageHorizontalGutter,
692
- KasySpacing.smd,
689
+ KasySpacing.md,
693
690
  KasySpacing.pageHorizontalGutter,
694
691
  MediaQuery.paddingOf(context).bottom + KasySpacing.xl,
695
692
  ),
696
693
  child: LayoutBuilder(
697
694
  builder: (context, c) {
698
- final pager = _Pagination(
699
- page: page,
700
- pageCount: pageCount,
701
- onPage: onPage,
702
- );
703
695
  final label = Text(
704
696
  u.results(from: rangeFrom, to: rangeTo, total: total),
705
697
  style: context.textTheme.bodySmall?.copyWith(
706
698
  color: context.colors.muted,
707
699
  ),
708
700
  );
701
+ // A single page has no "previous/next" to offer — showing the
702
+ // disabled controls is just dead weight. Keep only the quiet
703
+ // count, centered, so the bar stays clean (pro dashboard style).
704
+ if (pageCount <= 1) {
705
+ return Center(child: label);
706
+ }
707
+ final pager = _Pagination(
708
+ page: page,
709
+ pageCount: pageCount,
710
+ onPage: onPage,
711
+ );
709
712
  if (c.maxWidth < 460) {
710
713
  return Column(
711
714
  children: [
@@ -802,32 +805,38 @@ class _PageNum extends StatelessWidget {
802
805
 
803
806
  @override
804
807
  Widget build(BuildContext context) {
805
- return InkWell(
806
- onTap: selected ? null : onTap,
807
- borderRadius: BorderRadius.circular(KasyRadius.sm),
808
- child: Container(
809
- constraints: const BoxConstraints(minWidth: 32),
810
- height: 32,
811
- alignment: Alignment.center,
812
- padding: const EdgeInsets.symmetric(horizontal: 6),
813
- decoration: BoxDecoration(
814
- color: selected ? context.colors.primary : context.colors.surface,
815
- borderRadius: BorderRadius.circular(KasyRadius.sm),
816
- border: Border.all(
817
- color: selected
818
- ? context.colors.primary
819
- : context.colors.outline.withValues(alpha: 0.5),
820
- ),
808
+ final Widget content = Container(
809
+ constraints: const BoxConstraints(minWidth: 32),
810
+ height: 32,
811
+ alignment: Alignment.center,
812
+ padding: const EdgeInsets.symmetric(horizontal: 6),
813
+ decoration: BoxDecoration(
814
+ // Unselected pages stay transparent (border only) so KasyHover's overlay
815
+ // shows through on web hover; the current page keeps the solid primary.
816
+ color: selected ? context.colors.primary : Colors.transparent,
817
+ borderRadius: BorderRadius.circular(KasyRadius.sm),
818
+ border: Border.all(
819
+ color: selected
820
+ ? context.colors.primary
821
+ : context.colors.outline.withValues(alpha: 0.5),
821
822
  ),
822
- child: Text(
823
- '$number',
824
- style: context.textTheme.labelMedium?.copyWith(
825
- color: selected ? context.colors.onPrimary : context.colors.onSurface,
826
- fontWeight: FontWeight.w700,
827
- ),
823
+ ),
824
+ child: Text(
825
+ '$number',
826
+ style: context.textTheme.labelMedium?.copyWith(
827
+ color: selected ? context.colors.onPrimary : context.colors.onSurface,
828
+ fontWeight: FontWeight.w700,
828
829
  ),
829
830
  ),
830
831
  );
832
+ // Current page: not tappable, no hover. Other pages get the kit hover.
833
+ if (selected) return content;
834
+ return KasyHover(
835
+ onTap: onTap,
836
+ focusable: true,
837
+ borderRadius: BorderRadius.circular(KasyRadius.sm),
838
+ child: content,
839
+ );
831
840
  }
832
841
  }
833
842
 
@@ -877,23 +886,27 @@ class _NavBtn extends StatelessWidget {
877
886
  ),
878
887
  );
879
888
  final ic = Icon(icon, size: 18, color: color);
880
- return InkWell(
881
- onTap: enabled ? onTap : null,
882
- borderRadius: BorderRadius.circular(KasyRadius.sm),
883
- child: Container(
884
- height: 32,
885
- padding: const EdgeInsets.symmetric(horizontal: 8),
886
- decoration: BoxDecoration(
887
- borderRadius: BorderRadius.circular(KasyRadius.sm),
888
- border: Border.all(
889
- color: context.colors.outline.withValues(alpha: enabled ? 0.5 : 0.25),
890
- ),
891
- ),
892
- child: Row(
893
- mainAxisSize: MainAxisSize.min,
894
- children: leading ? [ic, const SizedBox(width: 2), text] : [text, const SizedBox(width: 2), ic],
889
+ final Widget content = Container(
890
+ height: 32,
891
+ padding: const EdgeInsets.symmetric(horizontal: 8),
892
+ decoration: BoxDecoration(
893
+ borderRadius: BorderRadius.circular(KasyRadius.sm),
894
+ border: Border.all(
895
+ color: context.colors.outline.withValues(alpha: enabled ? 0.5 : 0.25),
895
896
  ),
896
897
  ),
898
+ child: Row(
899
+ mainAxisSize: MainAxisSize.min,
900
+ children: leading ? [ic, const SizedBox(width: 2), text] : [text, const SizedBox(width: 2), ic],
901
+ ),
902
+ );
903
+ // Disabled (prev/next at the list edges): no hover, cursor or tap.
904
+ if (!enabled) return content;
905
+ return KasyHover(
906
+ onTap: onTap,
907
+ focusable: true,
908
+ borderRadius: BorderRadius.circular(KasyRadius.sm),
909
+ child: content,
897
910
  );
898
911
  }
899
912
  }
@@ -943,6 +956,7 @@ class _UserRow extends StatelessWidget {
943
956
  children: [
944
957
  KasyAvatar(
945
958
  diameter: 34,
959
+ image: _avatarImage(user),
946
960
  initials: hasName ? user.name : (hasEmail ? user.email : null),
947
961
  tone: _avatarTone(
948
962
  hasName ? user.name : (hasEmail ? user.email : null),
@@ -1014,7 +1028,7 @@ class _UserRow extends StatelessWidget {
1014
1028
  SizedBox(
1015
1029
  width: 88,
1016
1030
  child: Text(
1017
- user.createdAt != null ? _formatDate(user.createdAt!) : '—',
1031
+ user.createdAt != null ? _formatJoined(user.createdAt!) : '—',
1018
1032
  style: context.textTheme.bodySmall?.copyWith(
1019
1033
  color: context.colors.muted,
1020
1034
  ),
@@ -1025,14 +1039,128 @@ class _UserRow extends StatelessWidget {
1025
1039
  );
1026
1040
  }
1027
1041
 
1028
- String _formatDate(DateTime dt) {
1029
- final d = dt.toLocal();
1030
- return '${d.day.toString().padLeft(2, '0')}/'
1031
- '${d.month.toString().padLeft(2, '0')}/'
1032
- '${d.year.toString().substring(2)}';
1042
+ }
1043
+
1044
+ /// Mobile card for a single user — the same data as a table row, stacked
1045
+ /// vertically so it reads comfortably on a narrow screen.
1046
+ class _UserCard extends StatelessWidget {
1047
+ final AdminUser user;
1048
+ const _UserCard({required this.user});
1049
+
1050
+ @override
1051
+ Widget build(BuildContext context) {
1052
+ final u = t.admin_console.users;
1053
+ final bool hasName = user.name?.isNotEmpty == true;
1054
+ final bool hasEmail = user.email?.isNotEmpty == true;
1055
+ final String primaryText =
1056
+ hasName ? user.name! : (hasEmail ? user.email! : u.anonymous);
1057
+ final String? subText = hasName && hasEmail ? user.email : null;
1058
+ final bool isAnonymous = !hasName && !hasEmail;
1059
+
1060
+ return KasyCard(
1061
+ // Default elevated (white surface + shadow over the F5F5F5 page) so the
1062
+ // card reads as a real card, not a transparent outline that blends into
1063
+ // the screen.
1064
+ padding: const EdgeInsets.all(KasySpacing.md),
1065
+ child: Column(
1066
+ crossAxisAlignment: CrossAxisAlignment.start,
1067
+ children: [
1068
+ Row(
1069
+ children: [
1070
+ KasyAvatar(
1071
+ diameter: 38,
1072
+ image: _avatarImage(user),
1073
+ initials: hasName ? user.name : (hasEmail ? user.email : null),
1074
+ tone: _avatarTone(
1075
+ hasName ? user.name : (hasEmail ? user.email : null),
1076
+ ),
1077
+ ),
1078
+ const SizedBox(width: KasySpacing.sm),
1079
+ Expanded(
1080
+ child: Column(
1081
+ crossAxisAlignment: CrossAxisAlignment.start,
1082
+ mainAxisSize: MainAxisSize.min,
1083
+ children: [
1084
+ Text(
1085
+ primaryText,
1086
+ maxLines: 1,
1087
+ overflow: TextOverflow.ellipsis,
1088
+ style: isAnonymous
1089
+ ? context.textTheme.bodyMedium?.copyWith(
1090
+ color: context.colors.muted,
1091
+ fontStyle: FontStyle.italic,
1092
+ )
1093
+ : context.kasyTextTheme.rowTitle.copyWith(
1094
+ color: context.colors.onSurface,
1095
+ ),
1096
+ ),
1097
+ if (subText != null) ...[
1098
+ const SizedBox(height: 1),
1099
+ Text(
1100
+ subText,
1101
+ maxLines: 1,
1102
+ overflow: TextOverflow.ellipsis,
1103
+ style: context.textTheme.bodySmall?.copyWith(
1104
+ color: context.colors.muted,
1105
+ ),
1106
+ ),
1107
+ ],
1108
+ ],
1109
+ ),
1110
+ ),
1111
+ ],
1112
+ ),
1113
+ const SizedBox(height: KasySpacing.smd),
1114
+ Divider(
1115
+ height: 1,
1116
+ color: context.colors.outline.withValues(alpha: 0.25),
1117
+ ),
1118
+ const SizedBox(height: KasySpacing.smd),
1119
+ Row(
1120
+ children: [
1121
+ Expanded(
1122
+ child: Wrap(
1123
+ spacing: KasySpacing.xs,
1124
+ runSpacing: KasySpacing.xs,
1125
+ children: [
1126
+ KasyStatusTag(
1127
+ label: hasEmail ? u.status_active : u.status_inactive,
1128
+ tone: hasEmail
1129
+ ? KasyStatusTagTone.success
1130
+ : KasyStatusTagTone.neutral,
1131
+ ),
1132
+ KasyStatusTag(
1133
+ label: user.subscriber ? u.plan_subscriber : u.plan_free,
1134
+ tone: user.subscriber
1135
+ ? KasyStatusTagTone.primary
1136
+ : KasyStatusTagTone.neutral,
1137
+ ),
1138
+ ],
1139
+ ),
1140
+ ),
1141
+ const SizedBox(width: KasySpacing.sm),
1142
+ Text(
1143
+ user.createdAt != null ? _formatJoined(user.createdAt!) : '—',
1144
+ style: context.textTheme.bodySmall?.copyWith(
1145
+ color: context.colors.muted,
1146
+ ),
1147
+ ),
1148
+ ],
1149
+ ),
1150
+ ],
1151
+ ),
1152
+ );
1033
1153
  }
1034
1154
  }
1035
1155
 
1156
+ /// Shared joined-date formatter (dd/MM/yy) for the table row and mobile card.
1157
+ String _formatJoined(DateTime dt) {
1158
+ final d = dt.toLocal();
1159
+ return '${d.day.toString().padLeft(2, '0')}/'
1160
+ '${d.month.toString().padLeft(2, '0')}/'
1161
+ '${d.year.toString().substring(2)}';
1162
+ }
1163
+
1036
1164
  // ─────────────────────────────────────────────────────────────────────────────
1037
1165
  // Avatar tone — a stable design-system tone per user (variety, not random).
1038
1166
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1049,6 +1177,14 @@ KasyAvatarTone _avatarTone(String? seed) {
1049
1177
  return tones[seed.trimLeft().codeUnitAt(0) % tones.length];
1050
1178
  }
1051
1179
 
1180
+ /// The user's photo when they have one, else null so [KasyAvatar] falls back to
1181
+ /// initials. The avatarPath is the public URL the backend already stores.
1182
+ ImageProvider? _avatarImage(AdminUser user) {
1183
+ final path = user.avatarPath;
1184
+ if (path == null || path.isEmpty) return null;
1185
+ return NetworkImage(path);
1186
+ }
1187
+
1052
1188
  // ─────────────────────────────────────────────────────────────────────────────
1053
1189
  // Loading / empty / error states (big icon over text)
1054
1190
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1123,18 +1259,109 @@ class _IconBubble extends StatelessWidget {
1123
1259
  }
1124
1260
  }
1125
1261
 
1262
+ /// Skeleton placeholder that mirrors the real layout (table rows on wide
1263
+ /// viewports, cards on mobile) so loading reads as the product, not a spinner.
1126
1264
  class _LoadingState extends StatelessWidget {
1127
- const _LoadingState();
1265
+ final bool cards;
1266
+ const _LoadingState({required this.cards});
1128
1267
 
1129
1268
  @override
1130
1269
  Widget build(BuildContext context) {
1131
- return _CenteredState(
1132
- icon: const SizedBox(
1133
- width: 40,
1134
- height: 40,
1135
- child: CircularProgressIndicator.adaptive(strokeWidth: 2.5),
1270
+ final int count = cards ? 5 : 7;
1271
+ return KasySkeletonGroup(
1272
+ child: SingleChildScrollView(
1273
+ physics: const NeverScrollableScrollPhysics(),
1274
+ padding: const EdgeInsets.fromLTRB(
1275
+ KasySpacing.pageHorizontalGutter,
1276
+ KasySpacing.lg,
1277
+ KasySpacing.pageHorizontalGutter,
1278
+ 0,
1279
+ ),
1280
+ child: Column(
1281
+ crossAxisAlignment: CrossAxisAlignment.stretch,
1282
+ children: [
1283
+ for (int i = 0; i < count; i++) ...[
1284
+ if (cards)
1285
+ const _UserCardSkeleton()
1286
+ else
1287
+ const _UserRowSkeleton(),
1288
+ SizedBox(height: cards ? KasySpacing.sm : KasySpacing.md),
1289
+ ],
1290
+ ],
1291
+ ),
1292
+ ),
1293
+ );
1294
+ }
1295
+ }
1296
+
1297
+ class _UserRowSkeleton extends StatelessWidget {
1298
+ const _UserRowSkeleton();
1299
+
1300
+ @override
1301
+ Widget build(BuildContext context) {
1302
+ return const Row(
1303
+ children: [
1304
+ KasySkeleton.circle(size: 34),
1305
+ SizedBox(width: 10),
1306
+ Expanded(
1307
+ child: Column(
1308
+ crossAxisAlignment: CrossAxisAlignment.start,
1309
+ mainAxisSize: MainAxisSize.min,
1310
+ children: [
1311
+ KasySkeleton(width: 140, height: 12),
1312
+ SizedBox(height: 6),
1313
+ KasySkeleton(width: 90, height: 10),
1314
+ ],
1315
+ ),
1316
+ ),
1317
+ SizedBox(width: 12),
1318
+ KasySkeleton(width: 56, height: 20),
1319
+ SizedBox(width: 12),
1320
+ KasySkeleton(width: 40, height: 12),
1321
+ ],
1322
+ );
1323
+ }
1324
+ }
1325
+
1326
+ class _UserCardSkeleton extends StatelessWidget {
1327
+ const _UserCardSkeleton();
1328
+
1329
+ @override
1330
+ Widget build(BuildContext context) {
1331
+ return const KasyCard(
1332
+ padding: EdgeInsets.all(KasySpacing.md),
1333
+ child: Column(
1334
+ crossAxisAlignment: CrossAxisAlignment.start,
1335
+ children: [
1336
+ Row(
1337
+ children: [
1338
+ KasySkeleton.circle(size: 38),
1339
+ SizedBox(width: KasySpacing.sm),
1340
+ Expanded(
1341
+ child: Column(
1342
+ crossAxisAlignment: CrossAxisAlignment.start,
1343
+ mainAxisSize: MainAxisSize.min,
1344
+ children: [
1345
+ KasySkeleton(width: 150, height: 13),
1346
+ SizedBox(height: 6),
1347
+ KasySkeleton(width: 100, height: 10),
1348
+ ],
1349
+ ),
1350
+ ),
1351
+ ],
1352
+ ),
1353
+ SizedBox(height: KasySpacing.md),
1354
+ Row(
1355
+ children: [
1356
+ KasySkeleton(width: 64, height: 22),
1357
+ SizedBox(width: 8),
1358
+ KasySkeleton(width: 64, height: 22),
1359
+ Spacer(),
1360
+ KasySkeleton(width: 44, height: 12),
1361
+ ],
1362
+ ),
1363
+ ],
1136
1364
  ),
1137
- title: t.admin_console.users.loading,
1138
1365
  );
1139
1366
  }
1140
1367
  }
@@ -1167,26 +1394,11 @@ class _ErrorState extends StatelessWidget {
1167
1394
  icon: const _IconBubble(Icons.error_outline_rounded),
1168
1395
  title: u.title,
1169
1396
  subtitle: u.error,
1170
- action: InkWell(
1171
- onTap: onRetry,
1172
- borderRadius: BorderRadius.circular(KasyRadius.sm),
1173
- child: Container(
1174
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
1175
- decoration: BoxDecoration(
1176
- color: context.colors.surface,
1177
- borderRadius: BorderRadius.circular(KasyRadius.sm),
1178
- border: Border.all(
1179
- color: context.colors.outline.withValues(alpha: 0.5),
1180
- ),
1181
- ),
1182
- child: Text(
1183
- u.retry,
1184
- style: context.textTheme.labelMedium?.copyWith(
1185
- color: context.colors.onSurface,
1186
- fontWeight: FontWeight.w600,
1187
- ),
1188
- ),
1189
- ),
1397
+ action: KasyButton(
1398
+ label: u.retry,
1399
+ variant: KasyButtonVariant.outline,
1400
+ size: KasyButtonSize.small,
1401
+ onPressed: onRetry,
1190
1402
  ),
1191
1403
  );
1192
1404
  }