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.
- package/lib/commands/add.js +93 -80
- package/lib/commands/configure.js +100 -32
- package/lib/commands/doctor.js +28 -2
- package/lib/commands/new.js +86 -38
- package/lib/commands/notifications.js +1 -1
- package/lib/commands/remove.js +43 -15
- package/lib/commands/run.js +2 -2
- package/lib/commands/update.js +2 -2
- package/lib/scaffold/CHANGELOG.json +14 -0
- package/lib/scaffold/backends/api/generator.js +14 -14
- package/lib/scaffold/backends/api/patch/README.md +83 -0
- package/lib/scaffold/backends/api/patch/lib/core/data/api/storage_api.dart +1 -1
- package/lib/scaffold/backends/api/patch/lib/core/data/entities/user_entity.dart +5 -0
- package/lib/scaffold/backends/api/patch/lib/{environnements.dart → environments.dart} +3 -11
- package/lib/scaffold/backends/api/patch/lib/features/ai_chat/api/ai_chat_api.dart +108 -0
- package/lib/scaffold/backends/api/patch/lib/features/ai_chat/api/ai_chat_conversation_entity.dart +51 -0
- 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
- package/lib/scaffold/backends/api/patch/lib/features/{llm_chat/providers/llm_chat_notifier.dart → ai_chat/providers/ai_chat_notifier.dart} +71 -38
- package/lib/scaffold/backends/api/patch/lib/features/authentication/api/authentication_api.dart +2 -2
- package/lib/scaffold/backends/api/patch/lib/features/feedbacks/api/feature_request_api.dart +54 -0
- package/lib/scaffold/backends/api/patch/lib/features/onboarding/models/user_info.dart +16 -16
- package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/components/onboarding_att_setup.dart +4 -3
- package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +38 -28
- package/lib/scaffold/backends/api/patch/lib/features/settings/ui/components/admin/admin_users_api.dart +100 -0
- package/lib/scaffold/backends/api/patch/lib/features/{subscription → subscriptions}/api/entities/subscription_entity.dart +13 -0
- package/lib/scaffold/backends/api/patch/lib/features/subscriptions/api/stripe_backend_api.dart +60 -0
- package/lib/scaffold/backends/api/patch/lib/features/{subscription → subscriptions}/api/subscription_api.dart +1 -1
- package/lib/scaffold/backends/api/pubspec.yaml.tpl +4 -5
- package/lib/scaffold/backends/firebase/deploy.js +87 -13
- package/lib/scaffold/backends/firebase/enable-auth-via-cli.js +14 -6
- package/lib/scaffold/backends/firebase/generator.js +5 -5
- package/lib/scaffold/backends/firebase/setup-from-scratch.js +69 -45
- package/lib/scaffold/backends/firebase/tokens.js +4 -4
- package/lib/scaffold/backends/supabase/deploy.js +63 -11
- package/lib/scaffold/backends/supabase/edge-functions/admin-list-users/index.ts +149 -0
- package/lib/scaffold/backends/supabase/edge-functions/{llm-chat → ai-chat}/index.ts +19 -19
- package/lib/scaffold/backends/supabase/edge-functions/revenuecat-webhook/index.ts +2 -0
- package/lib/scaffold/backends/supabase/edge-functions/stripe-create-checkout-session/index.ts +123 -0
- package/lib/scaffold/backends/supabase/edge-functions/stripe-create-portal-session/index.ts +97 -0
- package/lib/scaffold/backends/supabase/edge-functions/stripe-list-prices/index.ts +83 -0
- package/lib/scaffold/backends/supabase/edge-functions/stripe-webhook/index.ts +138 -0
- package/lib/scaffold/backends/supabase/generator.js +17 -17
- package/lib/scaffold/backends/supabase/migrations/20240101000009_ai_messages.sql +50 -0
- package/lib/scaffold/backends/supabase/migrations/20240101000012_stripe_customers.sql +36 -0
- package/lib/scaffold/backends/supabase/migrations/20240101000013_admin_role.sql +62 -0
- package/lib/scaffold/backends/supabase/patch/lib/core/data/entities/user_entity.dart +4 -0
- package/lib/scaffold/backends/supabase/patch/lib/{environnements.dart → environments.dart} +3 -13
- package/lib/scaffold/backends/supabase/patch/lib/features/ai_chat/api/ai_chat_api.dart +95 -0
- package/lib/scaffold/backends/supabase/patch/lib/features/ai_chat/api/ai_chat_conversation_entity.dart +52 -0
- 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
- package/lib/scaffold/backends/supabase/patch/lib/features/{llm_chat/providers/llm_chat_notifier.dart → ai_chat/providers/ai_chat_notifier.dart} +63 -35
- package/lib/scaffold/backends/supabase/patch/lib/features/authentication/api/authentication_api.dart +1 -1
- package/lib/scaffold/backends/supabase/patch/lib/features/feedbacks/api/feature_request_api.dart +46 -0
- package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/models/user_info.dart +16 -16
- package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/components/onboarding_att_setup.dart +4 -3
- package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +38 -28
- package/lib/scaffold/backends/supabase/patch/lib/features/settings/ui/components/admin/admin_users_api.dart +93 -0
- package/lib/scaffold/backends/supabase/patch/lib/features/{subscription → subscriptions}/api/entities/subscription_entity.dart +13 -0
- package/lib/scaffold/backends/supabase/patch/lib/features/subscriptions/api/stripe_backend_api.dart +52 -0
- package/lib/scaffold/backends/supabase/patch/lib/features/{subscription → subscriptions}/api/subscription_api.dart +1 -1
- package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +4 -5
- package/lib/scaffold/backends/supabase/tokens.js +3 -3
- package/lib/scaffold/catalog.js +9 -11
- package/lib/scaffold/generate.js +45 -31
- package/lib/scaffold/shared/generator-utils.js +188 -81
- package/lib/scaffold/shared/sort-imports.js +191 -0
- package/lib/scaffold/shared/template-strings.js +3 -3
- package/lib/utils/checks.js +2 -2
- package/lib/utils/i18n/messages-en.js +50 -35
- package/lib/utils/i18n/messages-es.js +50 -35
- package/lib/utils/i18n/messages-pt.js +52 -37
- package/lib/utils/updates.js +15 -15
- package/package.json +1 -1
- package/templates/firebase/.env.example +2 -2
- package/templates/firebase/android/app/src/main/res/drawable/background.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-night/background.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-night-v21/background.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-v21/background.png +0 -0
- package/templates/firebase/android/app/src/main/res/values-night-v31/styles.xml +1 -1
- package/templates/firebase/android/app/src/main/res/values-v31/styles.xml +1 -1
- package/templates/firebase/assets/images/logo_wordmark_dark.png +0 -0
- package/templates/firebase/assets/images/logo_wordmark_light.png +0 -0
- package/templates/firebase/docs/revenuecat-setup.es.md +1 -1
- package/templates/firebase/docs/revenuecat-setup.pt.md +1 -1
- package/templates/firebase/firestore.rules +24 -5
- package/templates/firebase/functions/package-lock.json +22 -1
- package/templates/firebase/functions/package.json +2 -1
- package/templates/firebase/functions/src/admin/functions.ts +113 -0
- package/templates/firebase/functions/src/{llm_chat → ai_chat}/index.ts +16 -16
- package/templates/firebase/functions/src/index.ts +8 -2
- package/templates/firebase/functions/src/notifications/device_triggers.ts +2 -2
- package/templates/firebase/functions/src/notifications/triggers.ts +3 -3
- package/templates/firebase/functions/src/subscriptions/models/subscription_status.ts +2 -0
- package/templates/firebase/functions/src/subscriptions/stripe_functions.ts +222 -0
- package/templates/firebase/functions/src/subscriptions/subscriptions_functions.ts +2 -2
- package/templates/firebase/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png +0 -0
- package/templates/firebase/lib/components/components.dart +4 -1
- package/templates/firebase/lib/components/kasy_app_bar.dart +22 -7
- package/templates/firebase/lib/components/kasy_avatar.dart +7 -6
- package/templates/firebase/lib/components/kasy_button.dart +23 -99
- package/templates/firebase/lib/components/kasy_dialog.dart +11 -11
- package/templates/firebase/lib/components/kasy_image_viewer.dart +84 -0
- package/templates/firebase/lib/components/{kasy_sidebar_pro.dart → kasy_sidebar.dart} +692 -425
- package/templates/firebase/lib/components/kasy_status_tag.dart +91 -0
- package/templates/firebase/lib/components/kasy_text_area.dart +1 -3
- package/templates/firebase/lib/components/kasy_text_field.dart +5 -6
- package/templates/firebase/lib/components/kasy_text_field_otp.dart +1 -3
- package/templates/firebase/lib/components/kasy_toast.dart +2 -2
- package/templates/firebase/lib/components/kasy_web_header.dart +209 -0
- package/templates/firebase/lib/core/ads/ads_provider.dart +1 -1
- package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +57 -4
- package/templates/firebase/lib/core/bottom_menu/bottom_router.dart +19 -91
- package/templates/firebase/lib/core/bottom_menu/kasy_bottom_bar_factory.dart +196 -96
- package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +41 -0
- package/templates/firebase/lib/core/config/app_env.dart +5 -11
- package/templates/firebase/lib/core/config/features.dart +5 -4
- package/templates/firebase/lib/core/data/api/analytics_api.dart +1 -1
- package/templates/firebase/lib/core/data/api/http_client.dart +1 -1
- package/templates/firebase/lib/core/data/entities/user_entity.dart +3 -0
- package/templates/firebase/lib/core/data/models/entitlement.dart +35 -0
- package/templates/firebase/lib/core/data/models/subscription.dart +13 -186
- package/templates/firebase/lib/core/data/models/user.dart +11 -0
- package/templates/firebase/lib/core/data/repositories/user_repository.dart +1 -1
- package/templates/firebase/lib/core/home_widgets/home_widget_background_task.dart +1 -1
- package/templates/firebase/lib/core/home_widgets/home_widget_mywidget_service.dart +1 -1
- package/templates/firebase/lib/core/icons/kasy_icons.dart +31 -1
- package/templates/firebase/lib/core/rating/api/rating_api.dart +2 -2
- package/templates/firebase/lib/core/states/logout_action.dart +25 -0
- package/templates/firebase/lib/core/states/user_state_notifier.dart +3 -3
- package/templates/firebase/lib/core/theme/colors.dart +488 -188
- package/templates/firebase/lib/core/theme/radius.dart +22 -11
- package/templates/firebase/lib/core/theme/shadows.dart +66 -0
- package/templates/firebase/lib/core/theme/texts.dart +75 -41
- package/templates/firebase/lib/core/theme/universal_theme.dart +9 -4
- package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +5 -4
- package/templates/firebase/lib/core/web_viewport_scale.dart +52 -0
- package/templates/firebase/lib/core/widgets/kasy_brand_badge.dart +118 -0
- package/templates/firebase/lib/core/widgets/kasy_brand_logo.dart +40 -0
- package/templates/firebase/lib/core/widgets/kasy_focus_ring.dart +125 -0
- package/templates/firebase/lib/core/widgets/kasy_hover.dart +33 -13
- package/templates/firebase/lib/core/widgets/kasy_pressable_depth.dart +18 -13
- package/templates/firebase/lib/{environnements.dart → environments.dart} +3 -14
- package/templates/firebase/lib/features/ai_chat/ai_chat_page.dart +159 -0
- package/templates/firebase/lib/features/ai_chat/api/ai_chat_api.dart +107 -0
- package/templates/firebase/lib/features/ai_chat/api/ai_chat_conversation_entity.dart +64 -0
- package/templates/firebase/lib/features/{llm_chat/api/llm_chat_message_entity.dart → ai_chat/api/ai_chat_message_entity.dart} +6 -6
- package/templates/firebase/lib/features/{llm_chat/providers/llm_chat_notifier.dart → ai_chat/providers/ai_chat_notifier.dart} +73 -38
- package/templates/firebase/lib/features/ai_chat/providers/ai_conversations_notifier.dart +103 -0
- package/templates/firebase/lib/features/{llm_chat/ui/widgets/llm_chat_avatars.dart → ai_chat/ui/widgets/ai_chat_avatars.dart} +13 -13
- package/templates/firebase/lib/features/{llm_chat/ui/widgets/llm_chat_composer.dart → ai_chat/ui/widgets/ai_chat_composer.dart} +10 -10
- package/templates/firebase/lib/features/{llm_chat/llm_chat_page.dart → ai_chat/ui/widgets/ai_chat_conversation_view.dart} +80 -67
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_list.dart +285 -0
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +163 -0
- package/templates/firebase/lib/features/authentication/api/authentication_api.dart +52 -13
- package/templates/firebase/lib/features/authentication/api/popup_dismiss_watcher.dart +12 -0
- package/templates/firebase/lib/features/authentication/api/popup_dismiss_watcher_web.dart +35 -0
- package/templates/firebase/lib/features/authentication/ui/recover_password_page.dart +108 -68
- package/templates/firebase/lib/features/authentication/ui/signin_page.dart +38 -51
- package/templates/firebase/lib/features/authentication/ui/signup_page.dart +38 -51
- package/templates/firebase/lib/features/authentication/ui/widgets/auth_card_scaffold.dart +118 -0
- package/templates/firebase/lib/features/authentication/ui/widgets/recover_password_result.dart +61 -44
- package/templates/firebase/lib/features/feedbacks/api/feature_request_api.dart +32 -0
- package/templates/firebase/lib/features/feedbacks/models/feedback_state.dart +5 -5
- package/templates/firebase/lib/features/feedbacks/providers/feedback_page_notifier.dart +2 -2
- package/templates/firebase/lib/features/home/design_system_page.dart +808 -170
- package/templates/firebase/lib/features/home/home_components_page.dart +6 -3
- package/templates/firebase/lib/features/home/home_components_preview_page.dart +6 -6
- package/templates/firebase/lib/features/home/home_components_preview_registry.dart +325 -186
- package/templates/firebase/lib/features/home/home_feed.dart +289 -0
- package/templates/firebase/lib/features/home/home_image_grid.dart +355 -0
- package/templates/firebase/lib/features/home/home_page.dart +11 -250
- package/templates/firebase/lib/features/{local_reminder → local_reminders}/providers/reminder_notifier.dart +1 -1
- package/templates/firebase/lib/features/{local_reminder → local_reminders}/ui/reminder_page.dart +2 -2
- package/templates/firebase/lib/features/notifications/shared/att_permission.dart +1 -1
- package/templates/firebase/lib/features/notifications/ui/request_notification_permission.dart +25 -61
- package/templates/firebase/lib/features/notifications/ui/widgets/permission_request_view.dart +117 -0
- package/templates/firebase/lib/features/onboarding/models/user_info.dart +16 -16
- package/templates/firebase/lib/features/onboarding/ui/components/onboarding_att_setup.dart +4 -3
- package/templates/firebase/lib/features/onboarding/ui/components/onboarding_features.dart +7 -9
- package/templates/firebase/lib/features/onboarding/ui/components/onboarding_loader.dart +71 -48
- package/templates/firebase/lib/features/onboarding/ui/components/onboarding_notifications_setup.dart +3 -2
- package/templates/firebase/lib/features/onboarding/ui/components/onboarding_questions.dart +5 -5
- package/templates/firebase/lib/features/onboarding/ui/onboarding_page.dart +4 -4
- package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_background.dart +4 -2
- package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_feature.dart +39 -121
- package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_illustration_scaffold.dart +105 -70
- package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_module_mockups.dart +639 -0
- package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_progress.dart +62 -50
- package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +38 -28
- package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_step_header.dart +75 -0
- package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_sticky_footer.dart +16 -5
- package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +26 -22
- package/templates/firebase/lib/features/settings/settings_page.dart +601 -90
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +1193 -0
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_paywalls.dart +1 -1
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_routes.dart +2 -3
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_api.dart +86 -0
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_tab.dart +1215 -0
- package/templates/firebase/lib/features/settings/ui/components/admin/send_push_notification_page.dart +236 -0
- package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +3 -3
- package/templates/firebase/lib/features/settings/ui/widgets/kasy_user_avatar.dart +77 -0
- package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +1 -0
- package/templates/firebase/lib/features/{subscription → subscriptions}/api/entities/subscription_entity.dart +17 -0
- package/templates/firebase/lib/features/{subscription → subscriptions}/api/inapp_subscription_api.dart +67 -46
- package/templates/firebase/lib/features/subscriptions/api/revenuecat_product.dart +189 -0
- package/templates/firebase/lib/features/subscriptions/api/stripe_backend_api.dart +55 -0
- package/templates/firebase/lib/features/subscriptions/api/stripe_payment_api.dart +82 -0
- package/templates/firebase/lib/features/subscriptions/api/stripe_product.dart +178 -0
- package/templates/firebase/lib/features/{subscription → subscriptions}/api/subscription_api.dart +1 -1
- package/templates/firebase/lib/features/subscriptions/api/subscription_payment_api.dart +53 -0
- package/templates/firebase/lib/features/subscriptions/api/subscription_payment_api_provider.dart +21 -0
- package/templates/firebase/lib/features/{subscription → subscriptions}/providers/premium_page_provider.dart +9 -6
- package/templates/firebase/lib/features/{subscription → subscriptions}/repositories/subscription_repository.dart +26 -30
- package/{lib/scaffold/backends/supabase/patch/lib/features/subscription → templates/firebase/lib/features/subscriptions}/shared/maybeshow_premium.dart +0 -2
- package/templates/firebase/lib/features/subscriptions/shared/subscription_management.dart +45 -0
- package/templates/firebase/lib/features/{subscription → subscriptions}/ui/component/active_premium_content.dart +28 -4
- package/templates/firebase/lib/features/{subscription → subscriptions}/ui/component/paywall_minimal.dart +7 -7
- package/templates/firebase/lib/features/{subscription → subscriptions}/ui/component/paywall_row.dart +8 -8
- package/templates/firebase/lib/features/{subscription → subscriptions}/ui/component/paywall_with_switch.dart +7 -7
- package/templates/firebase/lib/features/{subscription → subscriptions}/ui/component/premium_content.dart +7 -7
- package/templates/firebase/lib/features/{subscription → subscriptions}/ui/component/premium_page_factory.dart +5 -5
- package/templates/firebase/lib/features/{subscription → subscriptions}/ui/premium_page.dart +5 -5
- package/templates/firebase/lib/features/{subscription → subscriptions}/ui/widgets/comparison_table.dart +1 -1
- package/templates/firebase/lib/features/{subscription → subscriptions}/ui/widgets/paywall_empty_state.dart +4 -4
- package/templates/firebase/lib/features/{subscription → subscriptions}/ui/widgets/premium_bottom_menu.dart +3 -4
- package/templates/firebase/lib/features/{subscription → subscriptions}/ui/widgets/selectable_col.dart +1 -1
- package/templates/firebase/lib/i18n/en.i18n.json +171 -46
- package/templates/firebase/lib/i18n/es.i18n.json +175 -50
- package/templates/firebase/lib/i18n/pt.i18n.json +166 -41
- package/templates/firebase/lib/main.dart +6 -3
- package/templates/firebase/lib/router.dart +15 -23
- package/templates/firebase/pubspec.yaml +4 -5
- package/templates/firebase/test/core/data/repositories/user_repository_test.dart +3 -3
- package/templates/firebase/test/core/states/user_state_notifier_test.dart +4 -4
- package/templates/firebase/test/core/widgets/focus_ring_shape_test.dart +55 -0
- package/templates/firebase/test/features/{subscription → subscriptions}/api/fake_inapp_subscription_api.dart +11 -4
- package/templates/firebase/test/features/{subscription → subscriptions}/api/fake_revenuecat_product.dart +1 -0
- package/templates/firebase/test/features/{subscription → subscriptions}/api/fake_subscription_api.dart +2 -2
- package/templates/firebase/test/features/{subscription → subscriptions}/subscription_page_test.dart +4 -4
- package/templates/firebase/test/test_utils.dart +6 -6
- package/templates/firebase/web/index.html +5 -2
- package/lib/scaffold/backends/api/patch/lib/features/llm_chat/api/llm_chat_api.dart +0 -58
- package/lib/scaffold/backends/api/patch/lib/features/subscription/shared/maybeshow_premium.dart +0 -86
- package/lib/scaffold/backends/supabase/migrations/20240101000009_llm_messages.sql +0 -22
- package/lib/scaffold/backends/supabase/patch/lib/features/llm_chat/api/llm_chat_api.dart +0 -47
- package/templates/firebase/assets/images/onboarding/authentication-login-template.jpg +0 -0
- package/templates/firebase/assets/images/onboarding/img2.jpg +0 -0
- package/templates/firebase/assets/images/onboarding/img3.jpg +0 -0
- package/templates/firebase/assets/images/onboarding/notifications.png +0 -0
- package/templates/firebase/assets/images/onboarding/purchase.png +0 -0
- package/templates/firebase/lib/core/sidebar/kasy_sidebar.dart +0 -2021
- package/templates/firebase/lib/features/authentication/ui/widgets/auth_brand.dart +0 -35
- package/templates/firebase/lib/features/home/home_features_page.dart +0 -207
- package/templates/firebase/lib/features/llm_chat/api/llm_chat_api.dart +0 -50
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_bottom_sheet.dart +0 -316
- package/templates/firebase/lib/features/subscription/shared/maybeshow_premium.dart +0 -85
- /package/templates/firebase/lib/features/{local_reminder → local_reminders}/repositories/reminder_preferences.dart +0 -0
- /package/templates/firebase/lib/features/{subscription → subscriptions}/providers/models/premium_state.dart +0 -0
- /package/templates/firebase/lib/features/{subscription → subscriptions}/ui/widgets/feature_line.dart +0 -0
- /package/templates/firebase/lib/features/{subscription → subscriptions}/ui/widgets/premium_background.dart +0 -0
- /package/templates/firebase/lib/features/{subscription → subscriptions}/ui/widgets/premium_background_gradient.dart +0 -0
- /package/templates/firebase/lib/features/{subscription → subscriptions}/ui/widgets/premium_banner.dart +0 -0
- /package/templates/firebase/lib/features/{subscription → subscriptions}/ui/widgets/premium_card.dart +0 -0
- /package/templates/firebase/lib/features/{subscription → subscriptions}/ui/widgets/premium_close_button.dart +0 -0
- /package/templates/firebase/lib/features/{subscription → subscriptions}/ui/widgets/premium_feature.dart +0 -0
- /package/templates/firebase/lib/features/{subscription → subscriptions}/ui/widgets/selectable_row.dart +0 -0
- /package/templates/firebase/lib/features/{subscription → subscriptions}/ui/widgets/trial_switcher.dart +0 -0
- /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
|
+
}
|