kasy-cli 1.21.8 → 1.22.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 (269) hide show
  1. package/lib/commands/add.js +93 -80
  2. package/lib/commands/configure.js +100 -32
  3. package/lib/commands/doctor.js +28 -2
  4. package/lib/commands/new.js +86 -38
  5. package/lib/commands/notifications.js +1 -1
  6. package/lib/commands/remove.js +43 -15
  7. package/lib/commands/run.js +2 -2
  8. package/lib/commands/update.js +2 -2
  9. package/lib/scaffold/CHANGELOG.json +14 -0
  10. package/lib/scaffold/backends/api/generator.js +14 -14
  11. package/lib/scaffold/backends/api/patch/README.md +83 -0
  12. package/lib/scaffold/backends/api/patch/lib/core/data/api/storage_api.dart +1 -1
  13. package/lib/scaffold/backends/api/patch/lib/core/data/entities/user_entity.dart +5 -0
  14. package/lib/scaffold/backends/api/patch/lib/{environnements.dart → environments.dart} +3 -11
  15. package/lib/scaffold/backends/api/patch/lib/features/ai_chat/api/ai_chat_api.dart +108 -0
  16. package/lib/scaffold/backends/api/patch/lib/features/ai_chat/api/ai_chat_conversation_entity.dart +51 -0
  17. package/lib/scaffold/backends/api/patch/lib/features/{llm_chat/api/llm_chat_message_entity.dart → ai_chat/api/ai_chat_message_entity.dart} +5 -5
  18. package/lib/scaffold/backends/api/patch/lib/features/{llm_chat/providers/llm_chat_notifier.dart → ai_chat/providers/ai_chat_notifier.dart} +71 -38
  19. package/lib/scaffold/backends/api/patch/lib/features/authentication/api/authentication_api.dart +2 -2
  20. package/lib/scaffold/backends/api/patch/lib/features/feedbacks/api/feature_request_api.dart +54 -0
  21. package/lib/scaffold/backends/api/patch/lib/features/onboarding/models/user_info.dart +16 -16
  22. package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/components/onboarding_att_setup.dart +4 -3
  23. package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +38 -28
  24. package/lib/scaffold/backends/api/patch/lib/features/settings/ui/components/admin/admin_users_api.dart +100 -0
  25. package/lib/scaffold/backends/api/patch/lib/features/{subscription → subscriptions}/api/entities/subscription_entity.dart +13 -0
  26. package/lib/scaffold/backends/api/patch/lib/features/subscriptions/api/stripe_backend_api.dart +60 -0
  27. package/lib/scaffold/backends/api/patch/lib/features/{subscription → subscriptions}/api/subscription_api.dart +1 -1
  28. package/lib/scaffold/backends/api/pubspec.yaml.tpl +4 -5
  29. package/lib/scaffold/backends/firebase/deploy.js +87 -13
  30. package/lib/scaffold/backends/firebase/enable-auth-via-cli.js +14 -6
  31. package/lib/scaffold/backends/firebase/generator.js +5 -5
  32. package/lib/scaffold/backends/firebase/setup-from-scratch.js +69 -45
  33. package/lib/scaffold/backends/firebase/tokens.js +4 -4
  34. package/lib/scaffold/backends/supabase/deploy.js +63 -11
  35. package/lib/scaffold/backends/supabase/edge-functions/admin-list-users/index.ts +149 -0
  36. package/lib/scaffold/backends/supabase/edge-functions/{llm-chat → ai-chat}/index.ts +19 -19
  37. package/lib/scaffold/backends/supabase/edge-functions/revenuecat-webhook/index.ts +2 -0
  38. package/lib/scaffold/backends/supabase/edge-functions/stripe-create-checkout-session/index.ts +123 -0
  39. package/lib/scaffold/backends/supabase/edge-functions/stripe-create-portal-session/index.ts +97 -0
  40. package/lib/scaffold/backends/supabase/edge-functions/stripe-list-prices/index.ts +83 -0
  41. package/lib/scaffold/backends/supabase/edge-functions/stripe-webhook/index.ts +138 -0
  42. package/lib/scaffold/backends/supabase/generator.js +17 -17
  43. package/lib/scaffold/backends/supabase/migrations/20240101000009_ai_messages.sql +50 -0
  44. package/lib/scaffold/backends/supabase/migrations/20240101000012_stripe_customers.sql +36 -0
  45. package/lib/scaffold/backends/supabase/migrations/20240101000013_admin_role.sql +62 -0
  46. package/lib/scaffold/backends/supabase/patch/lib/core/data/entities/user_entity.dart +4 -0
  47. package/lib/scaffold/backends/supabase/patch/lib/{environnements.dart → environments.dart} +3 -13
  48. package/lib/scaffold/backends/supabase/patch/lib/features/ai_chat/api/ai_chat_api.dart +95 -0
  49. package/lib/scaffold/backends/supabase/patch/lib/features/ai_chat/api/ai_chat_conversation_entity.dart +52 -0
  50. package/lib/scaffold/backends/supabase/patch/lib/features/{llm_chat/api/llm_chat_message_entity.dart → ai_chat/api/ai_chat_message_entity.dart} +6 -6
  51. package/lib/scaffold/backends/supabase/patch/lib/features/{llm_chat/providers/llm_chat_notifier.dart → ai_chat/providers/ai_chat_notifier.dart} +63 -35
  52. package/lib/scaffold/backends/supabase/patch/lib/features/authentication/api/authentication_api.dart +1 -1
  53. package/lib/scaffold/backends/supabase/patch/lib/features/feedbacks/api/feature_request_api.dart +46 -0
  54. package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/models/user_info.dart +16 -16
  55. package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/components/onboarding_att_setup.dart +4 -3
  56. package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +38 -28
  57. package/lib/scaffold/backends/supabase/patch/lib/features/settings/ui/components/admin/admin_users_api.dart +93 -0
  58. package/lib/scaffold/backends/supabase/patch/lib/features/{subscription → subscriptions}/api/entities/subscription_entity.dart +13 -0
  59. package/lib/scaffold/backends/supabase/patch/lib/features/subscriptions/api/stripe_backend_api.dart +52 -0
  60. package/lib/scaffold/backends/supabase/patch/lib/features/{subscription → subscriptions}/api/subscription_api.dart +1 -1
  61. package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +4 -5
  62. package/lib/scaffold/backends/supabase/tokens.js +3 -3
  63. package/lib/scaffold/catalog.js +9 -11
  64. package/lib/scaffold/generate.js +45 -31
  65. package/lib/scaffold/shared/generator-utils.js +188 -81
  66. package/lib/scaffold/shared/sort-imports.js +191 -0
  67. package/lib/scaffold/shared/template-strings.js +3 -3
  68. package/lib/utils/checks.js +2 -2
  69. package/lib/utils/i18n/messages-en.js +50 -35
  70. package/lib/utils/i18n/messages-es.js +50 -35
  71. package/lib/utils/i18n/messages-pt.js +52 -37
  72. package/lib/utils/updates.js +15 -15
  73. package/package.json +1 -1
  74. package/templates/firebase/.env.example +2 -2
  75. package/templates/firebase/android/app/src/main/res/drawable/background.png +0 -0
  76. package/templates/firebase/android/app/src/main/res/drawable-night/background.png +0 -0
  77. package/templates/firebase/android/app/src/main/res/drawable-night-v21/background.png +0 -0
  78. package/templates/firebase/android/app/src/main/res/drawable-v21/background.png +0 -0
  79. package/templates/firebase/android/app/src/main/res/values-night-v31/styles.xml +1 -1
  80. package/templates/firebase/android/app/src/main/res/values-v31/styles.xml +1 -1
  81. package/templates/firebase/assets/images/logo_wordmark_dark.png +0 -0
  82. package/templates/firebase/assets/images/logo_wordmark_light.png +0 -0
  83. package/templates/firebase/docs/revenuecat-setup.es.md +1 -1
  84. package/templates/firebase/docs/revenuecat-setup.pt.md +1 -1
  85. package/templates/firebase/firestore.rules +24 -5
  86. package/templates/firebase/functions/package-lock.json +22 -1
  87. package/templates/firebase/functions/package.json +2 -1
  88. package/templates/firebase/functions/src/admin/functions.ts +113 -0
  89. package/templates/firebase/functions/src/{llm_chat → ai_chat}/index.ts +16 -16
  90. package/templates/firebase/functions/src/index.ts +8 -2
  91. package/templates/firebase/functions/src/notifications/device_triggers.ts +2 -2
  92. package/templates/firebase/functions/src/notifications/triggers.ts +3 -3
  93. package/templates/firebase/functions/src/subscriptions/models/subscription_status.ts +2 -0
  94. package/templates/firebase/functions/src/subscriptions/stripe_functions.ts +222 -0
  95. package/templates/firebase/functions/src/subscriptions/subscriptions_functions.ts +2 -2
  96. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png +0 -0
  97. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png +0 -0
  98. package/templates/firebase/lib/components/components.dart +4 -1
  99. package/templates/firebase/lib/components/kasy_app_bar.dart +22 -7
  100. package/templates/firebase/lib/components/kasy_avatar.dart +7 -6
  101. package/templates/firebase/lib/components/kasy_button.dart +23 -99
  102. package/templates/firebase/lib/components/kasy_dialog.dart +11 -11
  103. package/templates/firebase/lib/components/kasy_image_viewer.dart +84 -0
  104. package/templates/firebase/lib/components/{kasy_sidebar_pro.dart → kasy_sidebar.dart} +692 -425
  105. package/templates/firebase/lib/components/kasy_status_tag.dart +91 -0
  106. package/templates/firebase/lib/components/kasy_text_area.dart +1 -3
  107. package/templates/firebase/lib/components/kasy_text_field.dart +5 -6
  108. package/templates/firebase/lib/components/kasy_text_field_otp.dart +1 -3
  109. package/templates/firebase/lib/components/kasy_toast.dart +2 -2
  110. package/templates/firebase/lib/components/kasy_web_header.dart +209 -0
  111. package/templates/firebase/lib/core/ads/ads_provider.dart +1 -1
  112. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +57 -4
  113. package/templates/firebase/lib/core/bottom_menu/bottom_router.dart +19 -91
  114. package/templates/firebase/lib/core/bottom_menu/kasy_bottom_bar_factory.dart +196 -96
  115. package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +41 -0
  116. package/templates/firebase/lib/core/config/app_env.dart +5 -11
  117. package/templates/firebase/lib/core/config/features.dart +5 -4
  118. package/templates/firebase/lib/core/data/api/analytics_api.dart +1 -1
  119. package/templates/firebase/lib/core/data/api/http_client.dart +1 -1
  120. package/templates/firebase/lib/core/data/entities/user_entity.dart +3 -0
  121. package/templates/firebase/lib/core/data/models/entitlement.dart +35 -0
  122. package/templates/firebase/lib/core/data/models/subscription.dart +13 -186
  123. package/templates/firebase/lib/core/data/models/user.dart +11 -0
  124. package/templates/firebase/lib/core/data/repositories/user_repository.dart +1 -1
  125. package/templates/firebase/lib/core/home_widgets/home_widget_background_task.dart +1 -1
  126. package/templates/firebase/lib/core/home_widgets/home_widget_mywidget_service.dart +1 -1
  127. package/templates/firebase/lib/core/icons/kasy_icons.dart +31 -1
  128. package/templates/firebase/lib/core/rating/api/rating_api.dart +2 -2
  129. package/templates/firebase/lib/core/states/logout_action.dart +25 -0
  130. package/templates/firebase/lib/core/states/user_state_notifier.dart +3 -3
  131. package/templates/firebase/lib/core/theme/colors.dart +488 -188
  132. package/templates/firebase/lib/core/theme/radius.dart +22 -11
  133. package/templates/firebase/lib/core/theme/shadows.dart +66 -0
  134. package/templates/firebase/lib/core/theme/texts.dart +75 -41
  135. package/templates/firebase/lib/core/theme/universal_theme.dart +9 -4
  136. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +5 -4
  137. package/templates/firebase/lib/core/web_viewport_scale.dart +52 -0
  138. package/templates/firebase/lib/core/widgets/kasy_brand_badge.dart +118 -0
  139. package/templates/firebase/lib/core/widgets/kasy_brand_logo.dart +40 -0
  140. package/templates/firebase/lib/core/widgets/kasy_focus_ring.dart +125 -0
  141. package/templates/firebase/lib/core/widgets/kasy_hover.dart +33 -13
  142. package/templates/firebase/lib/core/widgets/kasy_pressable_depth.dart +18 -13
  143. package/templates/firebase/lib/{environnements.dart → environments.dart} +3 -14
  144. package/templates/firebase/lib/features/ai_chat/ai_chat_page.dart +159 -0
  145. package/templates/firebase/lib/features/ai_chat/api/ai_chat_api.dart +107 -0
  146. package/templates/firebase/lib/features/ai_chat/api/ai_chat_conversation_entity.dart +64 -0
  147. package/templates/firebase/lib/features/{llm_chat/api/llm_chat_message_entity.dart → ai_chat/api/ai_chat_message_entity.dart} +6 -6
  148. package/templates/firebase/lib/features/{llm_chat/providers/llm_chat_notifier.dart → ai_chat/providers/ai_chat_notifier.dart} +73 -38
  149. package/templates/firebase/lib/features/ai_chat/providers/ai_conversations_notifier.dart +103 -0
  150. package/templates/firebase/lib/features/{llm_chat/ui/widgets/llm_chat_avatars.dart → ai_chat/ui/widgets/ai_chat_avatars.dart} +13 -13
  151. package/templates/firebase/lib/features/{llm_chat/ui/widgets/llm_chat_composer.dart → ai_chat/ui/widgets/ai_chat_composer.dart} +10 -10
  152. package/templates/firebase/lib/features/{llm_chat/llm_chat_page.dart → ai_chat/ui/widgets/ai_chat_conversation_view.dart} +80 -67
  153. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_list.dart +285 -0
  154. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +163 -0
  155. package/templates/firebase/lib/features/authentication/api/authentication_api.dart +52 -13
  156. package/templates/firebase/lib/features/authentication/api/popup_dismiss_watcher.dart +12 -0
  157. package/templates/firebase/lib/features/authentication/api/popup_dismiss_watcher_web.dart +35 -0
  158. package/templates/firebase/lib/features/authentication/ui/recover_password_page.dart +108 -68
  159. package/templates/firebase/lib/features/authentication/ui/signin_page.dart +38 -51
  160. package/templates/firebase/lib/features/authentication/ui/signup_page.dart +38 -51
  161. package/templates/firebase/lib/features/authentication/ui/widgets/auth_card_scaffold.dart +118 -0
  162. package/templates/firebase/lib/features/authentication/ui/widgets/recover_password_result.dart +61 -44
  163. package/templates/firebase/lib/features/feedbacks/api/feature_request_api.dart +32 -0
  164. package/templates/firebase/lib/features/feedbacks/models/feedback_state.dart +5 -5
  165. package/templates/firebase/lib/features/feedbacks/providers/feedback_page_notifier.dart +2 -2
  166. package/templates/firebase/lib/features/home/design_system_page.dart +808 -170
  167. package/templates/firebase/lib/features/home/home_components_page.dart +6 -3
  168. package/templates/firebase/lib/features/home/home_components_preview_page.dart +6 -6
  169. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +325 -186
  170. package/templates/firebase/lib/features/home/home_feed.dart +289 -0
  171. package/templates/firebase/lib/features/home/home_image_grid.dart +355 -0
  172. package/templates/firebase/lib/features/home/home_page.dart +11 -250
  173. package/templates/firebase/lib/features/{local_reminder → local_reminders}/providers/reminder_notifier.dart +1 -1
  174. package/templates/firebase/lib/features/{local_reminder → local_reminders}/ui/reminder_page.dart +2 -2
  175. package/templates/firebase/lib/features/notifications/shared/att_permission.dart +1 -1
  176. package/templates/firebase/lib/features/notifications/ui/request_notification_permission.dart +25 -61
  177. package/templates/firebase/lib/features/notifications/ui/widgets/permission_request_view.dart +117 -0
  178. package/templates/firebase/lib/features/onboarding/models/user_info.dart +16 -16
  179. package/templates/firebase/lib/features/onboarding/ui/components/onboarding_att_setup.dart +4 -3
  180. package/templates/firebase/lib/features/onboarding/ui/components/onboarding_features.dart +7 -9
  181. package/templates/firebase/lib/features/onboarding/ui/components/onboarding_loader.dart +71 -48
  182. package/templates/firebase/lib/features/onboarding/ui/components/onboarding_notifications_setup.dart +3 -2
  183. package/templates/firebase/lib/features/onboarding/ui/components/onboarding_questions.dart +5 -5
  184. package/templates/firebase/lib/features/onboarding/ui/onboarding_page.dart +4 -4
  185. package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_background.dart +4 -2
  186. package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_feature.dart +39 -121
  187. package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_illustration_scaffold.dart +105 -70
  188. package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_module_mockups.dart +639 -0
  189. package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_progress.dart +62 -50
  190. package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +38 -28
  191. package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_step_header.dart +75 -0
  192. package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_sticky_footer.dart +16 -5
  193. package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +26 -22
  194. package/templates/firebase/lib/features/settings/settings_page.dart +601 -90
  195. package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +1193 -0
  196. package/templates/firebase/lib/features/settings/ui/components/admin/admin_paywalls.dart +1 -1
  197. package/templates/firebase/lib/features/settings/ui/components/admin/admin_routes.dart +2 -3
  198. package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_api.dart +86 -0
  199. package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_tab.dart +1215 -0
  200. package/templates/firebase/lib/features/settings/ui/components/admin/send_push_notification_page.dart +236 -0
  201. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +3 -3
  202. package/templates/firebase/lib/features/settings/ui/widgets/kasy_user_avatar.dart +77 -0
  203. package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +1 -0
  204. package/templates/firebase/lib/features/{subscription → subscriptions}/api/entities/subscription_entity.dart +17 -0
  205. package/templates/firebase/lib/features/{subscription → subscriptions}/api/inapp_subscription_api.dart +67 -46
  206. package/templates/firebase/lib/features/subscriptions/api/revenuecat_product.dart +189 -0
  207. package/templates/firebase/lib/features/subscriptions/api/stripe_backend_api.dart +55 -0
  208. package/templates/firebase/lib/features/subscriptions/api/stripe_payment_api.dart +82 -0
  209. package/templates/firebase/lib/features/subscriptions/api/stripe_product.dart +178 -0
  210. package/templates/firebase/lib/features/{subscription → subscriptions}/api/subscription_api.dart +1 -1
  211. package/templates/firebase/lib/features/subscriptions/api/subscription_payment_api.dart +53 -0
  212. package/templates/firebase/lib/features/subscriptions/api/subscription_payment_api_provider.dart +21 -0
  213. package/templates/firebase/lib/features/{subscription → subscriptions}/providers/premium_page_provider.dart +9 -6
  214. package/templates/firebase/lib/features/{subscription → subscriptions}/repositories/subscription_repository.dart +26 -30
  215. package/{lib/scaffold/backends/supabase/patch/lib/features/subscription → templates/firebase/lib/features/subscriptions}/shared/maybeshow_premium.dart +0 -2
  216. package/templates/firebase/lib/features/subscriptions/shared/subscription_management.dart +45 -0
  217. package/templates/firebase/lib/features/{subscription → subscriptions}/ui/component/active_premium_content.dart +28 -4
  218. package/templates/firebase/lib/features/{subscription → subscriptions}/ui/component/paywall_minimal.dart +7 -7
  219. package/templates/firebase/lib/features/{subscription → subscriptions}/ui/component/paywall_row.dart +8 -8
  220. package/templates/firebase/lib/features/{subscription → subscriptions}/ui/component/paywall_with_switch.dart +7 -7
  221. package/templates/firebase/lib/features/{subscription → subscriptions}/ui/component/premium_content.dart +7 -7
  222. package/templates/firebase/lib/features/{subscription → subscriptions}/ui/component/premium_page_factory.dart +5 -5
  223. package/templates/firebase/lib/features/{subscription → subscriptions}/ui/premium_page.dart +5 -5
  224. package/templates/firebase/lib/features/{subscription → subscriptions}/ui/widgets/comparison_table.dart +1 -1
  225. package/templates/firebase/lib/features/{subscription → subscriptions}/ui/widgets/paywall_empty_state.dart +4 -4
  226. package/templates/firebase/lib/features/{subscription → subscriptions}/ui/widgets/premium_bottom_menu.dart +3 -4
  227. package/templates/firebase/lib/features/{subscription → subscriptions}/ui/widgets/selectable_col.dart +1 -1
  228. package/templates/firebase/lib/i18n/en.i18n.json +171 -46
  229. package/templates/firebase/lib/i18n/es.i18n.json +175 -50
  230. package/templates/firebase/lib/i18n/pt.i18n.json +166 -41
  231. package/templates/firebase/lib/main.dart +6 -3
  232. package/templates/firebase/lib/router.dart +15 -23
  233. package/templates/firebase/pubspec.yaml +4 -5
  234. package/templates/firebase/test/core/data/repositories/user_repository_test.dart +3 -3
  235. package/templates/firebase/test/core/states/user_state_notifier_test.dart +4 -4
  236. package/templates/firebase/test/core/widgets/focus_ring_shape_test.dart +55 -0
  237. package/templates/firebase/test/features/{subscription → subscriptions}/api/fake_inapp_subscription_api.dart +11 -4
  238. package/templates/firebase/test/features/{subscription → subscriptions}/api/fake_revenuecat_product.dart +1 -0
  239. package/templates/firebase/test/features/{subscription → subscriptions}/api/fake_subscription_api.dart +2 -2
  240. package/templates/firebase/test/features/{subscription → subscriptions}/subscription_page_test.dart +4 -4
  241. package/templates/firebase/test/test_utils.dart +6 -6
  242. package/templates/firebase/web/index.html +5 -2
  243. package/lib/scaffold/backends/api/patch/lib/features/llm_chat/api/llm_chat_api.dart +0 -58
  244. package/lib/scaffold/backends/api/patch/lib/features/subscription/shared/maybeshow_premium.dart +0 -86
  245. package/lib/scaffold/backends/supabase/migrations/20240101000009_llm_messages.sql +0 -22
  246. package/lib/scaffold/backends/supabase/patch/lib/features/llm_chat/api/llm_chat_api.dart +0 -47
  247. package/templates/firebase/assets/images/onboarding/authentication-login-template.jpg +0 -0
  248. package/templates/firebase/assets/images/onboarding/img2.jpg +0 -0
  249. package/templates/firebase/assets/images/onboarding/img3.jpg +0 -0
  250. package/templates/firebase/assets/images/onboarding/notifications.png +0 -0
  251. package/templates/firebase/assets/images/onboarding/purchase.png +0 -0
  252. package/templates/firebase/lib/core/sidebar/kasy_sidebar.dart +0 -2021
  253. package/templates/firebase/lib/features/authentication/ui/widgets/auth_brand.dart +0 -35
  254. package/templates/firebase/lib/features/home/home_features_page.dart +0 -207
  255. package/templates/firebase/lib/features/llm_chat/api/llm_chat_api.dart +0 -50
  256. package/templates/firebase/lib/features/settings/ui/components/admin/admin_bottom_sheet.dart +0 -316
  257. package/templates/firebase/lib/features/subscription/shared/maybeshow_premium.dart +0 -85
  258. /package/templates/firebase/lib/features/{local_reminder → local_reminders}/repositories/reminder_preferences.dart +0 -0
  259. /package/templates/firebase/lib/features/{subscription → subscriptions}/providers/models/premium_state.dart +0 -0
  260. /package/templates/firebase/lib/features/{subscription → subscriptions}/ui/widgets/feature_line.dart +0 -0
  261. /package/templates/firebase/lib/features/{subscription → subscriptions}/ui/widgets/premium_background.dart +0 -0
  262. /package/templates/firebase/lib/features/{subscription → subscriptions}/ui/widgets/premium_background_gradient.dart +0 -0
  263. /package/templates/firebase/lib/features/{subscription → subscriptions}/ui/widgets/premium_banner.dart +0 -0
  264. /package/templates/firebase/lib/features/{subscription → subscriptions}/ui/widgets/premium_card.dart +0 -0
  265. /package/templates/firebase/lib/features/{subscription → subscriptions}/ui/widgets/premium_close_button.dart +0 -0
  266. /package/templates/firebase/lib/features/{subscription → subscriptions}/ui/widgets/premium_feature.dart +0 -0
  267. /package/templates/firebase/lib/features/{subscription → subscriptions}/ui/widgets/selectable_row.dart +0 -0
  268. /package/templates/firebase/lib/features/{subscription → subscriptions}/ui/widgets/trial_switcher.dart +0 -0
  269. /package/templates/firebase/lib/features/{subscription → subscriptions}/ui/widgets/unsubscribe_feedback_popup.dart +0 -0
@@ -0,0 +1,1215 @@
1
+ import 'dart:math';
2
+
3
+ import 'package:flutter/material.dart';
4
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
5
+ import 'package:kasy_kit/components/kasy_avatar.dart';
6
+ import 'package:kasy_kit/components/kasy_status_tag.dart';
7
+ import 'package:kasy_kit/core/theme/theme.dart';
8
+ import 'package:kasy_kit/features/settings/ui/components/admin/admin_users_api.dart';
9
+ import 'package:kasy_kit/i18n/translations.g.dart';
10
+
11
+ // ─────────────────────────────────────────────────────────────────────────────
12
+ // Data: the whole (bounded) set is loaded once; the tab searches / sorts /
13
+ // paginates it locally so every interaction is instant and free of extra reads.
14
+ // ─────────────────────────────────────────────────────────────────────────────
15
+
16
+ final _usersResultProvider = FutureProvider.autoDispose<AdminUsersResult>(
17
+ (ref) => ref.read(adminUsersApiProvider).fetch(),
18
+ );
19
+
20
+ const double _maxWidth = 1080;
21
+ const double _filterWidth = 208;
22
+ const int _pageSize = 10;
23
+
24
+ /// Which column the table is sorted by. `null` means the smart default order:
25
+ /// active first → subscribers first → newest first (what the admin asked for).
26
+ enum _SortCol { user, status, plan, joined }
27
+
28
+ // ─────────────────────────────────────────────────────────────────────────────
29
+ // Tab root
30
+ // ─────────────────────────────────────────────────────────────────────────────
31
+
32
+ class AdminUsersTab extends ConsumerStatefulWidget {
33
+ const AdminUsersTab({super.key});
34
+
35
+ @override
36
+ ConsumerState<AdminUsersTab> createState() => _AdminUsersTabState();
37
+ }
38
+
39
+ class _AdminUsersTabState extends ConsumerState<AdminUsersTab> {
40
+ final _searchCtrl = TextEditingController();
41
+ String _search = '';
42
+ bool _subscribersOnly = false;
43
+ _SortCol? _sortCol; // null = smart default
44
+ bool _asc = true;
45
+ int _page = 0;
46
+
47
+ @override
48
+ void dispose() {
49
+ _searchCtrl.dispose();
50
+ super.dispose();
51
+ }
52
+
53
+ void _onSearch(String v) => setState(() {
54
+ _search = v;
55
+ _page = 0;
56
+ });
57
+
58
+ void _setSubscribersOnly(bool v) => setState(() {
59
+ _subscribersOnly = v;
60
+ _page = 0;
61
+ });
62
+
63
+ void _onSort(_SortCol col) => setState(() {
64
+ if (_sortCol == col) {
65
+ _asc = !_asc;
66
+ } else {
67
+ _sortCol = col;
68
+ _asc = col != _SortCol.joined; // joined defaults to newest-first
69
+ }
70
+ _page = 0;
71
+ });
72
+
73
+ // ── Sort helpers ────────────────────────────────────────────────────────
74
+ int _rankActive(AdminUser u) => (u.email?.isNotEmpty == true) ? 0 : 1;
75
+ int _rankSub(AdminUser u) => u.subscriber ? 0 : 1;
76
+ int _byJoined(AdminUser a, AdminUser b) =>
77
+ (a.createdAt?.millisecondsSinceEpoch ?? 0)
78
+ .compareTo(b.createdAt?.millisecondsSinceEpoch ?? 0);
79
+ String _displayKey(AdminUser u) =>
80
+ (u.name?.isNotEmpty == true ? u.name! : (u.email ?? '~')).toLowerCase();
81
+
82
+ int _compare(AdminUser a, AdminUser b) {
83
+ if (_sortCol == null) {
84
+ final byActive = _rankActive(a).compareTo(_rankActive(b));
85
+ if (byActive != 0) return byActive; // active first
86
+ final bySub = _rankSub(a).compareTo(_rankSub(b));
87
+ if (bySub != 0) return bySub; // subscribers first
88
+ return -_byJoined(a, b); // newest first
89
+ }
90
+ int r;
91
+ switch (_sortCol!) {
92
+ case _SortCol.user:
93
+ r = _displayKey(a).compareTo(_displayKey(b));
94
+ case _SortCol.status:
95
+ r = _rankActive(a).compareTo(_rankActive(b));
96
+ case _SortCol.plan:
97
+ r = _rankSub(a).compareTo(_rankSub(b));
98
+ case _SortCol.joined:
99
+ r = _byJoined(a, b);
100
+ }
101
+ if (r == 0) r = -_byJoined(a, b);
102
+ return _asc ? r : -r;
103
+ }
104
+
105
+ List<AdminUser> _process(List<AdminUser> all) {
106
+ final q = _search.trim().toLowerCase();
107
+ final list = all.where((u) {
108
+ if (_subscribersOnly && !u.subscriber) return false;
109
+ if (q.isEmpty) return true;
110
+ final email = (u.email ?? '').toLowerCase();
111
+ final name = (u.name ?? '').toLowerCase();
112
+ return email.contains(q) || name.contains(q);
113
+ }).toList()
114
+ ..sort(_compare);
115
+ return list;
116
+ }
117
+
118
+ @override
119
+ Widget build(BuildContext context) {
120
+ final async = ref.watch(_usersResultProvider);
121
+
122
+ 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,
152
+ ),
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
+ ),
165
+ ),
166
+ ],
167
+ );
168
+ },
169
+ ),
170
+ );
171
+ }
172
+
173
+ void _refresh() {
174
+ setState(() => _page = 0);
175
+ ref.invalidate(_usersResultProvider);
176
+ }
177
+ }
178
+
179
+ // ─────────────────────────────────────────────────────────────────────────────
180
+ // Toolbar: count · search · subscribers filter · refresh
181
+ // ─────────────────────────────────────────────────────────────────────────────
182
+
183
+ class _Toolbar extends StatelessWidget {
184
+ final TextEditingController controller;
185
+ final int resultCount;
186
+ final bool subscribersOnly;
187
+ final ValueChanged<String> onSearch;
188
+ final ValueChanged<bool> onSubscribersOnly;
189
+ final VoidCallback onRefresh;
190
+
191
+ const _Toolbar({
192
+ required this.controller,
193
+ required this.resultCount,
194
+ required this.subscribersOnly,
195
+ required this.onSearch,
196
+ required this.onSubscribersOnly,
197
+ required this.onRefresh,
198
+ });
199
+
200
+ @override
201
+ Widget build(BuildContext context) {
202
+ final u = t.admin_console.users;
203
+ final count = _ResultCount(value: resultCount, label: u.title);
204
+ final filter = _FilterButton(
205
+ subscribersOnly: subscribersOnly,
206
+ onChanged: onSubscribersOnly,
207
+ );
208
+ final refresh = _IconAction(
209
+ icon: Icons.refresh_rounded,
210
+ tooltip: u.refresh,
211
+ onTap: onRefresh,
212
+ );
213
+
214
+ return Padding(
215
+ padding: const EdgeInsets.fromLTRB(
216
+ KasySpacing.pageHorizontalGutter,
217
+ KasySpacing.md,
218
+ KasySpacing.pageHorizontalGutter,
219
+ 0,
220
+ ),
221
+ child: LayoutBuilder(
222
+ builder: (context, c) {
223
+ final compact = c.maxWidth < 560;
224
+ if (compact) {
225
+ return Column(
226
+ crossAxisAlignment: CrossAxisAlignment.stretch,
227
+ children: [
228
+ Row(
229
+ children: [
230
+ count,
231
+ const Spacer(),
232
+ filter,
233
+ const SizedBox(width: KasySpacing.xs),
234
+ refresh,
235
+ ],
236
+ ),
237
+ const SizedBox(height: KasySpacing.sm),
238
+ _SearchField(controller: controller, onChanged: onSearch),
239
+ ],
240
+ );
241
+ }
242
+ return Row(
243
+ children: [
244
+ count,
245
+ const Spacer(),
246
+ SizedBox(
247
+ width: 240,
248
+ child: _SearchField(controller: controller, onChanged: onSearch),
249
+ ),
250
+ const SizedBox(width: KasySpacing.sm),
251
+ filter,
252
+ const SizedBox(width: KasySpacing.xs),
253
+ refresh,
254
+ ],
255
+ );
256
+ },
257
+ ),
258
+ );
259
+ }
260
+ }
261
+
262
+ class _ResultCount extends StatelessWidget {
263
+ final int value;
264
+ final String label;
265
+ const _ResultCount({required this.value, required this.label});
266
+
267
+ @override
268
+ Widget build(BuildContext context) {
269
+ return Text.rich(
270
+ TextSpan(
271
+ children: [
272
+ TextSpan(
273
+ text: '$value ',
274
+ style: context.textTheme.titleMedium?.copyWith(
275
+ color: context.colors.onSurface,
276
+ fontWeight: FontWeight.w800,
277
+ ),
278
+ ),
279
+ TextSpan(
280
+ text: label.toLowerCase(),
281
+ style: context.textTheme.titleSmall?.copyWith(
282
+ color: context.colors.muted,
283
+ fontWeight: FontWeight.w500,
284
+ ),
285
+ ),
286
+ ],
287
+ ),
288
+ );
289
+ }
290
+ }
291
+
292
+ class _SearchField extends StatelessWidget {
293
+ final TextEditingController controller;
294
+ final ValueChanged<String> onChanged;
295
+ const _SearchField({required this.controller, required this.onChanged});
296
+
297
+ @override
298
+ Widget build(BuildContext context) {
299
+ final u = t.admin_console.users;
300
+ return SizedBox(
301
+ height: 38,
302
+ child: TextField(
303
+ controller: controller,
304
+ onChanged: onChanged,
305
+ style: context.textTheme.bodyMedium,
306
+ decoration: InputDecoration(
307
+ hintText: u.search_hint,
308
+ hintStyle: context.textTheme.bodyMedium?.copyWith(
309
+ color: context.colors.muted,
310
+ ),
311
+ prefixIcon: Icon(
312
+ Icons.search_rounded,
313
+ size: 18,
314
+ color: context.colors.muted,
315
+ ),
316
+ prefixIconConstraints: const BoxConstraints(
317
+ minWidth: 38,
318
+ minHeight: 38,
319
+ ),
320
+ isDense: true,
321
+ contentPadding: const EdgeInsets.symmetric(
322
+ horizontal: 12,
323
+ vertical: 9,
324
+ ),
325
+ filled: true,
326
+ fillColor: context.colors.surface,
327
+ enabledBorder: OutlineInputBorder(
328
+ borderRadius: BorderRadius.circular(KasyRadius.sm),
329
+ borderSide: BorderSide(
330
+ color: context.colors.outline.withValues(alpha: 0.5),
331
+ ),
332
+ ),
333
+ focusedBorder: OutlineInputBorder(
334
+ borderRadius: BorderRadius.circular(KasyRadius.sm),
335
+ borderSide: BorderSide(color: context.colors.primary, width: 1.4),
336
+ ),
337
+ border: OutlineInputBorder(
338
+ borderRadius: BorderRadius.circular(KasyRadius.sm),
339
+ borderSide: BorderSide(
340
+ color: context.colors.outline.withValues(alpha: 0.5),
341
+ ),
342
+ ),
343
+ ),
344
+ ),
345
+ );
346
+ }
347
+ }
348
+
349
+ class _FilterButton extends StatelessWidget {
350
+ final bool subscribersOnly;
351
+ final ValueChanged<bool> onChanged;
352
+ const _FilterButton({required this.subscribersOnly, required this.onChanged});
353
+
354
+ @override
355
+ Widget build(BuildContext context) {
356
+ 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),
439
+ ],
440
+ ),
441
+ );
442
+ }
443
+ }
444
+
445
+ class _IconAction extends StatelessWidget {
446
+ final IconData icon;
447
+ final String tooltip;
448
+ final VoidCallback onTap;
449
+ const _IconAction({
450
+ required this.icon,
451
+ required this.tooltip,
452
+ required this.onTap,
453
+ });
454
+
455
+ @override
456
+ Widget build(BuildContext context) {
457
+ // Icon-only control: a tooltip is the right call here (no visible label).
458
+ return Tooltip(
459
+ 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
+ ),
475
+ ),
476
+ );
477
+ }
478
+ }
479
+
480
+ class _TruncatedNote extends StatelessWidget {
481
+ final int count;
482
+ const _TruncatedNote({required this.count});
483
+
484
+ @override
485
+ Widget build(BuildContext context) {
486
+ return Padding(
487
+ padding: const EdgeInsets.fromLTRB(
488
+ KasySpacing.pageHorizontalGutter,
489
+ KasySpacing.sm,
490
+ KasySpacing.pageHorizontalGutter,
491
+ 0,
492
+ ),
493
+ child: Row(
494
+ children: [
495
+ Icon(Icons.info_outline_rounded, size: 14, color: context.colors.muted),
496
+ const SizedBox(width: 6),
497
+ Expanded(
498
+ child: Text(
499
+ t.admin_console.users.truncated(count: count),
500
+ style: context.textTheme.bodySmall?.copyWith(
501
+ color: context.colors.muted,
502
+ ),
503
+ ),
504
+ ),
505
+ ],
506
+ ),
507
+ );
508
+ }
509
+ }
510
+
511
+ // ─────────────────────────────────────────────────────────────────────────────
512
+ // Sortable header
513
+ // ─────────────────────────────────────────────────────────────────────────────
514
+
515
+ class _TableHeader extends StatelessWidget {
516
+ final _SortCol? sortCol;
517
+ final bool asc;
518
+ final ValueChanged<_SortCol> onSort;
519
+ const _TableHeader({
520
+ required this.sortCol,
521
+ required this.asc,
522
+ required this.onSort,
523
+ });
524
+
525
+ @override
526
+ Widget build(BuildContext context) {
527
+ final u = t.admin_console.users;
528
+ return Container(
529
+ margin: const EdgeInsets.only(top: KasySpacing.md),
530
+ decoration: BoxDecoration(
531
+ border: Border(
532
+ top: BorderSide(color: context.colors.outline.withValues(alpha: 0.3)),
533
+ bottom:
534
+ BorderSide(color: context.colors.outline.withValues(alpha: 0.3)),
535
+ ),
536
+ color: context.isDark
537
+ ? context.colors.surface.withValues(alpha: 0.5)
538
+ : context.colors.surfaceNeutralSoft.withValues(alpha: 0.4),
539
+ ),
540
+ padding: const EdgeInsets.symmetric(
541
+ horizontal: KasySpacing.pageHorizontalGutter,
542
+ vertical: 6,
543
+ ),
544
+ child: Row(
545
+ children: [
546
+ Expanded(
547
+ flex: 5,
548
+ child: _HeaderCell(
549
+ label: u.col_user,
550
+ active: sortCol == _SortCol.user,
551
+ asc: asc,
552
+ onTap: () => onSort(_SortCol.user),
553
+ ),
554
+ ),
555
+ Expanded(
556
+ flex: 2,
557
+ child: _HeaderCell(
558
+ label: u.col_status,
559
+ active: sortCol == _SortCol.status,
560
+ asc: asc,
561
+ onTap: () => onSort(_SortCol.status),
562
+ ),
563
+ ),
564
+ Expanded(
565
+ flex: 2,
566
+ child: _HeaderCell(
567
+ label: u.col_plan,
568
+ active: sortCol == _SortCol.plan,
569
+ asc: asc,
570
+ onTap: () => onSort(_SortCol.plan),
571
+ ),
572
+ ),
573
+ SizedBox(
574
+ width: 88,
575
+ child: _HeaderCell(
576
+ label: u.col_joined,
577
+ active: sortCol == _SortCol.joined,
578
+ asc: asc,
579
+ onTap: () => onSort(_SortCol.joined),
580
+ ),
581
+ ),
582
+ ],
583
+ ),
584
+ );
585
+ }
586
+ }
587
+
588
+ class _HeaderCell extends StatelessWidget {
589
+ final String label;
590
+ final bool active;
591
+ final bool asc;
592
+ final VoidCallback onTap;
593
+ const _HeaderCell({
594
+ required this.label,
595
+ required this.active,
596
+ required this.asc,
597
+ required this.onTap,
598
+ });
599
+
600
+ @override
601
+ Widget build(BuildContext context) {
602
+ final color = active ? context.colors.onSurface : context.colors.muted;
603
+ return InkWell(
604
+ onTap: onTap,
605
+ borderRadius: BorderRadius.circular(KasyRadius.xs),
606
+ child: Padding(
607
+ padding: const EdgeInsets.symmetric(vertical: 6),
608
+ child: Row(
609
+ mainAxisSize: MainAxisSize.min,
610
+ children: [
611
+ Flexible(
612
+ child: Text(
613
+ label.toUpperCase(),
614
+ maxLines: 1,
615
+ overflow: TextOverflow.ellipsis,
616
+ style: context.textTheme.labelSmall?.copyWith(
617
+ color: color,
618
+ letterSpacing: 0.8,
619
+ fontWeight: FontWeight.w700,
620
+ fontSize: 10.5,
621
+ ),
622
+ ),
623
+ ),
624
+ if (active) ...[
625
+ const SizedBox(width: 3),
626
+ Icon(
627
+ asc ? Icons.arrow_upward_rounded : Icons.arrow_downward_rounded,
628
+ size: 12,
629
+ color: context.colors.primary,
630
+ ),
631
+ ],
632
+ ],
633
+ ),
634
+ ),
635
+ );
636
+ }
637
+ }
638
+
639
+ // ─────────────────────────────────────────────────────────────────────────────
640
+ // Table body + pagination
641
+ // ─────────────────────────────────────────────────────────────────────────────
642
+
643
+ class _TableBody extends StatelessWidget {
644
+ final List<AdminUser> users;
645
+ final int rangeFrom;
646
+ final int rangeTo;
647
+ final int total;
648
+ final int page;
649
+ final int pageCount;
650
+ final ValueChanged<int> onPage;
651
+
652
+ const _TableBody({
653
+ required this.users,
654
+ required this.rangeFrom,
655
+ required this.rangeTo,
656
+ required this.total,
657
+ required this.page,
658
+ required this.pageCount,
659
+ required this.onPage,
660
+ });
661
+
662
+ @override
663
+ Widget build(BuildContext context) {
664
+ final u = t.admin_console.users;
665
+ return Column(
666
+ children: [
667
+ Expanded(
668
+ child: ListView.separated(
669
+ padding: const EdgeInsets.only(bottom: KasySpacing.xs),
670
+ itemCount: users.length,
671
+ separatorBuilder: (_, _) => Divider(
672
+ height: 1,
673
+ thickness: 1,
674
+ indent: KasySpacing.pageHorizontalGutter,
675
+ endIndent: KasySpacing.pageHorizontalGutter,
676
+ color: context.colors.outline.withValues(alpha: 0.18),
677
+ ),
678
+ itemBuilder: (_, i) => _UserRow(user: users[i]),
679
+ ),
680
+ ),
681
+ Container(
682
+ decoration: BoxDecoration(
683
+ border: Border(
684
+ top: BorderSide(
685
+ color: context.colors.outline.withValues(alpha: 0.3),
686
+ ),
687
+ ),
688
+ ),
689
+ padding: EdgeInsets.fromLTRB(
690
+ KasySpacing.pageHorizontalGutter,
691
+ KasySpacing.smd,
692
+ KasySpacing.pageHorizontalGutter,
693
+ MediaQuery.paddingOf(context).bottom + KasySpacing.xl,
694
+ ),
695
+ child: LayoutBuilder(
696
+ builder: (context, c) {
697
+ final pager = _Pagination(
698
+ page: page,
699
+ pageCount: pageCount,
700
+ onPage: onPage,
701
+ );
702
+ final label = Text(
703
+ u.results(from: rangeFrom, to: rangeTo, total: total),
704
+ style: context.textTheme.bodySmall?.copyWith(
705
+ color: context.colors.muted,
706
+ ),
707
+ );
708
+ if (c.maxWidth < 460) {
709
+ return Column(
710
+ children: [
711
+ pager,
712
+ const SizedBox(height: KasySpacing.sm),
713
+ label,
714
+ ],
715
+ );
716
+ }
717
+ return Row(
718
+ children: [label, const Spacer(), pager],
719
+ );
720
+ },
721
+ ),
722
+ ),
723
+ ],
724
+ );
725
+ }
726
+ }
727
+
728
+ class _Pagination extends StatelessWidget {
729
+ final int page;
730
+ final int pageCount;
731
+ final ValueChanged<int> onPage;
732
+ const _Pagination({
733
+ required this.page,
734
+ required this.pageCount,
735
+ required this.onPage,
736
+ });
737
+
738
+ @override
739
+ Widget build(BuildContext context) {
740
+ final u = t.admin_console.users;
741
+ return Row(
742
+ mainAxisSize: MainAxisSize.min,
743
+ children: [
744
+ _NavBtn(
745
+ label: u.prev,
746
+ icon: Icons.chevron_left_rounded,
747
+ leading: true,
748
+ enabled: page > 0,
749
+ onTap: () => onPage(page - 1),
750
+ ),
751
+ const SizedBox(width: 6),
752
+ for (final item in _pageWindow(pageCount, page))
753
+ Padding(
754
+ padding: const EdgeInsets.symmetric(horizontal: 2),
755
+ child: item == null
756
+ ? const _Ellipsis()
757
+ : _PageNum(
758
+ number: item + 1,
759
+ selected: item == page,
760
+ onTap: () => onPage(item),
761
+ ),
762
+ ),
763
+ const SizedBox(width: 6),
764
+ _NavBtn(
765
+ label: u.next,
766
+ icon: Icons.chevron_right_rounded,
767
+ leading: false,
768
+ enabled: page < pageCount - 1,
769
+ onTap: () => onPage(page + 1),
770
+ ),
771
+ ],
772
+ );
773
+ }
774
+ }
775
+
776
+ /// Page-number window: first, last, current ±1, with ellipses between gaps.
777
+ List<int?> _pageWindow(int count, int current) {
778
+ if (count <= 7) return [for (var i = 0; i < count; i++) i];
779
+ final out = <int?>[];
780
+ out.add(0);
781
+ if (current > 2) out.add(null);
782
+ final start = max(1, current - 1);
783
+ final end = min(count - 2, current + 1);
784
+ for (var i = start; i <= end; i++) {
785
+ out.add(i);
786
+ }
787
+ if (current < count - 3) out.add(null);
788
+ out.add(count - 1);
789
+ return out;
790
+ }
791
+
792
+ class _PageNum extends StatelessWidget {
793
+ final int number;
794
+ final bool selected;
795
+ final VoidCallback onTap;
796
+ const _PageNum({
797
+ required this.number,
798
+ required this.selected,
799
+ required this.onTap,
800
+ });
801
+
802
+ @override
803
+ Widget build(BuildContext context) {
804
+ return InkWell(
805
+ onTap: selected ? null : onTap,
806
+ borderRadius: BorderRadius.circular(KasyRadius.sm),
807
+ child: Container(
808
+ constraints: const BoxConstraints(minWidth: 32),
809
+ height: 32,
810
+ alignment: Alignment.center,
811
+ padding: const EdgeInsets.symmetric(horizontal: 6),
812
+ decoration: BoxDecoration(
813
+ color: selected ? context.colors.primary : context.colors.surface,
814
+ borderRadius: BorderRadius.circular(KasyRadius.sm),
815
+ border: Border.all(
816
+ color: selected
817
+ ? context.colors.primary
818
+ : context.colors.outline.withValues(alpha: 0.5),
819
+ ),
820
+ ),
821
+ child: Text(
822
+ '$number',
823
+ style: context.textTheme.labelMedium?.copyWith(
824
+ color: selected ? context.colors.onPrimary : context.colors.onSurface,
825
+ fontWeight: FontWeight.w700,
826
+ ),
827
+ ),
828
+ ),
829
+ );
830
+ }
831
+ }
832
+
833
+ class _Ellipsis extends StatelessWidget {
834
+ const _Ellipsis();
835
+
836
+ @override
837
+ Widget build(BuildContext context) {
838
+ return SizedBox(
839
+ width: 24,
840
+ height: 32,
841
+ child: Center(
842
+ child: Text(
843
+ '…',
844
+ style: context.textTheme.labelMedium?.copyWith(
845
+ color: context.colors.muted,
846
+ ),
847
+ ),
848
+ ),
849
+ );
850
+ }
851
+ }
852
+
853
+ class _NavBtn extends StatelessWidget {
854
+ final String label;
855
+ final IconData icon;
856
+ final bool leading;
857
+ final bool enabled;
858
+ final VoidCallback onTap;
859
+ const _NavBtn({
860
+ required this.label,
861
+ required this.icon,
862
+ required this.leading,
863
+ required this.enabled,
864
+ required this.onTap,
865
+ });
866
+
867
+ @override
868
+ Widget build(BuildContext context) {
869
+ final color =
870
+ enabled ? context.colors.onSurface : context.colors.muted.withValues(alpha: 0.5);
871
+ final text = Text(
872
+ label,
873
+ style: context.textTheme.labelMedium?.copyWith(
874
+ color: color,
875
+ fontWeight: FontWeight.w600,
876
+ ),
877
+ );
878
+ final ic = Icon(icon, size: 18, color: color);
879
+ return InkWell(
880
+ onTap: enabled ? onTap : null,
881
+ borderRadius: BorderRadius.circular(KasyRadius.sm),
882
+ child: Container(
883
+ height: 32,
884
+ padding: const EdgeInsets.symmetric(horizontal: 8),
885
+ decoration: BoxDecoration(
886
+ borderRadius: BorderRadius.circular(KasyRadius.sm),
887
+ border: Border.all(
888
+ color: context.colors.outline.withValues(alpha: enabled ? 0.5 : 0.25),
889
+ ),
890
+ ),
891
+ child: Row(
892
+ mainAxisSize: MainAxisSize.min,
893
+ children: leading ? [ic, const SizedBox(width: 2), text] : [text, const SizedBox(width: 2), ic],
894
+ ),
895
+ ),
896
+ );
897
+ }
898
+ }
899
+
900
+ // ─────────────────────────────────────────────────────────────────────────────
901
+ // User row
902
+ // ─────────────────────────────────────────────────────────────────────────────
903
+
904
+ class _UserRow extends StatelessWidget {
905
+ final AdminUser user;
906
+ const _UserRow({required this.user});
907
+
908
+ @override
909
+ Widget build(BuildContext context) {
910
+ final u = t.admin_console.users;
911
+ final hasName = user.name?.isNotEmpty == true;
912
+ final hasEmail = user.email?.isNotEmpty == true;
913
+
914
+ final String primaryText;
915
+ final String? subText;
916
+ final bool isAnonymous;
917
+ if (hasName) {
918
+ primaryText = user.name!;
919
+ subText = hasEmail ? user.email : null;
920
+ isAnonymous = false;
921
+ } else if (hasEmail) {
922
+ primaryText = user.email!;
923
+ subText = null;
924
+ isAnonymous = false;
925
+ } else {
926
+ primaryText = u.anonymous;
927
+ subText = null;
928
+ isAnonymous = true;
929
+ }
930
+
931
+ return Padding(
932
+ padding: const EdgeInsets.symmetric(
933
+ horizontal: KasySpacing.pageHorizontalGutter,
934
+ vertical: 10,
935
+ ),
936
+ child: Row(
937
+ children: [
938
+ // ── User cell
939
+ Expanded(
940
+ flex: 5,
941
+ child: Row(
942
+ children: [
943
+ KasyAvatar(
944
+ diameter: 34,
945
+ initials: hasName ? user.name : (hasEmail ? user.email : null),
946
+ tone: _avatarTone(
947
+ hasName ? user.name : (hasEmail ? user.email : null),
948
+ ),
949
+ ),
950
+ const SizedBox(width: 10),
951
+ Expanded(
952
+ child: Column(
953
+ crossAxisAlignment: CrossAxisAlignment.start,
954
+ mainAxisSize: MainAxisSize.min,
955
+ children: [
956
+ Text(
957
+ primaryText,
958
+ maxLines: 1,
959
+ overflow: TextOverflow.ellipsis,
960
+ style: context.textTheme.bodyMedium?.copyWith(
961
+ color: isAnonymous
962
+ ? context.colors.muted
963
+ : context.colors.onSurface,
964
+ fontWeight:
965
+ isAnonymous ? FontWeight.w400 : FontWeight.w600,
966
+ fontStyle:
967
+ isAnonymous ? FontStyle.italic : FontStyle.normal,
968
+ ),
969
+ ),
970
+ if (subText != null) ...[
971
+ const SizedBox(height: 1),
972
+ Text(
973
+ subText,
974
+ maxLines: 1,
975
+ overflow: TextOverflow.ellipsis,
976
+ style: context.textTheme.bodySmall?.copyWith(
977
+ color: context.colors.muted,
978
+ ),
979
+ ),
980
+ ],
981
+ ],
982
+ ),
983
+ ),
984
+ ],
985
+ ),
986
+ ),
987
+ // ── Status cell
988
+ Expanded(
989
+ flex: 2,
990
+ child: Align(
991
+ alignment: Alignment.centerLeft,
992
+ child: KasyStatusTag(
993
+ label: hasEmail ? u.status_active : u.status_inactive,
994
+ tone: hasEmail
995
+ ? KasyStatusTagTone.success
996
+ : KasyStatusTagTone.neutral,
997
+ ),
998
+ ),
999
+ ),
1000
+ // ── Plan cell
1001
+ Expanded(
1002
+ flex: 2,
1003
+ child: Align(
1004
+ alignment: Alignment.centerLeft,
1005
+ child: KasyStatusTag(
1006
+ label: user.subscriber ? u.plan_subscriber : u.plan_free,
1007
+ tone: user.subscriber
1008
+ ? KasyStatusTagTone.primary
1009
+ : KasyStatusTagTone.neutral,
1010
+ ),
1011
+ ),
1012
+ ),
1013
+ // ── Joined cell
1014
+ SizedBox(
1015
+ width: 88,
1016
+ child: Text(
1017
+ user.createdAt != null ? _formatDate(user.createdAt!) : '—',
1018
+ style: context.textTheme.bodySmall?.copyWith(
1019
+ color: context.colors.muted,
1020
+ ),
1021
+ ),
1022
+ ),
1023
+ ],
1024
+ ),
1025
+ );
1026
+ }
1027
+
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)}';
1033
+ }
1034
+ }
1035
+
1036
+ // ─────────────────────────────────────────────────────────────────────────────
1037
+ // Avatar tone — a stable design-system tone per user (variety, not random).
1038
+ // ─────────────────────────────────────────────────────────────────────────────
1039
+
1040
+ KasyAvatarTone _avatarTone(String? seed) {
1041
+ if (seed == null || seed.trim().isEmpty) return KasyAvatarTone.neutral;
1042
+ const tones = [
1043
+ KasyAvatarTone.blue,
1044
+ KasyAvatarTone.green,
1045
+ KasyAvatarTone.orange,
1046
+ KasyAvatarTone.red,
1047
+ KasyAvatarTone.neutral,
1048
+ ];
1049
+ return tones[seed.trimLeft().codeUnitAt(0) % tones.length];
1050
+ }
1051
+
1052
+ // ─────────────────────────────────────────────────────────────────────────────
1053
+ // Loading / empty / error states (big icon over text)
1054
+ // ─────────────────────────────────────────────────────────────────────────────
1055
+
1056
+ class _CenteredState extends StatelessWidget {
1057
+ final Widget icon;
1058
+ final String title;
1059
+ final String? subtitle;
1060
+ final Widget? action;
1061
+ const _CenteredState({
1062
+ required this.icon,
1063
+ required this.title,
1064
+ this.subtitle,
1065
+ this.action,
1066
+ });
1067
+
1068
+ @override
1069
+ Widget build(BuildContext context) {
1070
+ return Center(
1071
+ child: Padding(
1072
+ padding: const EdgeInsets.all(KasySpacing.lg),
1073
+ child: Column(
1074
+ mainAxisSize: MainAxisSize.min,
1075
+ children: [
1076
+ icon,
1077
+ const SizedBox(height: KasySpacing.md),
1078
+ Text(
1079
+ title,
1080
+ textAlign: TextAlign.center,
1081
+ style: context.textTheme.titleSmall?.copyWith(
1082
+ color: context.colors.onSurface,
1083
+ fontWeight: FontWeight.w700,
1084
+ ),
1085
+ ),
1086
+ if (subtitle != null) ...[
1087
+ const SizedBox(height: KasySpacing.xs),
1088
+ Text(
1089
+ subtitle!,
1090
+ textAlign: TextAlign.center,
1091
+ style: context.textTheme.bodySmall?.copyWith(
1092
+ color: context.colors.muted,
1093
+ height: 1.4,
1094
+ ),
1095
+ ),
1096
+ ],
1097
+ if (action != null) ...[
1098
+ const SizedBox(height: KasySpacing.md),
1099
+ action!,
1100
+ ],
1101
+ ],
1102
+ ),
1103
+ ),
1104
+ );
1105
+ }
1106
+ }
1107
+
1108
+ class _IconBubble extends StatelessWidget {
1109
+ final IconData icon;
1110
+ const _IconBubble(this.icon);
1111
+
1112
+ @override
1113
+ Widget build(BuildContext context) {
1114
+ return Container(
1115
+ width: 64,
1116
+ height: 64,
1117
+ decoration: BoxDecoration(
1118
+ color: context.colors.surfaceNeutralSoft,
1119
+ shape: BoxShape.circle,
1120
+ ),
1121
+ child: Icon(icon, size: 30, color: context.colors.muted),
1122
+ );
1123
+ }
1124
+ }
1125
+
1126
+ class _LoadingState extends StatelessWidget {
1127
+ const _LoadingState();
1128
+
1129
+ @override
1130
+ Widget build(BuildContext context) {
1131
+ return _CenteredState(
1132
+ icon: const SizedBox(
1133
+ width: 40,
1134
+ height: 40,
1135
+ child: CircularProgressIndicator.adaptive(strokeWidth: 2.5),
1136
+ ),
1137
+ title: t.admin_console.users.loading,
1138
+ );
1139
+ }
1140
+ }
1141
+
1142
+ class _EmptyState extends StatelessWidget {
1143
+ final bool searching;
1144
+ const _EmptyState({required this.searching});
1145
+
1146
+ @override
1147
+ Widget build(BuildContext context) {
1148
+ final u = t.admin_console.users;
1149
+ return _CenteredState(
1150
+ icon: _IconBubble(
1151
+ searching ? Icons.search_off_rounded : Icons.people_outline_rounded,
1152
+ ),
1153
+ title: searching ? u.empty_search : u.empty,
1154
+ subtitle: searching ? u.empty_search_hint : null,
1155
+ );
1156
+ }
1157
+ }
1158
+
1159
+ class _ErrorState extends StatelessWidget {
1160
+ final VoidCallback onRetry;
1161
+ const _ErrorState({required this.onRetry});
1162
+
1163
+ @override
1164
+ Widget build(BuildContext context) {
1165
+ final u = t.admin_console.users;
1166
+ return _CenteredState(
1167
+ icon: const _IconBubble(Icons.error_outline_rounded),
1168
+ title: u.title,
1169
+ 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
+ ),
1190
+ ),
1191
+ );
1192
+ }
1193
+ }
1194
+
1195
+ // ─────────────────────────────────────────────────────────────────────────────
1196
+ // Horizontal centering with max-width (keeps Expanded children working)
1197
+ // ─────────────────────────────────────────────────────────────────────────────
1198
+
1199
+ class _HCenter extends StatelessWidget {
1200
+ final Widget child;
1201
+ const _HCenter({required this.child});
1202
+
1203
+ @override
1204
+ Widget build(BuildContext context) {
1205
+ return LayoutBuilder(
1206
+ builder: (_, constraints) {
1207
+ final double hPad = max(0, (constraints.maxWidth - _maxWidth) / 2);
1208
+ return Padding(
1209
+ padding: EdgeInsets.symmetric(horizontal: hPad),
1210
+ child: child,
1211
+ );
1212
+ },
1213
+ );
1214
+ }
1215
+ }