From 519752b4783e88eeae7051aae981b70e42cf6a24 Mon Sep 17 00:00:00 2001 From: Amirmahdi Nourkazemi Date: Wed, 17 Jun 2026 12:57:28 +0330 Subject: [PATCH] feat: refactor code --- lib/app.dart | 140 +++-- lib/core/error/custom_error.dart | 28 + lib/core/locator/locator.dart | 103 ++++ lib/core/network/api_client.dart | 27 - lib/core/network/api_provider_imp.dart | 41 ++ lib/core/resources/data_state.dart | 15 + lib/core/usecase/use_case.dart | 37 ++ .../data_source/local/auth_local_data.dart | 16 + .../data_source/remote/auth_api_provider.dart | 20 + lib/feature/auth/data/model/user_model.dart | 18 + .../data/repository/auth_repository_impl.dart | 65 +++ .../auth/domain/entities/user_entity.dart | 16 + .../domain/repository/auth_repository.dart | 17 + .../domain/use_cases/check_otp_usecase.dart | 13 + .../auth/domain/use_cases/login_usecase.dart | 11 + .../auth/domain/use_cases/logout_usecase.dart | 11 + .../use_cases/update_profile_usecase.dart | 14 + .../auth/presentation/bloc/auth_bloc.dart | 63 +++ .../auth/presentation/bloc/auth_event.dart | 19 + .../auth/presentation/bloc/auth_state.dart | 37 ++ .../auth/presentation/bloc/login_status.dart | 12 + .../auth/presentation/bloc/otp_status.dart | 15 + .../presentation/bloc/profile_status.dart | 12 + .../presentation/screen}/mobile_screen.dart | 32 +- .../auth/presentation/screen}/otp_screen.dart | 37 +- .../screen/profile_setup_screen.dart | 146 +++++ .../data_source/remote/game_api_provider.dart | 12 + .../data_source/remote/game_ws_provider.dart | 45 ++ .../data/repository/game_repository_impl.dart | 53 ++ .../game/domain/entities/game_entities.dart} | 21 +- .../game/domain/entities/table_entities.dart | 42 ++ .../domain/repository/game_repository.dart | 17 + .../use_cases/get_tables_info_usecase.dart | 14 + .../domain/use_cases/get_tiers_usecase.dart | 14 + .../game/presentation/bloc/game_bloc.dart | 96 ++++ .../game/presentation/bloc/game_event.dart | 39 ++ .../game/presentation/bloc/game_state.dart | 98 ++++ .../presentation/bloc/private_info_bloc.dart | 41 ++ .../game/presentation/bloc/tier_bloc.dart | 41 ++ .../presentation/screen}/game_screen.dart | 106 ++-- .../screen/private_entry_screen.dart | 167 ++++++ .../screen}/private_table_screen.dart | 148 ++--- .../screen}/tier_list_screen.dart | 65 +-- .../widgets}/flame/card_codes.dart | 0 .../widgets}/flame/card_component.dart | 0 .../widgets}/flame/hokm_game.dart | 6 +- .../widgets}/flame/table_pieces.dart | 0 .../remote/profile_api_provider.dart | 15 + .../profile/data/model/profile_model.dart | 35 ++ .../repository/profile_repository_impl.dart | 42 ++ .../domain/entities/profile_entity.dart | 44 ++ .../domain/repository/profile_repository.dart | 8 + .../domain/use_cases/get_profile_usecase.dart | 13 + .../use_cases/save_profile_usecase.dart | 12 + .../presentation/bloc/profile_bloc.dart | 41 ++ .../presentation/bloc/profile_event.dart | 9 + .../presentation/bloc/profile_state.dart | 22 + .../presentation/bloc/profile_status.dart | 31 + .../presentation/screen/profile_screen.dart | 400 +++++++++++++ .../data_source/remote/shop_api_provider.dart | 32 ++ lib/feature/shop/data/model/shop_models.dart | 59 ++ .../data/repository/shop_repository_impl.dart | 65 +++ .../shop/domain/entities/shop_entities.dart | 89 +++ .../domain/repository/shop_repository.dart | 11 + .../domain/use_cases/ad_reward_usecase.dart | 11 + .../domain/use_cases/buy_card_usecase.dart | 11 + .../domain/use_cases/get_shop_usecase.dart | 12 + .../domain/use_cases/purchase_usecase.dart | 12 + .../domain/use_cases/select_card_usecase.dart | 12 + .../shop/presentation/bloc/shop_bloc.dart | 84 +++ .../shop/presentation/bloc/shop_event.dart | 21 + .../shop/presentation/bloc/shop_state.dart | 22 + .../shop/presentation/bloc/shop_status.dart | 34 ++ .../presentation/screen}/shop_screen.dart | 297 ++++------ .../shop/presentation/screen/vip_screen.dart | 205 +++++++ .../remote/wallet_api_provider.dart | 13 + .../wallet/data/model/wallet_model.dart | 39 ++ .../repository/wallet_repository_impl.dart | 38 ++ .../wallet/domain/entities/wallet_entity.dart | 28 + .../domain/repository/wallet_repository.dart | 9 + .../domain/use_cases/claim_daily_usecase.dart | 11 + .../domain/use_cases/get_wallet_usecase.dart | 13 + .../presentation/bloc/daily_status.dart | 15 + .../wallet/presentation/bloc/wallet_bloc.dart | 43 ++ .../presentation/bloc/wallet_event.dart | 5 + .../presentation/bloc/wallet_state.dart | 23 + .../presentation/bloc/wallet_status.dart | 17 + .../presentation/screen}/lobby_screen.dart | 87 +-- lib/features/auth/auth_cubit.dart | 95 ---- lib/features/auth/auth_repository.dart | 45 -- lib/features/auth/profile_setup_screen.dart | 144 ----- lib/features/game/game_cubit.dart | 188 ------ lib/features/game/game_repository.dart | 17 - lib/features/game/tier.dart | 19 - lib/features/lobby/wallet.dart | 42 -- lib/features/lobby/wallet_cubit.dart | 65 --- .../private/private_entry_screen.dart | 162 ------ lib/features/profile/profile_screen.dart | 535 ------------------ lib/features/shop/shop_cubit.dart | 79 --- lib/features/shop/shop_models.dart | 110 ---- lib/features/shop/shop_repository.dart | 40 -- lib/features/shop/vip_screen.dart | 201 ------- lib/main.dart | 20 +- pubspec.lock | 8 + pubspec.yaml | 1 + test/widget_test.dart | 17 +- 106 files changed, 3459 insertions(+), 2309 deletions(-) create mode 100644 lib/core/error/custom_error.dart create mode 100644 lib/core/locator/locator.dart delete mode 100644 lib/core/network/api_client.dart create mode 100644 lib/core/network/api_provider_imp.dart create mode 100644 lib/core/resources/data_state.dart create mode 100644 lib/core/usecase/use_case.dart create mode 100644 lib/feature/auth/data/data_source/local/auth_local_data.dart create mode 100644 lib/feature/auth/data/data_source/remote/auth_api_provider.dart create mode 100644 lib/feature/auth/data/model/user_model.dart create mode 100644 lib/feature/auth/data/repository/auth_repository_impl.dart create mode 100644 lib/feature/auth/domain/entities/user_entity.dart create mode 100644 lib/feature/auth/domain/repository/auth_repository.dart create mode 100644 lib/feature/auth/domain/use_cases/check_otp_usecase.dart create mode 100644 lib/feature/auth/domain/use_cases/login_usecase.dart create mode 100644 lib/feature/auth/domain/use_cases/logout_usecase.dart create mode 100644 lib/feature/auth/domain/use_cases/update_profile_usecase.dart create mode 100644 lib/feature/auth/presentation/bloc/auth_bloc.dart create mode 100644 lib/feature/auth/presentation/bloc/auth_event.dart create mode 100644 lib/feature/auth/presentation/bloc/auth_state.dart create mode 100644 lib/feature/auth/presentation/bloc/login_status.dart create mode 100644 lib/feature/auth/presentation/bloc/otp_status.dart create mode 100644 lib/feature/auth/presentation/bloc/profile_status.dart rename lib/{features/auth => feature/auth/presentation/screen}/mobile_screen.dart (74%) rename lib/{features/auth => feature/auth/presentation/screen}/otp_screen.dart (74%) create mode 100644 lib/feature/auth/presentation/screen/profile_setup_screen.dart create mode 100644 lib/feature/game/data/data_source/remote/game_api_provider.dart create mode 100644 lib/feature/game/data/data_source/remote/game_ws_provider.dart create mode 100644 lib/feature/game/data/repository/game_repository_impl.dart rename lib/{features/game/game_models.dart => feature/game/domain/entities/game_entities.dart} (85%) create mode 100644 lib/feature/game/domain/entities/table_entities.dart create mode 100644 lib/feature/game/domain/repository/game_repository.dart create mode 100644 lib/feature/game/domain/use_cases/get_tables_info_usecase.dart create mode 100644 lib/feature/game/domain/use_cases/get_tiers_usecase.dart create mode 100644 lib/feature/game/presentation/bloc/game_bloc.dart create mode 100644 lib/feature/game/presentation/bloc/game_event.dart create mode 100644 lib/feature/game/presentation/bloc/game_state.dart create mode 100644 lib/feature/game/presentation/bloc/private_info_bloc.dart create mode 100644 lib/feature/game/presentation/bloc/tier_bloc.dart rename lib/{features/game => feature/game/presentation/screen}/game_screen.dart (78%) create mode 100644 lib/feature/game/presentation/screen/private_entry_screen.dart rename lib/{features/private => feature/game/presentation/screen}/private_table_screen.dart (60%) rename lib/{features/game => feature/game/presentation/screen}/tier_list_screen.dart (76%) rename lib/{features/game => feature/game/presentation/widgets}/flame/card_codes.dart (100%) rename lib/{features/game => feature/game/presentation/widgets}/flame/card_component.dart (100%) rename lib/{features/game => feature/game/presentation/widgets}/flame/hokm_game.dart (99%) rename lib/{features/game => feature/game/presentation/widgets}/flame/table_pieces.dart (100%) create mode 100644 lib/feature/profile/data/data_source/remote/profile_api_provider.dart create mode 100644 lib/feature/profile/data/model/profile_model.dart create mode 100644 lib/feature/profile/data/repository/profile_repository_impl.dart create mode 100644 lib/feature/profile/domain/entities/profile_entity.dart create mode 100644 lib/feature/profile/domain/repository/profile_repository.dart create mode 100644 lib/feature/profile/domain/use_cases/get_profile_usecase.dart create mode 100644 lib/feature/profile/domain/use_cases/save_profile_usecase.dart create mode 100644 lib/feature/profile/presentation/bloc/profile_bloc.dart create mode 100644 lib/feature/profile/presentation/bloc/profile_event.dart create mode 100644 lib/feature/profile/presentation/bloc/profile_state.dart create mode 100644 lib/feature/profile/presentation/bloc/profile_status.dart create mode 100644 lib/feature/profile/presentation/screen/profile_screen.dart create mode 100644 lib/feature/shop/data/data_source/remote/shop_api_provider.dart create mode 100644 lib/feature/shop/data/model/shop_models.dart create mode 100644 lib/feature/shop/data/repository/shop_repository_impl.dart create mode 100644 lib/feature/shop/domain/entities/shop_entities.dart create mode 100644 lib/feature/shop/domain/repository/shop_repository.dart create mode 100644 lib/feature/shop/domain/use_cases/ad_reward_usecase.dart create mode 100644 lib/feature/shop/domain/use_cases/buy_card_usecase.dart create mode 100644 lib/feature/shop/domain/use_cases/get_shop_usecase.dart create mode 100644 lib/feature/shop/domain/use_cases/purchase_usecase.dart create mode 100644 lib/feature/shop/domain/use_cases/select_card_usecase.dart create mode 100644 lib/feature/shop/presentation/bloc/shop_bloc.dart create mode 100644 lib/feature/shop/presentation/bloc/shop_event.dart create mode 100644 lib/feature/shop/presentation/bloc/shop_state.dart create mode 100644 lib/feature/shop/presentation/bloc/shop_status.dart rename lib/{features/shop => feature/shop/presentation/screen}/shop_screen.dart (58%) create mode 100644 lib/feature/shop/presentation/screen/vip_screen.dart create mode 100644 lib/feature/wallet/data/data_source/remote/wallet_api_provider.dart create mode 100644 lib/feature/wallet/data/model/wallet_model.dart create mode 100644 lib/feature/wallet/data/repository/wallet_repository_impl.dart create mode 100644 lib/feature/wallet/domain/entities/wallet_entity.dart create mode 100644 lib/feature/wallet/domain/repository/wallet_repository.dart create mode 100644 lib/feature/wallet/domain/use_cases/claim_daily_usecase.dart create mode 100644 lib/feature/wallet/domain/use_cases/get_wallet_usecase.dart create mode 100644 lib/feature/wallet/presentation/bloc/daily_status.dart create mode 100644 lib/feature/wallet/presentation/bloc/wallet_bloc.dart create mode 100644 lib/feature/wallet/presentation/bloc/wallet_event.dart create mode 100644 lib/feature/wallet/presentation/bloc/wallet_state.dart create mode 100644 lib/feature/wallet/presentation/bloc/wallet_status.dart rename lib/{features/lobby => feature/wallet/presentation/screen}/lobby_screen.dart (75%) delete mode 100644 lib/features/auth/auth_cubit.dart delete mode 100644 lib/features/auth/auth_repository.dart delete mode 100644 lib/features/auth/profile_setup_screen.dart delete mode 100644 lib/features/game/game_cubit.dart delete mode 100644 lib/features/game/game_repository.dart delete mode 100644 lib/features/game/tier.dart delete mode 100644 lib/features/lobby/wallet.dart delete mode 100644 lib/features/lobby/wallet_cubit.dart delete mode 100644 lib/features/private/private_entry_screen.dart delete mode 100644 lib/features/profile/profile_screen.dart delete mode 100644 lib/features/shop/shop_cubit.dart delete mode 100644 lib/features/shop/shop_models.dart delete mode 100644 lib/features/shop/shop_repository.dart delete mode 100644 lib/features/shop/vip_screen.dart diff --git a/lib/app.dart b/lib/app.dart index b686684..4ad21c7 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -2,42 +2,33 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; -import 'core/network/api_client.dart'; -import 'core/network/ws_client.dart'; -import 'core/storage/token_storage.dart'; +import 'core/locator/locator.dart'; import 'core/theme/app_theme.dart'; -import 'features/auth/auth_cubit.dart'; -import 'features/auth/auth_repository.dart'; -import 'features/auth/mobile_screen.dart'; -import 'features/auth/otp_screen.dart'; -import 'features/auth/profile_setup_screen.dart'; -import 'features/game/game_cubit.dart'; -import 'features/game/game_repository.dart'; -import 'features/game/game_screen.dart'; -import 'features/game/tier_list_screen.dart'; -import 'features/lobby/lobby_screen.dart'; -import 'features/lobby/wallet_cubit.dart'; -import 'features/private/private_entry_screen.dart'; -import 'features/private/private_table_screen.dart'; -import 'features/profile/profile_screen.dart'; -import 'features/shop/shop_cubit.dart'; -import 'features/shop/shop_repository.dart'; -import 'features/shop/shop_screen.dart'; -import 'features/shop/vip_screen.dart'; +import 'feature/auth/presentation/bloc/auth_bloc.dart'; +import 'feature/auth/presentation/screen/mobile_screen.dart'; +import 'feature/auth/presentation/screen/otp_screen.dart'; +import 'feature/auth/presentation/screen/profile_setup_screen.dart'; +import 'feature/game/presentation/bloc/game_bloc.dart'; +import 'feature/game/presentation/bloc/game_event.dart'; +import 'feature/game/presentation/bloc/private_info_bloc.dart'; +import 'feature/game/presentation/bloc/tier_bloc.dart'; +import 'feature/game/presentation/screen/game_screen.dart'; +import 'feature/game/presentation/screen/private_entry_screen.dart'; +import 'feature/game/presentation/screen/private_table_screen.dart'; +import 'feature/game/presentation/screen/tier_list_screen.dart'; +import 'feature/profile/presentation/bloc/profile_bloc.dart'; +import 'feature/profile/presentation/bloc/profile_event.dart'; +import 'feature/profile/presentation/screen/profile_screen.dart'; +import 'feature/shop/presentation/bloc/shop_bloc.dart'; +import 'feature/shop/presentation/bloc/shop_event.dart'; +import 'feature/shop/presentation/screen/shop_screen.dart'; +import 'feature/shop/presentation/screen/vip_screen.dart'; +import 'feature/wallet/presentation/bloc/wallet_bloc.dart'; +import 'feature/wallet/presentation/screen/lobby_screen.dart'; class HakemApp extends StatelessWidget { - final ApiClient api; - final AuthRepository authRepo; - final TokenStorage tokenStorage; final bool loggedIn; - - const HakemApp({ - super.key, - required this.api, - required this.authRepo, - required this.tokenStorage, - required this.loggedIn, - }); + const HakemApp({super.key, required this.loggedIn}); @override Widget build(BuildContext context) { @@ -49,67 +40,65 @@ class HakemApp extends StatelessWidget { GoRoute(path: '/setup', builder: (_, __) => const ProfileSetupScreen()), GoRoute(path: '/lobby', builder: (_, __) => const LobbyScreen()), GoRoute( - path: '/profile', - builder: (_, __) => ProfileScreen(api: api)), - GoRoute( - path: '/private', - builder: (_, __) => PrivateEntryScreen(api: api)), - GoRoute( - path: '/private/room', - builder: (_, st) { - final create = st.uri.queryParameters['create'] == '1'; - final joinCode = st.uri.queryParameters['join']; - return FutureBuilder( - future: tokenStorage.read(), - builder: (context, snap) { - if (!snap.hasData) { - return const Scaffold( - body: Center(child: CircularProgressIndicator())); - } - return PrivateTableScreen( - token: snap.data!, - create: create, - joinCode: joinCode, - ); - }, - ); - }, + path: '/profile', + builder: (_, __) => BlocProvider( + create: (_) => locator()..add(LoadProfileEvent()), + child: const ProfileScreen(), + ), ), GoRoute( path: '/shop', builder: (_, __) => BlocProvider( - create: (_) => ShopCubit(ShopRepository(api))..load(), + create: (_) => locator()..add(LoadShopEvent()), child: const ShopScreen(), ), ), GoRoute( path: '/vip', builder: (_, __) => BlocProvider( - create: (_) => ShopCubit(ShopRepository(api))..load(), + create: (_) => locator()..add(LoadShopEvent()), child: const VipScreen(), ), ), + GoRoute( + path: '/private', + builder: (_, __) => BlocProvider( + create: (_) => locator(), + child: const PrivateEntryScreen(), + ), + ), + GoRoute( + path: '/private/room', + builder: (_, st) { + final create = st.uri.queryParameters['create'] == '1'; + final joinCode = st.uri.queryParameters['join']; + final action = create + ? {'type': 'create_table'} + : {'type': 'join_table', 'code': joinCode ?? ''}; + return BlocProvider( + create: (_) => + locator()..add(ConnectGameEvent(action)), + child: const PrivateTableScreen(), + ); + }, + ), GoRoute( path: '/game/tiers', - builder: (_, __) => TierListScreen(repo: GameRepository(api)), + builder: (_, __) => BlocProvider( + create: (_) => locator()..add(LoadTiersEvent()), + child: const TierListScreen(), + ), ), GoRoute( path: '/game/:tier', builder: (_, st) { final tier = st.pathParameters['tier']!; - final prize = int.tryParse(st.uri.queryParameters['prize'] ?? '') ?? 0; - return FutureBuilder( - future: tokenStorage.read(), - builder: (context, snap) { - if (!snap.hasData) { - return const Scaffold( - body: Center(child: CircularProgressIndicator())); - } - return BlocProvider( - create: (_) => GameCubit(WsClient(snap.data!), tier), - child: GameScreen(prize: prize), - ); - }, + final prize = + int.tryParse(st.uri.queryParameters['prize'] ?? '') ?? 0; + return BlocProvider( + create: (_) => locator() + ..add(ConnectGameEvent({'type': 'join_queue', 'tier': tier})), + child: GameScreen(prize: prize), ); }, ), @@ -118,15 +107,14 @@ class HakemApp extends StatelessWidget { return MultiBlocProvider( providers: [ - BlocProvider(create: (_) => AuthCubit(authRepo)), - BlocProvider(create: (_) => WalletCubit(api)), + BlocProvider(create: (_) => locator()), + BlocProvider(create: (_) => locator()), ], child: MaterialApp.router( title: 'سلطان حکم', debugShowCheckedModeBanner: false, theme: AppTheme.build(), routerConfig: router, - // اعمال راست‌به‌چپ برای کل اپ. builder: (context, child) => Directionality( textDirection: TextDirection.rtl, child: child!, diff --git a/lib/core/error/custom_error.dart b/lib/core/error/custom_error.dart new file mode 100644 index 0000000..d844146 --- /dev/null +++ b/lib/core/error/custom_error.dart @@ -0,0 +1,28 @@ +/// تبدیل کد وضعیت HTTP و پیام سرور به پیام خطای فارسیِ قابل‌نمایش. +String errorConvertor(int? statusCode, String? message) { + if (statusCode != null) { + switch (statusCode) { + case 500: + return 'ارتباط با سرور برقرار نشد؛ کمی بعد دوباره تلاش کنید'; + case 429: + return 'تعداد درخواست‌ها زیاد است؛ کمی بعد دوباره تلاش کنید'; + case 401: + return 'دسترسی لازم را ندارید؛ دوباره وارد شوید'; + case 403: + return 'این درخواست مجاز نیست'; + case 404: + return message ?? 'یافت نشد'; + case 409: + return message ?? 'قبلاً انجام شده است'; + case 402: + return 'موجودی سکه کافی نیست'; + case 400: + return message ?? 'درخواست نامعتبر است'; + case 422: + return message ?? 'مقادیر وارد شده صحیح نیست'; + default: + return message ?? 'خطای نامشخص'; + } + } + return message ?? 'خطا در ارتباط با سرور'; +} diff --git a/lib/core/locator/locator.dart b/lib/core/locator/locator.dart new file mode 100644 index 0000000..f0315c6 --- /dev/null +++ b/lib/core/locator/locator.dart @@ -0,0 +1,103 @@ +import 'package:get_it/get_it.dart'; + +import '../../feature/auth/data/data_source/local/auth_local_data.dart'; +import '../../feature/auth/data/data_source/remote/auth_api_provider.dart'; +import '../../feature/auth/data/repository/auth_repository_impl.dart'; +import '../../feature/auth/domain/repository/auth_repository.dart'; +import '../../feature/auth/domain/use_cases/check_otp_usecase.dart'; +import '../../feature/auth/domain/use_cases/login_usecase.dart'; +import '../../feature/auth/domain/use_cases/logout_usecase.dart'; +import '../../feature/auth/domain/use_cases/update_profile_usecase.dart'; +import '../../feature/auth/presentation/bloc/auth_bloc.dart'; +import '../../feature/game/data/data_source/remote/game_api_provider.dart'; +import '../../feature/game/data/data_source/remote/game_ws_provider.dart'; +import '../../feature/game/data/repository/game_repository_impl.dart'; +import '../../feature/game/domain/repository/game_repository.dart'; +import '../../feature/game/domain/use_cases/get_tables_info_usecase.dart'; +import '../../feature/game/domain/use_cases/get_tiers_usecase.dart'; +import '../../feature/game/presentation/bloc/game_bloc.dart'; +import '../../feature/game/presentation/bloc/private_info_bloc.dart'; +import '../../feature/game/presentation/bloc/tier_bloc.dart'; +import '../../feature/profile/data/data_source/remote/profile_api_provider.dart'; +import '../../feature/profile/data/repository/profile_repository_impl.dart'; +import '../../feature/profile/domain/repository/profile_repository.dart'; +import '../../feature/profile/domain/use_cases/get_profile_usecase.dart'; +import '../../feature/profile/domain/use_cases/save_profile_usecase.dart'; +import '../../feature/profile/presentation/bloc/profile_bloc.dart'; +import '../../feature/shop/data/data_source/remote/shop_api_provider.dart'; +import '../../feature/shop/data/repository/shop_repository_impl.dart'; +import '../../feature/shop/domain/repository/shop_repository.dart'; +import '../../feature/shop/domain/use_cases/ad_reward_usecase.dart'; +import '../../feature/shop/domain/use_cases/buy_card_usecase.dart'; +import '../../feature/shop/domain/use_cases/get_shop_usecase.dart'; +import '../../feature/shop/domain/use_cases/purchase_usecase.dart'; +import '../../feature/shop/domain/use_cases/select_card_usecase.dart'; +import '../../feature/shop/presentation/bloc/shop_bloc.dart'; +import '../../feature/wallet/data/data_source/remote/wallet_api_provider.dart'; +import '../../feature/wallet/data/repository/wallet_repository_impl.dart'; +import '../../feature/wallet/domain/repository/wallet_repository.dart'; +import '../../feature/wallet/domain/use_cases/claim_daily_usecase.dart'; +import '../../feature/wallet/domain/use_cases/get_wallet_usecase.dart'; +import '../../feature/wallet/presentation/bloc/wallet_bloc.dart'; +import '../network/api_provider_imp.dart'; +import '../storage/token_storage.dart'; + +final GetIt locator = GetIt.instance; + +/// ثبتِ همه‌ی وابستگی‌ها (تک‌خط در main فراخوانی می‌شود). +Future setupLocator() async { + // --- core --- + locator.registerSingleton(TokenStorage()); + locator.registerSingleton(ApiProviderImp(locator())); + + // --- data sources --- + locator.registerSingleton(AuthLocalData(locator())); + locator.registerSingleton(AuthApiProvider()); + locator.registerSingleton(WalletApiProvider()); + locator.registerSingleton(ShopApiProvider()); + locator.registerSingleton(ProfileApiProvider()); + locator.registerSingleton(GameApiProvider()); + locator.registerSingleton(GameWsProvider(locator())); + + // --- repositories --- + locator.registerSingleton( + AuthRepositoryImpl(locator(), locator())); + locator.registerSingleton( + WalletRepositoryImpl(locator())); + locator.registerSingleton(ShopRepositoryImpl(locator())); + locator.registerSingleton( + ProfileRepositoryImpl(locator())); + locator.registerSingleton( + GameRepositoryImpl(locator(), locator())); + + // --- use cases --- + locator.registerSingleton(LoginUseCase(locator())); + locator.registerSingleton(CheckOtpUseCase(locator())); + locator.registerSingleton( + UpdateProfileUseCase(locator())); + locator.registerSingleton(LogoutUseCase(locator())); + locator.registerSingleton(GetWalletUseCase(locator())); + locator.registerSingleton(ClaimDailyUseCase(locator())); + locator.registerSingleton(GetShopUseCase(locator())); + locator.registerSingleton(BuyCardUseCase(locator())); + locator.registerSingleton(SelectCardUseCase(locator())); + locator.registerSingleton(PurchaseUseCase(locator())); + locator.registerSingleton(AdRewardUseCase(locator())); + locator.registerSingleton(GetProfileUseCase(locator())); + locator.registerSingleton(SaveProfileUseCase(locator())); + locator.registerSingleton(GetTiersUseCase(locator())); + locator.registerSingleton( + GetTablesInfoUseCase(locator())); + + // --- blocs (factory) --- + locator.registerFactory( + () => AuthBloc(locator(), locator(), locator(), locator())); + locator.registerFactory(() => WalletBloc(locator(), locator())); + locator.registerFactory(() => + ShopBloc(locator(), locator(), locator(), locator(), locator())); + locator.registerFactory( + () => ProfileBloc(locator(), locator())); + locator.registerFactory(() => GameBloc(locator())); + locator.registerFactory(() => TierBloc(locator())); + locator.registerFactory(() => PrivateInfoBloc(locator())); +} diff --git a/lib/core/network/api_client.dart b/lib/core/network/api_client.dart deleted file mode 100644 index 52a6a7e..0000000 --- a/lib/core/network/api_client.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../config.dart'; -import '../storage/token_storage.dart'; - -/// کلاینت HTTP با تزریق خودکار توکن Bearer. -class ApiClient { - final Dio dio; - - ApiClient(TokenStorage storage) - : dio = Dio(BaseOptions( - baseUrl: AppConfig.apiUrl, - connectTimeout: const Duration(seconds: 10), - receiveTimeout: const Duration(seconds: 10), - headers: {'Accept': 'application/json'}, - )) { - dio.interceptors.add(InterceptorsWrapper( - onRequest: (options, handler) async { - final token = await storage.read(); - if (token != null && token.isNotEmpty) { - options.headers['Authorization'] = 'Bearer $token'; - } - handler.next(options); - }, - )); - } -} diff --git a/lib/core/network/api_provider_imp.dart b/lib/core/network/api_provider_imp.dart new file mode 100644 index 0000000..7bf4f5a --- /dev/null +++ b/lib/core/network/api_provider_imp.dart @@ -0,0 +1,41 @@ +import 'package:dio/dio.dart'; + +import '../config.dart'; +import '../storage/token_storage.dart'; + +/// لایه‌ی پایه‌ی شبکه: یک Dio با baseUrl، تزریق خودکار توکن Bearer و +/// validateStatus باز (تا کدهای خطا throw نشوند و در repository بررسی شوند). +class ApiProviderImp { + final Dio dio; + + ApiProviderImp(TokenStorage storage) + : dio = Dio(BaseOptions( + baseUrl: AppConfig.apiUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 15), + headers: {'Accept': 'application/json'}, + validateStatus: (_) => true, + )) { + dio.interceptors.add(InterceptorsWrapper( + onRequest: (options, handler) async { + final token = await storage.read(); + if (token != null && token.isNotEmpty) { + options.headers['Authorization'] = 'Bearer $token'; + } + handler.next(options); + }, + )); + } + + Future get(String path, {Map? query}) => + dio.get(path, queryParameters: query); + + Future post(String path, {Object? body}) => + dio.post(path, data: body); + + Future put(String path, {Object? body}) => + dio.put(path, data: body); + + Future delete(String path, {Object? body}) => + dio.delete(path, data: body); +} diff --git a/lib/core/resources/data_state.dart b/lib/core/resources/data_state.dart new file mode 100644 index 0000000..804ca18 --- /dev/null +++ b/lib/core/resources/data_state.dart @@ -0,0 +1,15 @@ +/// نتیجه‌ی یک عملیات داده‌ای: موفق (داده) یا خطا (پیام). +/// لایه‌ی data همیشه DataState برمی‌گرداند تا لایه‌های بالا throw نگیرند. +abstract class DataState { + final T? data; + final String? error; + const DataState(this.data, this.error); +} + +class DataSuccess extends DataState { + const DataSuccess(T data) : super(data, null); +} + +class DataError extends DataState { + const DataError(String error) : super(null, error); +} diff --git a/lib/core/usecase/use_case.dart b/lib/core/usecase/use_case.dart new file mode 100644 index 0000000..bb3f27c --- /dev/null +++ b/lib/core/usecase/use_case.dart @@ -0,0 +1,37 @@ +/// قرارداد یوزکیس: هر یوزکیس با یک پارامتر فراخوانی می‌شود و یک Future برمی‌گرداند. +abstract class UseCase { + Future call(P params); +} + +/// نبودِ پارامتر (برای یوزکیس‌های بدون ورودی). +class NoParams { + const NoParams(); +} + +/// پارامتر بررسی کد یک‌بارمصرف. +class OtpParams { + final String mobile; + final String token; + const OtpParams(this.mobile, this.token); +} + +/// پارامتر به‌روزرسانی پروفایل (نام و آواتار). +class ProfileParams { + final String firstName; + final String avatar; + const ProfileParams(this.firstName, this.avatar); +} + +/// پارامتر خرید درون‌برنامه‌ای (IAP). +class PurchaseParams { + final String store; + final String kind; + final String productId; + final String token; + const PurchaseParams({ + required this.store, + required this.kind, + required this.productId, + required this.token, + }); +} diff --git a/lib/feature/auth/data/data_source/local/auth_local_data.dart b/lib/feature/auth/data/data_source/local/auth_local_data.dart new file mode 100644 index 0000000..25db814 --- /dev/null +++ b/lib/feature/auth/data/data_source/local/auth_local_data.dart @@ -0,0 +1,16 @@ +import '../../../../../core/storage/token_storage.dart'; + +/// ذخیره‌ی محلیِ توکن احراز هویت. +class AuthLocalData { + final TokenStorage _storage; + AuthLocalData(this._storage); + + Future saveToken(String token) => _storage.write(token); + Future readToken() => _storage.read(); + Future clearToken() => _storage.clear(); + + Future hasToken() async { + final t = await _storage.read(); + return t != null && t.isNotEmpty; + } +} diff --git a/lib/feature/auth/data/data_source/remote/auth_api_provider.dart b/lib/feature/auth/data/data_source/remote/auth_api_provider.dart new file mode 100644 index 0000000..4c901bb --- /dev/null +++ b/lib/feature/auth/data/data_source/remote/auth_api_provider.dart @@ -0,0 +1,20 @@ +import 'package:dio/dio.dart'; + +import '../../../../../core/locator/locator.dart'; +import '../../../../../core/network/api_provider_imp.dart'; + +/// تماس‌های خامِ HTTP مربوط به احراز هویت (خروجی Response). +class AuthApiProvider { + ApiProviderImp get _api => locator(); + + Future loginOtp(String mobile) => + _api.post('/auth/login-otp', body: {'mobile': mobile}); + + Future checkOtp(String mobile, String token) => + _api.post('/auth/check-otp', body: {'mobile': mobile, 'token': token}); + + Future updateProfile(String firstName, String avatar) => + _api.post('/profile', body: {'first_name': firstName, 'avatar': avatar}); + + Future me() => _api.get('/me'); +} diff --git a/lib/feature/auth/data/model/user_model.dart b/lib/feature/auth/data/model/user_model.dart new file mode 100644 index 0000000..8098df0 --- /dev/null +++ b/lib/feature/auth/data/model/user_model.dart @@ -0,0 +1,18 @@ +import '../../domain/entities/user_entity.dart'; + +/// مدلِ داده‌ی کاربر؛ از JSON ساخته شده و به UserEntity نگاشت می‌شود. +class UserModel extends UserEntity { + const UserModel({ + required super.id, + required super.mobile, + super.firstName, + super.avatar, + }); + + factory UserModel.fromJson(Map j) => UserModel( + id: (j['id'] ?? 0) as int, + mobile: (j['mobile'] ?? '') as String, + firstName: j['first_name'] as String?, + avatar: j['avatar'] as String?, + ); +} diff --git a/lib/feature/auth/data/repository/auth_repository_impl.dart b/lib/feature/auth/data/repository/auth_repository_impl.dart new file mode 100644 index 0000000..b6e57bf --- /dev/null +++ b/lib/feature/auth/data/repository/auth_repository_impl.dart @@ -0,0 +1,65 @@ +import 'package:dio/dio.dart'; + +import '../../../../core/error/custom_error.dart'; +import '../../../../core/resources/data_state.dart'; +import '../../../../core/usecase/use_case.dart'; +import '../../domain/entities/user_entity.dart'; +import '../../domain/repository/auth_repository.dart'; +import '../data_source/local/auth_local_data.dart'; +import '../data_source/remote/auth_api_provider.dart'; +import '../model/user_model.dart'; + +class AuthRepositoryImpl extends AuthRepository { + final AuthApiProvider api; + final AuthLocalData local; + AuthRepositoryImpl(this.api, this.local); + + @override + Future> loginOtp(String mobile) async { + final Response res = await api.loginOtp(mobile); + if (res.statusCode == 200) { + return const DataSuccess('ok'); + } + return DataError(errorConvertor(res.statusCode, _msg(res))); + } + + @override + Future> checkOtp(OtpParams params) async { + final Response res = await api.checkOtp(params.mobile, params.token); + if (res.statusCode == 200) { + final token = res.data['token'] as String?; + if (token == null || token.isEmpty) { + return const DataError('پاسخ نامعتبر از سرور'); + } + await local.saveToken(token); + return DataSuccess(UserModel.fromJson( + Map.from(res.data['user'] as Map))); + } + return DataError(errorConvertor(res.statusCode, _msg(res))); + } + + @override + Future> updateProfile(ProfileParams params) async { + final Response res = await api.updateProfile(params.firstName, params.avatar); + if (res.statusCode == 200) { + return DataSuccess(UserModel.fromJson( + Map.from(res.data['user'] as Map))); + } + return DataError(errorConvertor(res.statusCode, _msg(res))); + } + + @override + Future> logout() async { + await local.clearToken(); + return const DataSuccess('ok'); + } + + @override + Future isLoggedIn() => local.hasToken(); + + String? _msg(Response res) { + final d = res.data; + if (d is Map && d['message'] != null) return d['message'].toString(); + return null; + } +} diff --git a/lib/feature/auth/domain/entities/user_entity.dart b/lib/feature/auth/domain/entities/user_entity.dart new file mode 100644 index 0000000..ebfa6d4 --- /dev/null +++ b/lib/feature/auth/domain/entities/user_entity.dart @@ -0,0 +1,16 @@ +/// موجودیتِ کاربر (نام نمایشی و آواتار برای استفاده در UI). +class UserEntity { + final int id; + final String mobile; + final String? firstName; + final String? avatar; + + const UserEntity({ + required this.id, + required this.mobile, + this.firstName, + this.avatar, + }); + + bool get hasName => firstName != null && firstName!.trim().isNotEmpty; +} diff --git a/lib/feature/auth/domain/repository/auth_repository.dart b/lib/feature/auth/domain/repository/auth_repository.dart new file mode 100644 index 0000000..780a37c --- /dev/null +++ b/lib/feature/auth/domain/repository/auth_repository.dart @@ -0,0 +1,17 @@ +import '../../../../core/resources/data_state.dart'; +import '../../../../core/usecase/use_case.dart'; +import '../entities/user_entity.dart'; + +/// قرارداد لایه‌ی داده‌ی احراز هویت (پیاده‌سازی در data/repository). +abstract class AuthRepository { + Future> loginOtp(String mobile); + + /// بررسی کد؛ توکن را ذخیره کرده و کاربرِ احرازشده را برمی‌گرداند. + Future> checkOtp(OtpParams params); + + Future> updateProfile(ProfileParams params); + + Future> logout(); + + Future isLoggedIn(); +} diff --git a/lib/feature/auth/domain/use_cases/check_otp_usecase.dart b/lib/feature/auth/domain/use_cases/check_otp_usecase.dart new file mode 100644 index 0000000..32110fc --- /dev/null +++ b/lib/feature/auth/domain/use_cases/check_otp_usecase.dart @@ -0,0 +1,13 @@ +import '../../../../core/resources/data_state.dart'; +import '../../../../core/usecase/use_case.dart'; +import '../entities/user_entity.dart'; +import '../repository/auth_repository.dart'; + +class CheckOtpUseCase implements UseCase, OtpParams> { + final AuthRepository repository; + CheckOtpUseCase(this.repository); + + @override + Future> call(OtpParams params) => + repository.checkOtp(params); +} diff --git a/lib/feature/auth/domain/use_cases/login_usecase.dart b/lib/feature/auth/domain/use_cases/login_usecase.dart new file mode 100644 index 0000000..f5e6cec --- /dev/null +++ b/lib/feature/auth/domain/use_cases/login_usecase.dart @@ -0,0 +1,11 @@ +import '../../../../core/resources/data_state.dart'; +import '../../../../core/usecase/use_case.dart'; +import '../repository/auth_repository.dart'; + +class LoginUseCase implements UseCase, String> { + final AuthRepository repository; + LoginUseCase(this.repository); + + @override + Future> call(String params) => repository.loginOtp(params); +} diff --git a/lib/feature/auth/domain/use_cases/logout_usecase.dart b/lib/feature/auth/domain/use_cases/logout_usecase.dart new file mode 100644 index 0000000..8901890 --- /dev/null +++ b/lib/feature/auth/domain/use_cases/logout_usecase.dart @@ -0,0 +1,11 @@ +import '../../../../core/resources/data_state.dart'; +import '../../../../core/usecase/use_case.dart'; +import '../repository/auth_repository.dart'; + +class LogoutUseCase implements UseCase, NoParams> { + final AuthRepository repository; + LogoutUseCase(this.repository); + + @override + Future> call(NoParams params) => repository.logout(); +} diff --git a/lib/feature/auth/domain/use_cases/update_profile_usecase.dart b/lib/feature/auth/domain/use_cases/update_profile_usecase.dart new file mode 100644 index 0000000..6006961 --- /dev/null +++ b/lib/feature/auth/domain/use_cases/update_profile_usecase.dart @@ -0,0 +1,14 @@ +import '../../../../core/resources/data_state.dart'; +import '../../../../core/usecase/use_case.dart'; +import '../entities/user_entity.dart'; +import '../repository/auth_repository.dart'; + +class UpdateProfileUseCase + implements UseCase, ProfileParams> { + final AuthRepository repository; + UpdateProfileUseCase(this.repository); + + @override + Future> call(ProfileParams params) => + repository.updateProfile(params); +} diff --git a/lib/feature/auth/presentation/bloc/auth_bloc.dart b/lib/feature/auth/presentation/bloc/auth_bloc.dart new file mode 100644 index 0000000..1815a72 --- /dev/null +++ b/lib/feature/auth/presentation/bloc/auth_bloc.dart @@ -0,0 +1,63 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../core/resources/data_state.dart'; +import '../../../../core/usecase/use_case.dart'; +import '../../domain/use_cases/check_otp_usecase.dart'; +import '../../domain/use_cases/login_usecase.dart'; +import '../../domain/use_cases/logout_usecase.dart'; +import '../../domain/use_cases/update_profile_usecase.dart'; +import 'auth_event.dart'; +import 'auth_state.dart'; +import 'login_status.dart'; +import 'otp_status.dart'; +import 'profile_status.dart'; + +class AuthBloc extends Bloc { + final LoginUseCase loginUseCase; + final CheckOtpUseCase checkOtpUseCase; + final UpdateProfileUseCase updateProfileUseCase; + final LogoutUseCase logoutUseCase; + + AuthBloc( + this.loginUseCase, + this.checkOtpUseCase, + this.updateProfileUseCase, + this.logoutUseCase, + ) : super(AuthState.initial()) { + on((event, emit) async { + emit(state.copyWith(mobile: event.mobile, loginStatus: LoginLoading())); + final res = await loginUseCase(event.mobile); + if (res is DataSuccess) { + emit(state.copyWith(loginStatus: LoginSuccess())); + } else { + emit(state.copyWith(loginStatus: LoginError(res.error!))); + } + }); + + on((event, emit) async { + emit(state.copyWith(otpStatus: OtpLoading())); + final res = await checkOtpUseCase(OtpParams(state.mobile, event.token)); + if (res is DataSuccess) { + emit(state.copyWith(otpStatus: OtpSuccess(res.data!.hasName))); + } else { + emit(state.copyWith(otpStatus: OtpError(res.error!))); + } + }); + + on((event, emit) async { + emit(state.copyWith(profileStatus: ProfileLoading())); + final res = await updateProfileUseCase( + ProfileParams(event.firstName, event.avatar)); + if (res is DataSuccess) { + emit(state.copyWith(profileStatus: ProfileSuccess())); + } else { + emit(state.copyWith(profileStatus: ProfileError(res.error!))); + } + }); + + on((event, emit) async { + await logoutUseCase(const NoParams()); + emit(AuthState.initial()); + }); + } +} diff --git a/lib/feature/auth/presentation/bloc/auth_event.dart b/lib/feature/auth/presentation/bloc/auth_event.dart new file mode 100644 index 0000000..f73a69d --- /dev/null +++ b/lib/feature/auth/presentation/bloc/auth_event.dart @@ -0,0 +1,19 @@ +abstract class AuthEvent {} + +class LoginOtpEvent extends AuthEvent { + final String mobile; + LoginOtpEvent(this.mobile); +} + +class CheckOtpEvent extends AuthEvent { + final String token; + CheckOtpEvent(this.token); +} + +class UpdateProfileEvent extends AuthEvent { + final String firstName; + final String avatar; + UpdateProfileEvent(this.firstName, this.avatar); +} + +class LogoutEvent extends AuthEvent {} diff --git a/lib/feature/auth/presentation/bloc/auth_state.dart b/lib/feature/auth/presentation/bloc/auth_state.dart new file mode 100644 index 0000000..d73c624 --- /dev/null +++ b/lib/feature/auth/presentation/bloc/auth_state.dart @@ -0,0 +1,37 @@ +import 'login_status.dart'; +import 'otp_status.dart'; +import 'profile_status.dart'; + +class AuthState { + final String mobile; // شماره‌ی در حال احراز (برای صفحه‌ی کد) + final LoginStatus loginStatus; + final OtpStatus otpStatus; + final ProfileStatus profileStatus; + + AuthState({ + required this.mobile, + required this.loginStatus, + required this.otpStatus, + required this.profileStatus, + }); + + factory AuthState.initial() => AuthState( + mobile: '', + loginStatus: LoginInitial(), + otpStatus: OtpInitial(), + profileStatus: ProfileInitial(), + ); + + AuthState copyWith({ + String? mobile, + LoginStatus? loginStatus, + OtpStatus? otpStatus, + ProfileStatus? profileStatus, + }) => + AuthState( + mobile: mobile ?? this.mobile, + loginStatus: loginStatus ?? this.loginStatus, + otpStatus: otpStatus ?? this.otpStatus, + profileStatus: profileStatus ?? this.profileStatus, + ); +} diff --git a/lib/feature/auth/presentation/bloc/login_status.dart b/lib/feature/auth/presentation/bloc/login_status.dart new file mode 100644 index 0000000..e723257 --- /dev/null +++ b/lib/feature/auth/presentation/bloc/login_status.dart @@ -0,0 +1,12 @@ +abstract class LoginStatus {} + +class LoginInitial extends LoginStatus {} + +class LoginLoading extends LoginStatus {} + +class LoginSuccess extends LoginStatus {} + +class LoginError extends LoginStatus { + final String message; + LoginError(this.message); +} diff --git a/lib/feature/auth/presentation/bloc/otp_status.dart b/lib/feature/auth/presentation/bloc/otp_status.dart new file mode 100644 index 0000000..35e7860 --- /dev/null +++ b/lib/feature/auth/presentation/bloc/otp_status.dart @@ -0,0 +1,15 @@ +abstract class OtpStatus {} + +class OtpInitial extends OtpStatus {} + +class OtpLoading extends OtpStatus {} + +class OtpSuccess extends OtpStatus { + final bool hasName; // اگر نام نداشته باشد، باید به صفحه‌ی انتخاب نام برود + OtpSuccess(this.hasName); +} + +class OtpError extends OtpStatus { + final String message; + OtpError(this.message); +} diff --git a/lib/feature/auth/presentation/bloc/profile_status.dart b/lib/feature/auth/presentation/bloc/profile_status.dart new file mode 100644 index 0000000..f29a722 --- /dev/null +++ b/lib/feature/auth/presentation/bloc/profile_status.dart @@ -0,0 +1,12 @@ +abstract class ProfileStatus {} + +class ProfileInitial extends ProfileStatus {} + +class ProfileLoading extends ProfileStatus {} + +class ProfileSuccess extends ProfileStatus {} + +class ProfileError extends ProfileStatus { + final String message; + ProfileError(this.message); +} diff --git a/lib/features/auth/mobile_screen.dart b/lib/feature/auth/presentation/screen/mobile_screen.dart similarity index 74% rename from lib/features/auth/mobile_screen.dart rename to lib/feature/auth/presentation/screen/mobile_screen.dart index 6edc34c..5aba454 100644 --- a/lib/features/auth/mobile_screen.dart +++ b/lib/feature/auth/presentation/screen/mobile_screen.dart @@ -3,8 +3,11 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; -import '../../core/widgets/game_ui.dart'; -import 'auth_cubit.dart'; +import '../../../../core/widgets/game_ui.dart'; +import '../bloc/auth_bloc.dart'; +import '../bloc/auth_event.dart'; +import '../bloc/auth_state.dart'; +import '../bloc/login_status.dart'; /// صفحه‌ی ورود شماره موبایل. class MobileScreen extends StatefulWidget { @@ -29,18 +32,19 @@ class _MobileScreenState extends State { Widget build(BuildContext context) { return Scaffold( body: GameBackground( - child: BlocConsumer( + child: BlocConsumer( + listenWhen: (a, b) => a.loginStatus != b.loginStatus, listener: (context, state) { - if (state.status == AuthStatus.otpSent) { + final s = state.loginStatus; + if (s is LoginSuccess) { context.push('/otp'); - } else if (state.status == AuthStatus.error) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(state.error ?? 'خطا')), - ); + } else if (s is LoginError) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(s.message))); } }, builder: (context, state) { - final loading = state.status == AuthStatus.loading; + final loading = state.loginStatus is LoginLoading; return Center( child: SingleChildScrollView( padding: const EdgeInsets.all(24), @@ -61,12 +65,14 @@ class _MobileScreenState extends State { controller: _controller, keyboardType: TextInputType.phone, textAlign: TextAlign.center, - style: const TextStyle(fontSize: 20, letterSpacing: 2), + style: + const TextStyle(fontSize: 20, letterSpacing: 2), inputFormatters: [ FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(11), ], - decoration: const InputDecoration(hintText: '09xxxxxxxxx'), + decoration: + const InputDecoration(hintText: '09xxxxxxxxx'), onChanged: (_) => setState(() {}), ), const SizedBox(height: 20), @@ -77,8 +83,8 @@ class _MobileScreenState extends State { onTap: (!_valid || loading) ? null : () => context - .read() - .requestOtp(_controller.text.trim()), + .read() + .add(LoginOtpEvent(_controller.text.trim())), ), ], ), diff --git a/lib/features/auth/otp_screen.dart b/lib/feature/auth/presentation/screen/otp_screen.dart similarity index 74% rename from lib/features/auth/otp_screen.dart rename to lib/feature/auth/presentation/screen/otp_screen.dart index b43c7e9..fce644f 100644 --- a/lib/features/auth/otp_screen.dart +++ b/lib/feature/auth/presentation/screen/otp_screen.dart @@ -3,9 +3,12 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; -import '../../core/theme/app_theme.dart'; -import '../../core/widgets/game_ui.dart'; -import 'auth_cubit.dart'; +import '../../../../core/theme/app_theme.dart'; +import '../../../../core/widgets/game_ui.dart'; +import '../bloc/auth_bloc.dart'; +import '../bloc/auth_event.dart'; +import '../bloc/auth_state.dart'; +import '../bloc/otp_status.dart'; /// صفحه‌ی ورود کد یک‌بارمصرف (۵ رقمی). class OtpScreen extends StatefulWidget { @@ -30,19 +33,19 @@ class _OtpScreenState extends State { Widget build(BuildContext context) { return Scaffold( body: GameBackground( - child: BlocConsumer( + child: BlocConsumer( + listenWhen: (a, b) => a.otpStatus != b.otpStatus, listener: (context, state) { - if (state.status == AuthStatus.authenticated) { - context.go(state.needsProfile ? '/setup' : '/lobby'); - } else if (state.status == AuthStatus.error) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(state.error ?? 'خطا')), - ); - context.read().resetError(onOtpScreen: true); + final s = state.otpStatus; + if (s is OtpSuccess) { + context.go(s.hasName ? '/lobby' : '/setup'); + } else if (s is OtpError) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(s.message))); } }, builder: (context, state) { - final loading = state.status == AuthStatus.loading; + final loading = state.otpStatus is OtpLoading; return Center( child: SingleChildScrollView( padding: const EdgeInsets.all(24), @@ -64,12 +67,14 @@ class _OtpScreenState extends State { controller: _controller, keyboardType: TextInputType.number, textAlign: TextAlign.center, - style: const TextStyle(fontSize: 28, letterSpacing: 12), + style: const TextStyle( + fontSize: 28, letterSpacing: 12), inputFormatters: [ FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(5), ], - decoration: const InputDecoration(hintText: '- - - - -'), + decoration: + const InputDecoration(hintText: '- - - - -'), onChanged: (_) => setState(() {}), ), const SizedBox(height: 20), @@ -80,8 +85,8 @@ class _OtpScreenState extends State { onTap: (!_valid || loading) ? null : () => context - .read() - .verifyOtp(_controller.text.trim()), + .read() + .add(CheckOtpEvent(_controller.text.trim())), ), TextButton( onPressed: loading ? null : () => context.pop(), diff --git a/lib/feature/auth/presentation/screen/profile_setup_screen.dart b/lib/feature/auth/presentation/screen/profile_setup_screen.dart new file mode 100644 index 0000000..e65cc3a --- /dev/null +++ b/lib/feature/auth/presentation/screen/profile_setup_screen.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:random_avatar/random_avatar.dart'; + +import '../../../../core/theme/app_theme.dart'; +import '../../../../core/widgets/game_ui.dart'; +import '../bloc/auth_bloc.dart'; +import '../bloc/auth_event.dart'; +import '../bloc/auth_state.dart'; +import '../bloc/profile_status.dart'; + +/// صفحه‌ی انتخاب نام و آواتار پس از اولین ورود. +class ProfileSetupScreen extends StatefulWidget { + const ProfileSetupScreen({super.key}); + + @override + State createState() => _ProfileSetupScreenState(); +} + +class _ProfileSetupScreenState extends State { + final _name = TextEditingController(); + late List _seeds; + int _selected = 0; + + @override + void initState() { + super.initState(); + _seeds = List.generate(12, (i) => 'hakem-${i + 1}'); + } + + @override + void dispose() { + _name.dispose(); + super.dispose(); + } + + bool get _valid => _name.text.trim().length >= 2; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: GameBackground( + child: BlocConsumer( + listenWhen: (a, b) => a.profileStatus != b.profileStatus, + listener: (context, state) { + final s = state.profileStatus; + if (s is ProfileSuccess) { + context.go('/lobby'); + } else if (s is ProfileError) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(s.message))); + } + }, + builder: (context, state) { + final saving = state.profileStatus is ProfileLoading; + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const GlowText('انتخاب نام و آواتار', size: 26), + const SizedBox(height: 20), + GamePanel( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: + Border.all(color: AppColors.gold, width: 2), + ), + child: RandomAvatar(_seeds[_selected], + height: 84, width: 84), + ), + const SizedBox(height: 14), + TextField( + controller: _name, + textAlign: TextAlign.center, + maxLength: 20, + inputFormatters: [ + LengthLimitingTextInputFormatter(20), + ], + decoration: const InputDecoration( + hintText: 'نام نمایشی (۲ تا ۲۰ حرف)', + counterText: ''), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 14), + const Text('یک آواتار انتخاب کن', + style: TextStyle(color: AppColors.gold)), + const SizedBox(height: 10), + GridView.count( + crossAxisCount: 4, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 10, + crossAxisSpacing: 10, + children: [ + for (var i = 0; i < _seeds.length; i++) + GestureDetector( + onTap: () => setState(() => _selected = i), + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.bgDark, + border: Border.all( + color: _selected == i + ? AppColors.gold + : Colors.transparent, + width: 2.5, + ), + ), + child: RandomAvatar(_seeds[i]), + ), + ), + ], + ), + const SizedBox(height: 18), + GameButton( + label: saving ? 'در حال ذخیره…' : 'تأیید و ورود', + width: double.infinity, + colors: const [Color(0xFF3FA34D), Color(0xFF1B5E20)], + onTap: (!_valid || saving) + ? null + : () => context.read().add( + UpdateProfileEvent( + _name.text.trim(), _seeds[_selected])), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/lib/feature/game/data/data_source/remote/game_api_provider.dart b/lib/feature/game/data/data_source/remote/game_api_provider.dart new file mode 100644 index 0000000..8fd5673 --- /dev/null +++ b/lib/feature/game/data/data_source/remote/game_api_provider.dart @@ -0,0 +1,12 @@ +import 'package:dio/dio.dart'; + +import '../../../../../core/locator/locator.dart'; +import '../../../../../core/network/api_provider_imp.dart'; + +/// تماس‌های HTTP بازی: فهرست میزها و سهمیه‌ی میز خصوصی. +class GameApiProvider { + ApiProviderImp get _api => locator(); + + Future getShop() => _api.get('/shop'); + Future getTablesInfo() => _api.get('/tables/info'); +} diff --git a/lib/feature/game/data/data_source/remote/game_ws_provider.dart b/lib/feature/game/data/data_source/remote/game_ws_provider.dart new file mode 100644 index 0000000..650309e --- /dev/null +++ b/lib/feature/game/data/data_source/remote/game_ws_provider.dart @@ -0,0 +1,45 @@ +import 'dart:async'; + +import '../../../../../core/network/ws_client.dart'; +import '../../../../auth/data/data_source/local/auth_local_data.dart'; + +/// منبعِ realtime بازی: یک اتصال WebSocket را مدیریت کرده و پیام‌ها/وضعیت را +/// به‌صورت استریم در اختیار repository می‌گذارد. توکن از حافظه‌ی محلی خوانده می‌شود. +class GameWsProvider { + final AuthLocalData local; + GameWsProvider(this.local); + + WsClient? _ws; + StreamSubscription? _msgSub; + StreamSubscription? _statusSub; + + final _messages = StreamController>.broadcast(); + final _status = StreamController.broadcast(); + + Stream> get messages => _messages.stream; + Stream get status => _status.stream; + + Future connect() async { + await _teardown(); // اتصال قبلی (در صورت وجود) بسته شود + final token = await local.readToken(); + if (token == null || token.isEmpty) return; + final ws = WsClient(token); + _ws = ws; + _msgSub = ws.messages.listen(_messages.add); + _statusSub = ws.status.listen(_status.add); + ws.connect(); + } + + void send(Map msg) => _ws?.send(msg); + + Future _teardown() async { + await _msgSub?.cancel(); + await _statusSub?.cancel(); + _msgSub = null; + _statusSub = null; + _ws?.dispose(); + _ws = null; + } + + Future disconnect() => _teardown(); +} diff --git a/lib/feature/game/data/repository/game_repository_impl.dart b/lib/feature/game/data/repository/game_repository_impl.dart new file mode 100644 index 0000000..c3fed93 --- /dev/null +++ b/lib/feature/game/data/repository/game_repository_impl.dart @@ -0,0 +1,53 @@ +import 'package:dio/dio.dart'; + +import '../../../../core/error/custom_error.dart'; +import '../../../../core/network/ws_client.dart'; +import '../../../../core/resources/data_state.dart'; +import '../../domain/entities/table_entities.dart'; +import '../../domain/repository/game_repository.dart'; +import '../data_source/remote/game_api_provider.dart'; +import '../data_source/remote/game_ws_provider.dart'; + +class GameRepositoryImpl extends GameRepository { + final GameWsProvider ws; + final GameApiProvider api; + GameRepositoryImpl(this.ws, this.api); + + @override + Stream> get messages => ws.messages; + + @override + Stream get status => ws.status; + + @override + Future connect() => ws.connect(); + + @override + void send(Map msg) => ws.send(msg); + + @override + Future disconnect() => ws.disconnect(); + + @override + Future>> getTiers() async { + final Response res = await api.getShop(); + if (res.statusCode == 200) { + final cat = Map.from(res.data['catalog'] as Map); + final list = ((cat['table_tiers'] as List?) ?? []) + .map((e) => TableTier.fromJson(Map.from(e as Map))) + .toList(); + return DataSuccess(list); + } + return DataError(errorConvertor(res.statusCode, null)); + } + + @override + Future> getTablesInfo() async { + final Response res = await api.getTablesInfo(); + if (res.statusCode == 200) { + return DataSuccess( + TablesInfo.fromJson(Map.from(res.data as Map))); + } + return DataError(errorConvertor(res.statusCode, null)); + } +} diff --git a/lib/features/game/game_models.dart b/lib/feature/game/domain/entities/game_entities.dart similarity index 85% rename from lib/features/game/game_models.dart rename to lib/feature/game/domain/entities/game_entities.dart index 406f69a..a4c8bfe 100644 --- a/lib/features/game/game_models.dart +++ b/lib/feature/game/domain/entities/game_entities.dart @@ -1,4 +1,4 @@ -// مدل‌های وضعیت بازی (پیام‌های WebSocket سرور). +// موجودیت‌های وضعیت بازی (نگاشت از پیام‌های WebSocket سرور). class GamePlayer { final int seat; @@ -28,8 +28,8 @@ class GameState { final int yourSeat; final int hakem; final int turn; - final String? trump; // پس از انتخاب حکم - final bool trickDone; // دستِ کامل در حال نمایش (بازی ممنوع) + final String? trump; + final bool trickDone; final List yourHand; final List handCounts; final List trick; @@ -68,7 +68,8 @@ class GameState { turn: (j['turn'] ?? 0) as int, trump: j['trump'] as String?, trickDone: (j['trick_done'] ?? false) as bool, - yourHand: ((j['your_hand'] as List?) ?? []).map((e) => e as String).toList(), + yourHand: + ((j['your_hand'] as List?) ?? []).map((e) => e as String).toList(), handCounts: ints(j['hand_counts']), trick: ((j['trick'] as List?) ?? []) .map((e) => TrickCard.fromJson(Map.from(e as Map))) @@ -101,9 +102,8 @@ class HandResult { kot = (j['kot'] ?? false) as bool, hakemKot = (j['hakem_kot'] ?? false) as bool, points = (j['points'] ?? 0) as int, - scores = ((j['scores'] as List?) ?? []) - .map((e) => (e as num).toInt()) - .toList(); + scores = + ((j['scores'] as List?) ?? []).map((e) => (e as num).toInt()).toList(); } /// نتیجه‌ی پایان بازی (پیام type=game_over). @@ -112,12 +112,11 @@ class GameOver { final List scores; GameOver.fromJson(Map j) : winnerTeam = (j['winner_team'] ?? 0) as int, - scores = ((j['scores'] as List?) ?? []) - .map((e) => (e as num).toInt()) - .toList(); + scores = + ((j['scores'] as List?) ?? []).map((e) => (e as num).toInt()).toList(); } -extension _FirstOrNull on Iterable { +extension FirstOrNullExt on Iterable { E? get firstOrNull { final it = iterator; return it.moveNext() ? it.current : null; diff --git a/lib/feature/game/domain/entities/table_entities.dart b/lib/feature/game/domain/entities/table_entities.dart new file mode 100644 index 0000000..af96613 --- /dev/null +++ b/lib/feature/game/domain/entities/table_entities.dart @@ -0,0 +1,42 @@ +/// نوع میز (از catalog.table_tiers در GET /api/shop). +class TableTier { + final String id; + final String title; + final int hands; + final int entry; + final int prize; + final int xp; + final int trophy; + + const TableTier({ + required this.id, + required this.title, + required this.hands, + required this.entry, + required this.prize, + required this.xp, + required this.trophy, + }); + + factory TableTier.fromJson(Map j) => TableTier( + id: j['id'] as String, + title: j['title'] as String, + hands: (j['hands'] ?? 0) as int, + entry: (j['entry'] ?? 0) as int, + prize: (j['prize'] ?? 0) as int, + xp: (j['xp'] ?? 0) as int, + trophy: (j['trophy'] ?? 0) as int, + ); +} + +/// اطلاعات سهمیه‌ی میزهای خصوصی (GET /api/tables/info). +class TablesInfo { + final int remaining; + final bool unlimited; + const TablesInfo(this.remaining, this.unlimited); + + factory TablesInfo.fromJson(Map j) => TablesInfo( + (j['remaining'] ?? 0) as int, + (j['unlimited'] ?? false) as bool, + ); +} diff --git a/lib/feature/game/domain/repository/game_repository.dart b/lib/feature/game/domain/repository/game_repository.dart new file mode 100644 index 0000000..1d4add1 --- /dev/null +++ b/lib/feature/game/domain/repository/game_repository.dart @@ -0,0 +1,17 @@ +import '../../../../core/network/ws_client.dart'; +import '../../../../core/resources/data_state.dart'; +import '../entities/table_entities.dart'; + +/// قرارداد دادهٔ بازی: بخش realtime (سوکت) + بخش HTTP (میزها/سهمیه). +abstract class GameRepository { + // --- realtime --- + Stream> get messages; + Stream get status; + Future connect(); + void send(Map msg); + Future disconnect(); + + // --- HTTP --- + Future>> getTiers(); + Future> getTablesInfo(); +} diff --git a/lib/feature/game/domain/use_cases/get_tables_info_usecase.dart b/lib/feature/game/domain/use_cases/get_tables_info_usecase.dart new file mode 100644 index 0000000..c78170d --- /dev/null +++ b/lib/feature/game/domain/use_cases/get_tables_info_usecase.dart @@ -0,0 +1,14 @@ +import '../../../../core/resources/data_state.dart'; +import '../../../../core/usecase/use_case.dart'; +import '../entities/table_entities.dart'; +import '../repository/game_repository.dart'; + +class GetTablesInfoUseCase + implements UseCase, NoParams> { + final GameRepository repository; + GetTablesInfoUseCase(this.repository); + + @override + Future> call(NoParams params) => + repository.getTablesInfo(); +} diff --git a/lib/feature/game/domain/use_cases/get_tiers_usecase.dart b/lib/feature/game/domain/use_cases/get_tiers_usecase.dart new file mode 100644 index 0000000..01563da --- /dev/null +++ b/lib/feature/game/domain/use_cases/get_tiers_usecase.dart @@ -0,0 +1,14 @@ +import '../../../../core/resources/data_state.dart'; +import '../../../../core/usecase/use_case.dart'; +import '../entities/table_entities.dart'; +import '../repository/game_repository.dart'; + +class GetTiersUseCase + implements UseCase>, NoParams> { + final GameRepository repository; + GetTiersUseCase(this.repository); + + @override + Future>> call(NoParams params) => + repository.getTiers(); +} diff --git a/lib/feature/game/presentation/bloc/game_bloc.dart b/lib/feature/game/presentation/bloc/game_bloc.dart new file mode 100644 index 0000000..ea781f9 --- /dev/null +++ b/lib/feature/game/presentation/bloc/game_bloc.dart @@ -0,0 +1,96 @@ +import 'dart:async'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../core/network/ws_client.dart'; +import '../../domain/entities/game_entities.dart'; +import '../../domain/repository/game_repository.dart'; +import 'game_event.dart'; +import 'game_state.dart'; + +/// بلوکِ realtime بازی: به استریمِ پیام‌ها/وضعیتِ repository گوش می‌دهد و +/// اقدام‌های بازیکن را به سرور می‌فرستد. متدهای کمکی برای موتور Flame هم دارد. +class GameBloc extends Bloc { + final GameRepository repository; + late final StreamSubscription _msgSub; + late final StreamSubscription _statusSub; + + Map _joinAction = const {}; + bool _joined = false; + + GameBloc(this.repository) : super(const GameUiState()) { + _msgSub = + repository.messages.listen((m) => add(GameMessageReceived(m))); + _statusSub = + repository.status.listen((s) => add(GameStatusChanged(s))); + + on((event, emit) async { + _joinAction = event.joinAction; + _joined = false; + await repository.connect(); + }); + + on((event, emit) { + emit(state.copyWith(connection: event.status)); + if (event.status == WsStatus.connected && !_joined) { + _joined = true; + repository.send(_joinAction); + } + }); + + on((event, emit) => _onMessage(event.message, emit)); + + on( + (event, emit) => repository.send({'type': 'choose_trump', 'suit': event.suit})); + on( + (event, emit) => repository.send({'type': 'play_card', 'card': event.card})); + on((event, emit) => repository.send({'type': 'leave'})); + on((event, emit) => repository.send({'type': 'start_table'})); + on((event, emit) => repository.send({'type': 'leave_table'})); + on((event, emit) => emit(state.copyWith(clearNotice: true))); + } + + void _onMessage(Map msg, Emitter emit) { + switch (msg['type']) { + case 'state': + final gs = GameState.fromJson(msg); + final clear = gs.phase == 'choose_trump' || gs.phase == 'playing'; + emit(state.copyWith(state: gs, clearHandResult: clear)); + case 'hand_over': + emit(state.copyWith(handResult: HandResult.fromJson(msg))); + case 'game_over': + emit(state.copyWith(gameOver: GameOver.fromJson(msg))); + case 'table_lobby': + emit(state.copyWith(lobby: TableLobby.fromJson(msg))); + case 'countdown': + emit(state.copyWith(countdown: (msg['seconds'] ?? 3) as int)); + case 'table_closed': + emit(state.copyWith( + tableClosed: true, notice: 'میز توسط میزبان بسته شد')); + case 'player_disconnected': + emit(state.copyWith(notice: 'یک بازیکن قطع شد')); + case 'player_reconnected': + emit(state.copyWith(notice: 'بازیکن بازگشت')); + case 'player_left': + emit(state.copyWith(notice: 'یک بازیکن میز را ترک کرد')); + case 'error': + emit(state.copyWith(notice: (msg['message'] ?? 'خطا').toString())); + } + } + + // --- متدهای کمکی برای موتور Flame و صفحه‌ها --- + void chooseTrump(String suit) => add(ChooseTrumpEvent(suit)); + void playCard(String card) => add(PlayCardEvent(card)); + void leave() => add(LeaveGameEvent()); + void startTable() => add(StartTableEvent()); + void leaveTable() => add(LeaveTableEvent()); + void clearNotice() => add(ClearNoticeEvent()); + + @override + Future close() { + _msgSub.cancel(); + _statusSub.cancel(); + repository.disconnect(); + return super.close(); + } +} diff --git a/lib/feature/game/presentation/bloc/game_event.dart b/lib/feature/game/presentation/bloc/game_event.dart new file mode 100644 index 0000000..2f2e617 --- /dev/null +++ b/lib/feature/game/presentation/bloc/game_event.dart @@ -0,0 +1,39 @@ +import '../../../../core/network/ws_client.dart'; + +abstract class GameEvent {} + +/// شروع اتصال با اقدامِ ورود (join_queue / create_table / join_table). +class ConnectGameEvent extends GameEvent { + final Map joinAction; + ConnectGameEvent(this.joinAction); +} + +/// پیام دریافتی از سرور (داخلی). +class GameMessageReceived extends GameEvent { + final Map message; + GameMessageReceived(this.message); +} + +/// تغییر وضعیت اتصال (داخلی). +class GameStatusChanged extends GameEvent { + final WsStatus status; + GameStatusChanged(this.status); +} + +class ChooseTrumpEvent extends GameEvent { + final String suit; + ChooseTrumpEvent(this.suit); +} + +class PlayCardEvent extends GameEvent { + final String card; + PlayCardEvent(this.card); +} + +class LeaveGameEvent extends GameEvent {} + +class StartTableEvent extends GameEvent {} + +class LeaveTableEvent extends GameEvent {} + +class ClearNoticeEvent extends GameEvent {} diff --git a/lib/feature/game/presentation/bloc/game_state.dart b/lib/feature/game/presentation/bloc/game_state.dart new file mode 100644 index 0000000..c5a02b2 --- /dev/null +++ b/lib/feature/game/presentation/bloc/game_state.dart @@ -0,0 +1,98 @@ +import 'package:equatable/equatable.dart'; + +import '../../../../core/network/ws_client.dart'; +import '../../domain/entities/game_entities.dart'; + +/// یک بازیکن در اتاق انتظارِ میز خصوصی. +class LobbyPlayer { + final String name; + final bool host; + const LobbyPlayer(this.name, this.host); +} + +/// وضعیت اتاق انتظارِ میز خصوصی (دورهمی). +class TableLobby { + final String code; + final List players; + final bool isHost; + final int remaining; + final bool unlimited; + const TableLobby({ + required this.code, + required this.players, + required this.isHost, + required this.remaining, + required this.unlimited, + }); + + factory TableLobby.fromJson(Map j) => TableLobby( + code: (j['code'] ?? '') as String, + players: ((j['players'] as List?) ?? []) + .map((e) => LobbyPlayer( + (e['name'] ?? '') as String, (e['host'] ?? false) as bool)) + .toList(), + isHost: (j['host'] ?? false) as bool, + remaining: (j['remaining'] ?? 0) as int, + unlimited: (j['unlimited'] ?? false) as bool, + ); + + String get sig => '$code|$isHost|$remaining|$unlimited|' + '${players.map((p) => '${p.name}${p.host ? '*' : ''}').join(',')}'; +} + +class GameUiState extends Equatable { + final WsStatus connection; + final GameState? state; + final HandResult? handResult; + final GameOver? gameOver; + final String? notice; + final TableLobby? lobby; + final int? countdown; + final bool tableClosed; + + const GameUiState({ + this.connection = WsStatus.connecting, + this.state, + this.handResult, + this.gameOver, + this.notice, + this.lobby, + this.countdown, + this.tableClosed = false, + }); + + GameUiState copyWith({ + WsStatus? connection, + GameState? state, + HandResult? handResult, + GameOver? gameOver, + String? notice, + TableLobby? lobby, + int? countdown, + bool? tableClosed, + bool clearHandResult = false, + bool clearNotice = false, + }) => + GameUiState( + connection: connection ?? this.connection, + state: state ?? this.state, + handResult: clearHandResult ? null : (handResult ?? this.handResult), + gameOver: gameOver ?? this.gameOver, + notice: clearNotice ? null : (notice ?? this.notice), + lobby: lobby ?? this.lobby, + countdown: countdown ?? this.countdown, + tableClosed: tableClosed ?? this.tableClosed, + ); + + @override + List get props => [ + connection, + state, + handResult, + gameOver, + notice, + lobby?.sig, + countdown, + tableClosed, + ]; +} diff --git a/lib/feature/game/presentation/bloc/private_info_bloc.dart b/lib/feature/game/presentation/bloc/private_info_bloc.dart new file mode 100644 index 0000000..56fd9f1 --- /dev/null +++ b/lib/feature/game/presentation/bloc/private_info_bloc.dart @@ -0,0 +1,41 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../core/resources/data_state.dart'; +import '../../../../core/usecase/use_case.dart'; +import '../../domain/entities/table_entities.dart'; +import '../../domain/use_cases/get_tables_info_usecase.dart'; + +abstract class PrivateInfoEvent {} + +class LoadTablesInfoEvent extends PrivateInfoEvent {} + +abstract class PrivateInfoState {} + +class PrivateInfoInitial extends PrivateInfoState {} + +class PrivateInfoLoading extends PrivateInfoState {} + +class PrivateInfoLoaded extends PrivateInfoState { + final TablesInfo info; + PrivateInfoLoaded(this.info); +} + +class PrivateInfoError extends PrivateInfoState { + final String message; + PrivateInfoError(this.message); +} + +class PrivateInfoBloc extends Bloc { + final GetTablesInfoUseCase getTablesInfoUseCase; + PrivateInfoBloc(this.getTablesInfoUseCase) : super(PrivateInfoInitial()) { + on((event, emit) async { + emit(PrivateInfoLoading()); + final res = await getTablesInfoUseCase(const NoParams()); + if (res is DataSuccess) { + emit(PrivateInfoLoaded(res.data!)); + } else { + emit(PrivateInfoError(res.error!)); + } + }); + } +} diff --git a/lib/feature/game/presentation/bloc/tier_bloc.dart b/lib/feature/game/presentation/bloc/tier_bloc.dart new file mode 100644 index 0000000..2f2fcdc --- /dev/null +++ b/lib/feature/game/presentation/bloc/tier_bloc.dart @@ -0,0 +1,41 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../core/resources/data_state.dart'; +import '../../../../core/usecase/use_case.dart'; +import '../../domain/entities/table_entities.dart'; +import '../../domain/use_cases/get_tiers_usecase.dart'; + +abstract class TierEvent {} + +class LoadTiersEvent extends TierEvent {} + +abstract class TierState {} + +class TierInitial extends TierState {} + +class TierLoading extends TierState {} + +class TierLoaded extends TierState { + final List tiers; + TierLoaded(this.tiers); +} + +class TierError extends TierState { + final String message; + TierError(this.message); +} + +class TierBloc extends Bloc { + final GetTiersUseCase getTiersUseCase; + TierBloc(this.getTiersUseCase) : super(TierInitial()) { + on((event, emit) async { + emit(TierLoading()); + final res = await getTiersUseCase(const NoParams()); + if (res is DataSuccess) { + emit(TierLoaded(res.data!)); + } else { + emit(TierError(res.error!)); + } + }); + } +} diff --git a/lib/features/game/game_screen.dart b/lib/feature/game/presentation/screen/game_screen.dart similarity index 78% rename from lib/features/game/game_screen.dart rename to lib/feature/game/presentation/screen/game_screen.dart index 7e30f83..1e91c22 100644 --- a/lib/features/game/game_screen.dart +++ b/lib/feature/game/presentation/screen/game_screen.dart @@ -5,16 +5,18 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; -import '../../core/network/ws_client.dart'; -import '../../core/theme/app_theme.dart'; -import '../lobby/wallet_cubit.dart'; -import 'flame/hokm_game.dart'; -import 'game_cubit.dart'; -import 'game_models.dart'; +import '../../../../core/network/ws_client.dart'; +import '../../../../core/theme/app_theme.dart'; +import '../../../wallet/presentation/bloc/wallet_bloc.dart'; +import '../../../wallet/presentation/bloc/wallet_event.dart'; +import '../../domain/entities/game_entities.dart'; +import '../bloc/game_bloc.dart'; +import '../bloc/game_state.dart'; +import '../widgets/flame/hokm_game.dart'; -/// صفحه‌ی میز بازی: صحنه‌ی Flame + اوورلی‌های وضعیت (انتخاب حکم، نتیجه، پایان، اتصال). +/// صفحه‌ی میز بازی: صحنه‌ی Flame + اوورلی‌های وضعیت. class GameScreen extends StatefulWidget { - final int prize; // جایزه‌ی میز برای نمایش در دیالوگ جستجو + final int prize; const GameScreen({super.key, this.prize = 0}); @override @@ -29,7 +31,7 @@ class _GameScreenState extends State { @override void initState() { super.initState(); - _game = HokmGame(context.read()); + _game = HokmGame(context.read()); } @override @@ -38,47 +40,46 @@ class _GameScreenState extends State { super.dispose(); } - // دیالوگ جستجو تا یافتن حریفان و کمی پس از آن نمایش داده می‌شود. bool _showSearch(GameUiState s) => s.state == null || !_introHidden; @override Widget build(BuildContext context) { return PopScope( - // خروج با دکمه‌ی back سیستم هم باید با تأیید باشد. canPop: false, onPopInvokedWithResult: (didPop, _) { if (!didPop) _confirmLeave(context); }, child: Scaffold( - body: BlocConsumer( - listenWhen: (a, b) => a.notice != b.notice && b.notice != null, - listener: (context, state) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(state.notice!), duration: const Duration(seconds: 2)), - ); - context.read().clearNotice(); - }, - builder: (context, state) { - // پس از یافتن حریفان، دیالوگ جستجو را کمی نگه می‌داریم بعد مخفی می‌کنیم. - if (state.state != null && _introTimer == null) { - _introTimer = Timer(const Duration(milliseconds: 1600), () { - if (mounted) setState(() => _introHidden = true); - }); - } - return Stack( - children: [ - GameWidget(game: _game), - _backButton(context), - if (state.connection == WsStatus.disconnected) _connBanner(), - if (_showSearch(state)) _searchPanel(state), - if (!_showSearch(state) && _showTrumpPicker(state)) - _trumpPicker(context), - if (state.handResult != null && state.gameOver == null) - _handResult(state), - if (state.gameOver != null) _gameOver(context, state), - ], - ); - }, + body: BlocConsumer( + listenWhen: (a, b) => a.notice != b.notice && b.notice != null, + listener: (context, state) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.notice!), + duration: const Duration(seconds: 2)), + ); + context.read().clearNotice(); + }, + builder: (context, state) { + if (state.state != null && _introTimer == null) { + _introTimer = Timer(const Duration(milliseconds: 1600), () { + if (mounted) setState(() => _introHidden = true); + }); + } + return Stack( + children: [ + GameWidget(game: _game), + _backButton(context), + if (state.connection == WsStatus.disconnected) _connBanner(), + if (_showSearch(state)) _searchPanel(state), + if (!_showSearch(state) && _showTrumpPicker(state)) + _trumpPicker(context), + if (state.handResult != null && state.gameOver == null) + _handResult(state), + if (state.gameOver != null) _gameOver(context, state), + ], + ); + }, ), ), ); @@ -102,8 +103,7 @@ class _GameScreenState extends State { ); Future _confirmLeave(BuildContext context) async { - // اگر بازی تمام شده، بدون تأیید خارج شو. - if (context.read().state.gameOver != null) { + if (context.read().state.gameOver != null) { _exitToLobby(context); return; } @@ -124,13 +124,13 @@ class _GameScreenState extends State { ), ); if (yes == true && context.mounted) { - context.read().leave(); + context.read().leave(); _exitToLobby(context); } } void _exitToLobby(BuildContext context) { - context.read().load(); + context.read().add(LoadWalletEvent()); context.go('/lobby'); } @@ -152,7 +152,6 @@ class _GameScreenState extends State { ), ); - // دیالوگ «جستجوی حریف» مطابق اپ مرجع: ۴ جایگاه بازیکن + جایزه. Widget _searchPanel(GameUiState state) { final players = state.state?.players ?? const []; final mySeat = state.state?.yourSeat ?? -1; @@ -245,7 +244,9 @@ class _GameScreenState extends State { color: isYou ? AppColors.gold : AppColors.goldDark, width: 1.5), ), child: Icon( - found ? (p.bot ? Icons.smart_toy : Icons.person) : Icons.help_outline, + found + ? (p.bot ? Icons.smart_toy : Icons.person) + : Icons.help_outline, color: found ? AppColors.gold : Colors.white24, size: 30, ), @@ -292,8 +293,7 @@ class _GameScreenState extends State { backgroundColor: AppColors.bgDark, minimumSize: const Size(120, 64), ), - onPressed: () => - context.read().chooseTrump(id), + onPressed: () => context.read().chooseTrump(id), child: Row(mainAxisSize: MainAxisSize.min, children: [ Text(sym, style: TextStyle(fontSize: 26, color: color)), const SizedBox(width: 8), @@ -332,11 +332,14 @@ class _GameScreenState extends State { Padding( padding: const EdgeInsets.only(top: 6), child: Text( - r.hakemKot ? 'حاکم‌کُت! (${r.points} امتیاز)' : 'کُت! (${r.points} امتیاز)', + r.hakemKot + ? 'حاکم‌کُت! (${r.points} امتیاز)' + : 'کُت! (${r.points} امتیاز)', style: const TextStyle(color: AppColors.gold)), ), const SizedBox(height: 6), - Text('امتیاز: ${r.scores.isNotEmpty ? r.scores[0] : 0} - ${r.scores.length > 1 ? r.scores[1] : 0}', + Text( + 'امتیاز: ${r.scores.isNotEmpty ? r.scores[0] : 0} - ${r.scores.length > 1 ? r.scores[1] : 0}', style: const TextStyle(color: Colors.white70)), ]), ), @@ -358,7 +361,8 @@ class _GameScreenState extends State { fontSize: 32, fontWeight: FontWeight.bold)), const SizedBox(height: 12), - Text('نتیجه نهایی: ${g.scores.isNotEmpty ? g.scores[0] : 0} - ${g.scores.length > 1 ? g.scores[1] : 0}', + Text( + 'نتیجه نهایی: ${g.scores.isNotEmpty ? g.scores[0] : 0} - ${g.scores.length > 1 ? g.scores[1] : 0}', style: const TextStyle(color: Colors.white70, fontSize: 18)), const SizedBox(height: 28), SizedBox( diff --git a/lib/feature/game/presentation/screen/private_entry_screen.dart b/lib/feature/game/presentation/screen/private_entry_screen.dart new file mode 100644 index 0000000..2e29a8d --- /dev/null +++ b/lib/feature/game/presentation/screen/private_entry_screen.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../../core/theme/app_theme.dart'; +import '../../../../core/widgets/game_ui.dart'; +import '../bloc/private_info_bloc.dart'; + +/// صفحه‌ی دورهمی: پیوستن با شماره میز یا ساختِ میز جدید. +class PrivateEntryScreen extends StatefulWidget { + const PrivateEntryScreen({super.key}); + + @override + State createState() => _PrivateEntryScreenState(); +} + +class _PrivateEntryScreenState extends State { + final _code = TextEditingController(); + + @override + void initState() { + super.initState(); + context.read().add(LoadTablesInfoEvent()); + } + + @override + void dispose() { + _code.dispose(); + super.dispose(); + } + + void _join() { + final code = _code.text.trim(); + if (code.length < 4) return; + context.push('/private/room?join=$code'); + } + + void _create(bool canCreate) { + if (!canCreate) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: + Text('سهمیه‌ی میزهای رایگان تمام شده؛ با VIP نامحدود بسازید'))); + return; + } + context.push('/private/room?create=1').then((_) { + if (mounted) { + context.read().add(LoadTablesInfoEvent()); + } + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: GameBackground( + child: SafeArea( + child: BlocBuilder( + builder: (context, state) { + final loading = state is! PrivateInfoLoaded; + final unlimited = + state is PrivateInfoLoaded && state.info.unlimited; + final remaining = + state is PrivateInfoLoaded ? state.info.remaining : 0; + final canCreate = unlimited || remaining > 0; + return Column( + children: [ + Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.all(10), + child: GestureDetector( + onTap: () => context.pop(), + child: Container( + width: 46, + height: 46, + decoration: BoxDecoration( + color: AppColors.panel, + borderRadius: BorderRadius.circular(12), + border: + Border.all(color: AppColors.gold, width: 1.5), + ), + child: const Icon(Icons.arrow_back, + color: AppColors.gold), + ), + ), + ), + ), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + children: [ + const SizedBox(height: 8), + const Icon(Icons.person, + color: AppColors.gold, size: 56), + const SizedBox(height: 12), + TextField( + controller: _code, + textAlign: TextAlign.center, + keyboardType: TextInputType.number, + style: const TextStyle( + fontSize: 22, letterSpacing: 6), + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(5), + ], + decoration: + const InputDecoration(hintText: 'شماره میز'), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 16), + GameButton( + label: 'پیوستن', + width: double.infinity, + colors: const [Color(0xFFC2185B), Color(0xFF6A0D38)], + onTap: _code.text.trim().length >= 4 ? _join : null, + ), + const SizedBox(height: 8), + const Text('برای ورود، شماره میز را وارد کنید.', + style: TextStyle( + color: Colors.white60, fontSize: 13)), + const SizedBox(height: 24), + Divider( + color: AppColors.goldDark.withValues(alpha: 0.5)), + const SizedBox(height: 16), + Text( + loading + ? '...' + : unlimited + ? 'میزهای نامحدود (VIP)' + : 'میزهای رایگان باقیمانده: $remaining', + style: const TextStyle( + color: AppColors.gold, + fontSize: 14, + fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + const Icon(Icons.groups, + color: AppColors.gold, size: 56), + const SizedBox(height: 12), + GameButton( + label: 'ساخت میز', + width: double.infinity, + colors: canCreate + ? const [Color(0xFFC2185B), Color(0xFF6A0D38)] + : const [Color(0xFF555555), Color(0xFF333333)], + onTap: loading ? null : () => _create(canCreate), + ), + const SizedBox(height: 8), + const Text('میز جدید بساز و دوستانت را دعوت کن', + style: TextStyle( + color: Colors.white60, fontSize: 13)), + const SizedBox(height: 24), + ], + ), + ), + ), + ], + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/features/private/private_table_screen.dart b/lib/feature/game/presentation/screen/private_table_screen.dart similarity index 60% rename from lib/features/private/private_table_screen.dart rename to lib/feature/game/presentation/screen/private_table_screen.dart index c318e15..3972a99 100644 --- a/lib/features/private/private_table_screen.dart +++ b/lib/feature/game/presentation/screen/private_table_screen.dart @@ -5,42 +5,20 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; -import '../../core/network/ws_client.dart'; -import '../../core/theme/app_theme.dart'; -import '../../core/widgets/game_ui.dart'; -import '../game/game_cubit.dart'; -import '../game/game_screen.dart'; +import '../../../../core/network/ws_client.dart'; +import '../../../../core/theme/app_theme.dart'; +import '../../../../core/widgets/game_ui.dart'; +import '../bloc/game_bloc.dart'; +import '../bloc/game_state.dart'; +import 'game_screen.dart'; -/// میز خصوصی: اتاق انتظار (نمایش کد، بازیکنان، شروع) و سپس صحنه‌ی بازی. -/// از همان اتصال WebSocket برای لابی و بازی استفاده می‌شود (بدون اتصال مجدد). +/// میز خصوصی: اتاق انتظار (کد، بازیکنان، شروع) سپس صحنه‌ی بازی (روی همان اتصال). class PrivateTableScreen extends StatelessWidget { - final String token; - final bool create; - final String? joinCode; - const PrivateTableScreen({ - super.key, - required this.token, - required this.create, - this.joinCode, - }); + const PrivateTableScreen({super.key}); @override Widget build(BuildContext context) { - return BlocProvider( - create: (_) => create - ? GameCubit.createPrivate(WsClient(token)) - : GameCubit.joinPrivate(WsClient(token), joinCode ?? ''), - child: const _PrivateTableView(), - ); - } -} - -class _PrivateTableView extends StatelessWidget { - const _PrivateTableView(); - - @override - Widget build(BuildContext context) { - return BlocConsumer( + return BlocConsumer( listenWhen: (a, b) => (a.notice != b.notice && b.notice != null) || (!a.tableClosed && b.tableClosed), @@ -50,14 +28,13 @@ class _PrivateTableView extends StatelessWidget { return; } if (state.notice != null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(state.notice!), duration: const Duration(seconds: 2)), - ); - context.read().clearNotice(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(state.notice!), + duration: const Duration(seconds: 2))); + context.read().clearNotice(); } }, builder: (context, state) { - // بازی شروع شده ⇒ همان صحنه‌ی بازی روی همین اتصال. if (state.state != null) { return const GameScreen(prize: 0); } @@ -80,7 +57,7 @@ class _LobbyView extends StatelessWidget { canPop: false, onPopInvokedWithResult: (didPop, _) { if (didPop) return; - context.read().leaveTable(); + context.read().leaveTable(); if (context.canPop()) context.pop(); }, child: Scaffold( @@ -96,7 +73,7 @@ class _LobbyView extends StatelessWidget { padding: const EdgeInsets.all(10), child: GestureDetector( onTap: () { - context.read().leaveTable(); + context.read().leaveTable(); if (context.canPop()) context.pop(); }, child: Container( @@ -140,48 +117,35 @@ class _LobbyView extends StatelessWidget { children: [ const GlowText('میز دورهمی', size: 26), const SizedBox(height: 16), - // کد میز برای اشتراک‌گذاری GamePanel( - child: Column( - children: [ - const Text('شماره میز', - style: TextStyle(color: Colors.white70)), - const SizedBox(height: 6), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SelectableText( - lobby.code, - style: const TextStyle( - color: AppColors.gold, - fontSize: 40, - fontWeight: FontWeight.bold, - letterSpacing: 8), - ), - IconButton( - onPressed: () { - Clipboard.setData(ClipboardData(text: lobby.code)); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('کد کپی شد')), - ); - }, - icon: const Icon(Icons.copy, color: AppColors.gold), - ), - ], + child: Column(children: [ + const Text('شماره میز', style: TextStyle(color: Colors.white70)), + const SizedBox(height: 6), + Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + SelectableText(lobby.code, + style: const TextStyle( + color: AppColors.gold, + fontSize: 40, + fontWeight: FontWeight.bold, + letterSpacing: 8)), + IconButton( + onPressed: () { + Clipboard.setData(ClipboardData(text: lobby.code)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('کد کپی شد'))); + }, + icon: const Icon(Icons.copy, color: AppColors.gold), ), - const Text('این کد را برای دوستانت بفرست', - style: TextStyle(color: Colors.white54, fontSize: 12)), - ], - ), + ]), + const Text('این کد را برای دوستانت بفرست', + style: TextStyle(color: Colors.white54, fontSize: 12)), + ]), ), const SizedBox(height: 16), - // فهرست بازیکنان (۴ جایگاه) GamePanel( - child: Column( - children: [ - for (var i = 0; i < 4; i++) _seatRow(i, lobby), - ], - ), + child: Column(children: [ + for (var i = 0; i < 4; i++) _seatRow(i, lobby), + ]), ), const SizedBox(height: 20), if (lobby.isHost) @@ -190,7 +154,7 @@ class _LobbyView extends StatelessWidget { icon: Icons.play_arrow, width: double.infinity, colors: const [Color(0xFF3FA34D), Color(0xFF1B5E20)], - onTap: () => context.read().startTable(), + onTap: () => context.read().startTable(), ) else const Text('در انتظار شروع توسط میزبان…', @@ -210,23 +174,21 @@ class _LobbyView extends StatelessWidget { final p = filled ? lobby.players[i] : null; return Padding( padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - children: [ - Icon(filled ? Icons.person : Icons.person_outline, - color: filled ? AppColors.gold : Colors.white24, size: 24), - const SizedBox(width: 10), - Text( - filled ? p!.name : 'در انتظار بازیکن…', - style: TextStyle( - color: filled ? Colors.white : Colors.white38, - fontSize: 15, - fontWeight: filled ? FontWeight.bold : FontWeight.normal), - ), - const Spacer(), - if (p?.host == true) - const Icon(Icons.star, color: AppColors.gold, size: 18), - ], - ), + child: Row(children: [ + Icon(filled ? Icons.person : Icons.person_outline, + color: filled ? AppColors.gold : Colors.white24, size: 24), + const SizedBox(width: 10), + Text( + filled ? p!.name : 'در انتظار بازیکن…', + style: TextStyle( + color: filled ? Colors.white : Colors.white38, + fontSize: 15, + fontWeight: filled ? FontWeight.bold : FontWeight.normal), + ), + const Spacer(), + if (p?.host == true) + const Icon(Icons.star, color: AppColors.gold, size: 18), + ]), ); } } diff --git a/lib/features/game/tier_list_screen.dart b/lib/feature/game/presentation/screen/tier_list_screen.dart similarity index 76% rename from lib/features/game/tier_list_screen.dart rename to lib/feature/game/presentation/screen/tier_list_screen.dart index c0a0686..d6e624f 100644 --- a/lib/features/game/tier_list_screen.dart +++ b/lib/feature/game/presentation/screen/tier_list_screen.dart @@ -1,28 +1,15 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; -import '../../core/theme/app_theme.dart'; -import '../../core/widgets/game_ui.dart'; -import 'game_repository.dart'; -import 'tier.dart'; +import '../../../../core/theme/app_theme.dart'; +import '../../../../core/widgets/game_ui.dart'; +import '../../domain/entities/table_entities.dart'; +import '../bloc/tier_bloc.dart'; /// لیست میزها (tierها) با ورودی/جایزه؛ انتخاب → ورود به میز. -class TierListScreen extends StatefulWidget { - final GameRepository repo; - const TierListScreen({super.key, required this.repo}); - - @override - State createState() => _TierListScreenState(); -} - -class _TierListScreenState extends State { - late Future> _future; - - @override - void initState() { - super.initState(); - _future = widget.repo.getTiers(); - } +class TierListScreen extends StatelessWidget { + const TierListScreen({super.key}); @override Widget build(BuildContext context) { @@ -41,26 +28,26 @@ class _TierListScreenState extends State { ]), ), Expanded( - child: FutureBuilder>( - future: _future, - builder: (context, snap) { - if (snap.connectionState != ConnectionState.done) { - return const Center( - child: CircularProgressIndicator(color: AppColors.gold)); - } - if (snap.hasError || snap.data == null) { + child: BlocBuilder( + builder: (context, state) { + if (state is TierError) { return Center( child: Column(mainAxisSize: MainAxisSize.min, children: [ - const Text('خطا در بارگذاری میزها'), + Text(state.message), TextButton( onPressed: () => - setState(() => _future = widget.repo.getTiers()), + context.read().add(LoadTiersEvent()), child: const Text('تلاش مجدد'), ), ]), ); } - final tiers = snap.data!; + if (state is! TierLoaded) { + return const Center( + child: + CircularProgressIndicator(color: AppColors.gold)); + } + final tiers = state.tiers; return ListView.separated( padding: const EdgeInsets.fromLTRB(16, 4, 16, 20), itemCount: tiers.length, @@ -101,12 +88,11 @@ class _TierCard extends StatelessWidget { final int index; const _TierCard({required this.tier, required this.index}); - // پالتِ رنگیِ هر میز (مطابق اپ مرجع). static const _palettes = [ - [Color(0xFF43A047), Color(0xFF1B5E20)], // سبز - [Color(0xFFE53935), Color(0xFF8E0E1B)], // قرمز - [Color(0xFF1E88E5), Color(0xFF0D3C73)], // آبی - [Color(0xFF8E24AA), Color(0xFF4A0D5E)], // بنفش + [Color(0xFF43A047), Color(0xFF1B5E20)], + [Color(0xFFE53935), Color(0xFF8E0E1B)], + [Color(0xFF1E88E5), Color(0xFF0D3C73)], + [Color(0xFF8E24AA), Color(0xFF4A0D5E)], ]; @override @@ -125,12 +111,12 @@ class _TierCard extends StatelessWidget { borderRadius: BorderRadius.circular(16), border: Border.all(color: AppColors.gold, width: 2), boxShadow: const [ - BoxShadow(color: Colors.black54, blurRadius: 10, offset: Offset(0, 5)), + BoxShadow( + color: Colors.black54, blurRadius: 10, offset: Offset(0, 5)), ], ), child: Row( children: [ - // ریبونِ تعداد دست Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( @@ -191,8 +177,7 @@ class _TierCard extends StatelessWidget { children: [ Icon(icon, size: 16, color: AppColors.gold), const SizedBox(width: 4), - Text(text, - style: const TextStyle(color: Colors.white, fontSize: 12)), + Text(text, style: const TextStyle(color: Colors.white, fontSize: 12)), ], ); } diff --git a/lib/features/game/flame/card_codes.dart b/lib/feature/game/presentation/widgets/flame/card_codes.dart similarity index 100% rename from lib/features/game/flame/card_codes.dart rename to lib/feature/game/presentation/widgets/flame/card_codes.dart diff --git a/lib/features/game/flame/card_component.dart b/lib/feature/game/presentation/widgets/flame/card_component.dart similarity index 100% rename from lib/features/game/flame/card_component.dart rename to lib/feature/game/presentation/widgets/flame/card_component.dart diff --git a/lib/features/game/flame/hokm_game.dart b/lib/feature/game/presentation/widgets/flame/hokm_game.dart similarity index 99% rename from lib/features/game/flame/hokm_game.dart rename to lib/feature/game/presentation/widgets/flame/hokm_game.dart index a8fa75d..e462c4f 100644 --- a/lib/features/game/flame/hokm_game.dart +++ b/lib/feature/game/presentation/widgets/flame/hokm_game.dart @@ -7,8 +7,8 @@ import 'package:flame/game.dart'; import 'package:flame_audio/flame_audio.dart'; import 'package:flutter/material.dart'; -import '../game_cubit.dart'; -import '../game_models.dart'; +import '../../../domain/entities/game_entities.dart'; +import '../../bloc/game_bloc.dart'; import 'card_codes.dart'; import 'card_component.dart'; import 'table_pieces.dart'; @@ -21,7 +21,7 @@ import 'table_pieces.dart'; /// - [_rebuildBacksAndInfo] عناصرِ بازساخته‌شونده (پشت‌کارت، شمارنده‌ها، برچسب‌ها). /// - [_checkCut] + [update]/[render] افکتِ «بریدن با حکم» (تکان + رعد). class HokmGame extends FlameGame { - final GameCubit cubit; + final GameBloc cubit; // وضعیت بازی و اشتراکِ stream. GameState? _s; diff --git a/lib/features/game/flame/table_pieces.dart b/lib/feature/game/presentation/widgets/flame/table_pieces.dart similarity index 100% rename from lib/features/game/flame/table_pieces.dart rename to lib/feature/game/presentation/widgets/flame/table_pieces.dart diff --git a/lib/feature/profile/data/data_source/remote/profile_api_provider.dart b/lib/feature/profile/data/data_source/remote/profile_api_provider.dart new file mode 100644 index 0000000..7393f41 --- /dev/null +++ b/lib/feature/profile/data/data_source/remote/profile_api_provider.dart @@ -0,0 +1,15 @@ +import 'package:dio/dio.dart'; + +import '../../../../../core/locator/locator.dart'; +import '../../../../../core/network/api_provider_imp.dart'; + +class ProfileApiProvider { + ApiProviderImp get _api => locator(); + + Future getMe() => _api.get('/me'); + Future getWallet() => _api.get('/wallet'); + Future getStats() => _api.get('/stats'); + + Future updateProfile(String firstName, String avatar) => + _api.post('/profile', body: {'first_name': firstName, 'avatar': avatar}); +} diff --git a/lib/feature/profile/data/model/profile_model.dart b/lib/feature/profile/data/model/profile_model.dart new file mode 100644 index 0000000..1ee93f2 --- /dev/null +++ b/lib/feature/profile/data/model/profile_model.dart @@ -0,0 +1,35 @@ +import '../../domain/entities/profile_entity.dart'; + +/// نگاشتِ پاسخ‌های /me، /wallet و /stats به ProfileEntity. +class ProfileModel { + static ProfileEntity fromJson( + Map user, + Map wallet, + Map stats, + ) { + final name = (user['first_name'] as String?)?.trim(); + final avatar = (user['avatar'] as String?)?.trim(); + final s = stats['stats'] as Map?; + return ProfileEntity( + name: (name == null || name.isEmpty) ? 'بازیکن' : name, + avatar: (avatar == null || avatar.isEmpty) ? 'hakem-1' : avatar, + mobile: (user['mobile'] as String?) ?? '', + level: (wallet['level'] ?? 1) as int, + trophies: (wallet['trophies'] ?? 0) as int, + xpInto: (wallet['xp_into_level'] ?? 0) as int, + xpNext: (wallet['xp_for_next'] ?? 1) as int, + vip: (stats['vip'] ?? false) as bool, + stats: s == null + ? null + : ProfileStats( + games: (s['games'] ?? 0) as int, + wins: (s['wins'] ?? 0) as int, + losses: (s['losses'] ?? 0) as int, + kotMade: (s['kot_made'] ?? 0) as int, + kotReceived: (s['kot_received'] ?? 0) as int, + cuts: (s['cuts'] ?? 0) as int, + hakemCount: (s['hakem_count'] ?? 0) as int, + ), + ); + } +} diff --git a/lib/feature/profile/data/repository/profile_repository_impl.dart b/lib/feature/profile/data/repository/profile_repository_impl.dart new file mode 100644 index 0000000..a6b87c2 --- /dev/null +++ b/lib/feature/profile/data/repository/profile_repository_impl.dart @@ -0,0 +1,42 @@ +import 'package:dio/dio.dart'; + +import '../../../../core/error/custom_error.dart'; +import '../../../../core/resources/data_state.dart'; +import '../../../../core/usecase/use_case.dart'; +import '../../domain/entities/profile_entity.dart'; +import '../../domain/repository/profile_repository.dart'; +import '../data_source/remote/profile_api_provider.dart'; +import '../model/profile_model.dart'; + +class ProfileRepositoryImpl extends ProfileRepository { + final ProfileApiProvider api; + ProfileRepositoryImpl(this.api); + + @override + Future> getProfile() async { + final results = + await Future.wait([api.getMe(), api.getWallet(), api.getStats()]); + final Response me = results[0]; + final Response wallet = results[1]; + final Response stats = results[2]; + if (me.statusCode == 200 && + wallet.statusCode == 200 && + stats.statusCode == 200) { + return DataSuccess(ProfileModel.fromJson( + Map.from((me.data['user'] ?? {}) as Map), + Map.from(wallet.data as Map), + Map.from(stats.data as Map), + )); + } + return DataError(errorConvertor(me.statusCode, null)); + } + + @override + Future> updateProfile(ProfileParams params) async { + final Response res = await api.updateProfile(params.firstName, params.avatar); + if (res.statusCode == 200) return const DataSuccess('ok'); + final d = res.data; + final msg = (d is Map && d['message'] != null) ? d['message'].toString() : null; + return DataError(errorConvertor(res.statusCode, msg)); + } +} diff --git a/lib/feature/profile/domain/entities/profile_entity.dart b/lib/feature/profile/domain/entities/profile_entity.dart new file mode 100644 index 0000000..510064e --- /dev/null +++ b/lib/feature/profile/domain/entities/profile_entity.dart @@ -0,0 +1,44 @@ +/// آمار بازیِ کاربر (در صورت قفل بودن، null است). +class ProfileStats { + final int games; + final int wins; + final int losses; + final int kotMade; + final int kotReceived; + final int cuts; + final int hakemCount; + const ProfileStats({ + required this.games, + required this.wins, + required this.losses, + required this.kotMade, + required this.kotReceived, + required this.cuts, + required this.hakemCount, + }); +} + +/// موجودیتِ کاملِ پروفایل (نام/آواتار + خلاصه‌ی اقتصادی + آمار). +class ProfileEntity { + final String name; + final String avatar; + final String mobile; + final int level; + final int trophies; + final int xpInto; + final int xpNext; + final bool vip; + final ProfileStats? stats; // null یعنی قفل (غیر VIP) + + const ProfileEntity({ + required this.name, + required this.avatar, + required this.mobile, + required this.level, + required this.trophies, + required this.xpInto, + required this.xpNext, + required this.vip, + required this.stats, + }); +} diff --git a/lib/feature/profile/domain/repository/profile_repository.dart b/lib/feature/profile/domain/repository/profile_repository.dart new file mode 100644 index 0000000..44da013 --- /dev/null +++ b/lib/feature/profile/domain/repository/profile_repository.dart @@ -0,0 +1,8 @@ +import '../../../../core/resources/data_state.dart'; +import '../../../../core/usecase/use_case.dart'; +import '../entities/profile_entity.dart'; + +abstract class ProfileRepository { + Future> getProfile(); + Future> updateProfile(ProfileParams params); +} diff --git a/lib/feature/profile/domain/use_cases/get_profile_usecase.dart b/lib/feature/profile/domain/use_cases/get_profile_usecase.dart new file mode 100644 index 0000000..186dd21 --- /dev/null +++ b/lib/feature/profile/domain/use_cases/get_profile_usecase.dart @@ -0,0 +1,13 @@ +import '../../../../core/resources/data_state.dart'; +import '../../../../core/usecase/use_case.dart'; +import '../entities/profile_entity.dart'; +import '../repository/profile_repository.dart'; + +class GetProfileUseCase implements UseCase, NoParams> { + final ProfileRepository repository; + GetProfileUseCase(this.repository); + + @override + Future> call(NoParams params) => + repository.getProfile(); +} diff --git a/lib/feature/profile/domain/use_cases/save_profile_usecase.dart b/lib/feature/profile/domain/use_cases/save_profile_usecase.dart new file mode 100644 index 0000000..270c5bb --- /dev/null +++ b/lib/feature/profile/domain/use_cases/save_profile_usecase.dart @@ -0,0 +1,12 @@ +import '../../../../core/resources/data_state.dart'; +import '../../../../core/usecase/use_case.dart'; +import '../repository/profile_repository.dart'; + +class SaveProfileUseCase implements UseCase, ProfileParams> { + final ProfileRepository repository; + SaveProfileUseCase(this.repository); + + @override + Future> call(ProfileParams params) => + repository.updateProfile(params); +} diff --git a/lib/feature/profile/presentation/bloc/profile_bloc.dart b/lib/feature/profile/presentation/bloc/profile_bloc.dart new file mode 100644 index 0000000..c361013 --- /dev/null +++ b/lib/feature/profile/presentation/bloc/profile_bloc.dart @@ -0,0 +1,41 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../core/resources/data_state.dart'; +import '../../../../core/usecase/use_case.dart'; +import '../../domain/use_cases/get_profile_usecase.dart'; +import '../../domain/use_cases/save_profile_usecase.dart'; +import 'profile_event.dart'; +import 'profile_state.dart'; +import 'profile_status.dart'; + +class ProfileBloc extends Bloc { + final GetProfileUseCase getProfileUseCase; + final SaveProfileUseCase saveProfileUseCase; + + ProfileBloc(this.getProfileUseCase, this.saveProfileUseCase) + : super(ProfileBlocState.initial()) { + on((event, emit) => _load(emit)); + + on((event, emit) async { + emit(state.copyWith(saveStatus: ProfileSaveLoading())); + final res = await saveProfileUseCase( + ProfileParams(event.firstName, event.avatar)); + if (res is DataSuccess) { + emit(state.copyWith(saveStatus: ProfileSaveSuccess())); + await _load(emit); + } else { + emit(state.copyWith(saveStatus: ProfileSaveError(res.error!))); + } + }); + } + + Future _load(Emitter emit) async { + emit(state.copyWith(loadStatus: ProfileLoadLoading())); + final res = await getProfileUseCase(const NoParams()); + if (res is DataSuccess) { + emit(state.copyWith(loadStatus: ProfileLoadLoaded(res.data!))); + } else { + emit(state.copyWith(loadStatus: ProfileLoadError(res.error!))); + } + } +} diff --git a/lib/feature/profile/presentation/bloc/profile_event.dart b/lib/feature/profile/presentation/bloc/profile_event.dart new file mode 100644 index 0000000..57f3242 --- /dev/null +++ b/lib/feature/profile/presentation/bloc/profile_event.dart @@ -0,0 +1,9 @@ +abstract class ProfileEvent {} + +class LoadProfileEvent extends ProfileEvent {} + +class SaveProfileEvent extends ProfileEvent { + final String firstName; + final String avatar; + SaveProfileEvent(this.firstName, this.avatar); +} diff --git a/lib/feature/profile/presentation/bloc/profile_state.dart b/lib/feature/profile/presentation/bloc/profile_state.dart new file mode 100644 index 0000000..f8771ef --- /dev/null +++ b/lib/feature/profile/presentation/bloc/profile_state.dart @@ -0,0 +1,22 @@ +import 'profile_status.dart'; + +class ProfileBlocState { + final ProfileLoadStatus loadStatus; + final ProfileSaveStatus saveStatus; + + ProfileBlocState({required this.loadStatus, required this.saveStatus}); + + factory ProfileBlocState.initial() => ProfileBlocState( + loadStatus: ProfileLoadInitial(), + saveStatus: ProfileSaveIdle(), + ); + + ProfileBlocState copyWith({ + ProfileLoadStatus? loadStatus, + ProfileSaveStatus? saveStatus, + }) => + ProfileBlocState( + loadStatus: loadStatus ?? this.loadStatus, + saveStatus: saveStatus ?? this.saveStatus, + ); +} diff --git a/lib/feature/profile/presentation/bloc/profile_status.dart b/lib/feature/profile/presentation/bloc/profile_status.dart new file mode 100644 index 0000000..82eebc3 --- /dev/null +++ b/lib/feature/profile/presentation/bloc/profile_status.dart @@ -0,0 +1,31 @@ +import '../../domain/entities/profile_entity.dart'; + +abstract class ProfileLoadStatus {} + +class ProfileLoadInitial extends ProfileLoadStatus {} + +class ProfileLoadLoading extends ProfileLoadStatus {} + +class ProfileLoadLoaded extends ProfileLoadStatus { + final ProfileEntity profile; + ProfileLoadLoaded(this.profile); +} + +class ProfileLoadError extends ProfileLoadStatus { + final String message; + ProfileLoadError(this.message); +} + +/// وضعیتِ ذخیره‌ی ویرایش پروفایل. +abstract class ProfileSaveStatus {} + +class ProfileSaveIdle extends ProfileSaveStatus {} + +class ProfileSaveLoading extends ProfileSaveStatus {} + +class ProfileSaveSuccess extends ProfileSaveStatus {} + +class ProfileSaveError extends ProfileSaveStatus { + final String message; + ProfileSaveError(this.message); +} diff --git a/lib/feature/profile/presentation/screen/profile_screen.dart b/lib/feature/profile/presentation/screen/profile_screen.dart new file mode 100644 index 0000000..b165b6c --- /dev/null +++ b/lib/feature/profile/presentation/screen/profile_screen.dart @@ -0,0 +1,400 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show LengthLimitingTextInputFormatter; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:random_avatar/random_avatar.dart'; + +import '../../../../core/theme/app_theme.dart'; +import '../../../../core/widgets/game_ui.dart'; +import '../../../wallet/presentation/bloc/wallet_bloc.dart'; +import '../../../wallet/presentation/bloc/wallet_event.dart'; +import '../../domain/entities/profile_entity.dart'; +import '../bloc/profile_bloc.dart'; +import '../bloc/profile_event.dart'; +import '../bloc/profile_state.dart'; +import '../bloc/profile_status.dart'; + +/// صفحه‌ی پروفایل: نام، آواتار (قابل ویرایش)، سطح، جام و آمارِ بازی (ویژه‌ی VIP). +class ProfileScreen extends StatelessWidget { + const ProfileScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: GameBackground( + child: SafeArea( + child: BlocConsumer( + listenWhen: (a, b) => a.saveStatus != b.saveStatus, + listener: (context, state) { + final s = state.saveStatus; + if (s is ProfileSaveSuccess) { + context.read().add(LoadWalletEvent()); + } else if (s is ProfileSaveError) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(s.message))); + } + }, + builder: (context, state) { + final st = state.loadStatus; + if (st is ProfileLoadError) { + return Center( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Text(st.message, + style: const TextStyle(color: Colors.white70)), + const SizedBox(height: 12), + GameButton( + label: 'تلاش دوباره', + onTap: () => + context.read().add(LoadProfileEvent())), + ]), + ); + } + if (st is! ProfileLoadLoaded) { + return const Center( + child: CircularProgressIndicator(color: AppColors.gold)); + } + return _content(context, st.profile); + }, + ), + ), + ), + ); + } + + Future _editProfile(BuildContext context, ProfileEntity d) async { + final result = await showModalBottomSheet>( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => _EditProfileSheet(name: d.name, avatar: d.avatar), + ); + if (result == null || !context.mounted) return; + context + .read() + .add(SaveProfileEvent(result['name']!, result['avatar']!)); + } + + Widget _content(BuildContext context, ProfileEntity d) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Row(children: [ + IconButton( + onPressed: () => context.pop(), + icon: const Icon(Icons.arrow_back, color: AppColors.gold), + ), + const Spacer(), + const GlowText('پروفایل', size: 24), + const Spacer(), + const SizedBox(width: 48), + ]), + const SizedBox(height: 8), + GamePanel( + child: Column(children: [ + Stack(children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: AppColors.gold, width: 2.5), + ), + child: RandomAvatar(d.avatar, height: 92, width: 92), + ), + Positioned( + bottom: 0, + right: 0, + child: GestureDetector( + onTap: () => _editProfile(context, d), + child: Container( + padding: const EdgeInsets.all(6), + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [Color(0xFFFFD54F), Color(0xFFB8860B)]), + ), + child: const Icon(Icons.edit, + color: Color(0xFF3A0A12), size: 18), + ), + ), + ), + ]), + const SizedBox(height: 10), + Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + Flexible(child: GlowText(d.name, size: 22)), + if (d.vip) ...[const SizedBox(width: 8), const _VipBadge()], + ]), + if (d.mobile.isNotEmpty) + Text(d.mobile, + style: + const TextStyle(color: Colors.white38, fontSize: 12)), + const SizedBox(height: 14), + Row(children: [ + Expanded( + child: _MiniStat( + icon: Icons.star, label: 'سطح', value: '${d.level}')), + Expanded( + child: _MiniStat( + icon: Icons.emoji_events, + label: 'جام', + value: '${d.trophies}')), + ]), + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular(5), + child: LinearProgressIndicator( + value: d.xpNext == 0 ? 0 : d.xpInto / d.xpNext, + minHeight: 8, + backgroundColor: Colors.white10, + valueColor: const AlwaysStoppedAnimation(AppColors.gold), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text('${d.xpInto} / ${d.xpNext} XP', + style: const TextStyle(color: Colors.white38, fontSize: 11)), + ), + ]), + ), + const SizedBox(height: 16), + const Align( + alignment: Alignment.centerRight, + child: GlowText('آمار بازی', size: 18)), + const SizedBox(height: 8), + _statsSection(context, d), + ], + ), + ); + } + + Widget _statsSection(BuildContext context, ProfileEntity d) { + final s = d.stats; + final rows = [ + _StatRow('بازی کل', s?.games, Icons.casino), + _StatRow('برد کل', s?.wins, Icons.thumb_up), + _StatRow('باخت کل', s?.losses, Icons.thumb_down), + _StatRow('کُت کردن', s?.kotMade, Icons.flash_on), + _StatRow('کُت شدن', s?.kotReceived, Icons.flash_off), + _StatRow('بریدن', s?.cuts, Icons.bolt), + _StatRow('دست حاکم', s?.hakemCount, Icons.workspace_premium), + ]; + final panel = GamePanel(child: Column(children: rows)); + if (d.vip) return panel; + + return Stack(children: [ + Opacity(opacity: 0.35, child: IgnorePointer(child: panel)), + Positioned.fill( + child: Container( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.goldDark), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.lock, color: AppColors.gold, size: 36), + const SizedBox(height: 8), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Text('مشاهده‌ی آمار ویژه‌ی کاربران VIP است', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white, fontSize: 14)), + ), + const SizedBox(height: 12), + GameButton( + label: 'تهیه اشتراک VIP', + icon: Icons.workspace_premium, + colors: const [Color(0xFFFFC107), Color(0xFFB8860B)], + onTap: () async { + await context.push('/vip'); + if (context.mounted) { + context.read().add(LoadProfileEvent()); + } + }, + ), + ], + ), + ), + ), + ]); + } +} + +class _StatRow extends StatelessWidget { + final String label; + final Object? value; + final IconData icon; + const _StatRow(this.label, this.value, this.icon); + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 7), + child: Row(children: [ + Icon(icon, color: AppColors.gold, size: 20), + const SizedBox(width: 10), + Text(label, style: const TextStyle(color: Colors.white, fontSize: 15)), + const Spacer(), + Text('${value ?? '—'}', + style: const TextStyle( + color: AppColors.gold, + fontSize: 16, + fontWeight: FontWeight.bold)), + ]), + ); + } +} + +class _MiniStat extends StatelessWidget { + final IconData icon; + final String label; + final String value; + const _MiniStat( + {required this.icon, required this.label, required this.value}); + @override + Widget build(BuildContext context) { + return Column(children: [ + Icon(icon, color: AppColors.gold, size: 22), + const SizedBox(height: 2), + Text(value, + style: const TextStyle( + color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)), + Text(label, style: const TextStyle(color: Colors.white54, fontSize: 12)), + ]); + } +} + +class _VipBadge extends StatelessWidget { + const _VipBadge(); + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + gradient: + const LinearGradient(colors: [Color(0xFFFFD54F), Color(0xFFB8860B)]), + borderRadius: BorderRadius.circular(8), + ), + child: const Text('VIP', + style: TextStyle( + color: Color(0xFF3A0A12), + fontWeight: FontWeight.bold, + fontSize: 12)), + ); + } +} + +/// شیتِ ویرایش نام و آواتار (با تأیید، مقدار جدید را برمی‌گرداند). +class _EditProfileSheet extends StatefulWidget { + final String name; + final String avatar; + const _EditProfileSheet({required this.name, required this.avatar}); + + @override + State<_EditProfileSheet> createState() => _EditProfileSheetState(); +} + +class _EditProfileSheetState extends State<_EditProfileSheet> { + late final TextEditingController _name; + late final List _seeds; + late String _selected; + + @override + void initState() { + super.initState(); + _name = TextEditingController(text: widget.name); + _seeds = List.generate(12, (i) => 'hakem-${i + 1}'); + if (!_seeds.contains(widget.avatar)) _seeds.insert(0, widget.avatar); + _selected = widget.avatar; + } + + @override + void dispose() { + _name.dispose(); + super.dispose(); + } + + bool get _valid => _name.text.trim().length >= 2; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: Container( + decoration: const BoxDecoration( + color: AppColors.bgDark, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + border: Border(top: BorderSide(color: AppColors.gold, width: 2)), + ), + padding: const EdgeInsets.all(18), + child: SingleChildScrollView( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + const GlowText('ویرایش پروفایل', size: 20), + const SizedBox(height: 14), + Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: AppColors.gold, width: 2), + ), + child: RandomAvatar(_selected, height: 72, width: 72), + ), + const SizedBox(height: 12), + TextField( + controller: _name, + textAlign: TextAlign.center, + maxLength: 20, + inputFormatters: [LengthLimitingTextInputFormatter(20)], + decoration: const InputDecoration( + hintText: 'نام نمایشی (۲ تا ۲۰ حرف)', counterText: ''), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 10), + const Align( + alignment: Alignment.centerRight, + child: + Text('انتخاب آواتار', style: TextStyle(color: AppColors.gold)), + ), + const SizedBox(height: 8), + GridView.count( + crossAxisCount: 4, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 10, + crossAxisSpacing: 10, + children: [ + for (final s in _seeds) + GestureDetector( + onTap: () => setState(() => _selected = s), + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.panel, + border: Border.all( + color: _selected == s + ? AppColors.gold + : Colors.transparent, + width: 2.5, + ), + ), + child: RandomAvatar(s), + ), + ), + ], + ), + const SizedBox(height: 16), + GameButton( + label: 'تأیید', + width: double.infinity, + colors: const [Color(0xFF3FA34D), Color(0xFF1B5E20)], + onTap: _valid + ? () => Navigator.pop( + context, {'name': _name.text.trim(), 'avatar': _selected}) + : null, + ), + ]), + ), + ), + ); + } +} diff --git a/lib/feature/shop/data/data_source/remote/shop_api_provider.dart b/lib/feature/shop/data/data_source/remote/shop_api_provider.dart new file mode 100644 index 0000000..6950748 --- /dev/null +++ b/lib/feature/shop/data/data_source/remote/shop_api_provider.dart @@ -0,0 +1,32 @@ +import 'package:dio/dio.dart'; + +import '../../../../../core/locator/locator.dart'; +import '../../../../../core/network/api_provider_imp.dart'; + +class ShopApiProvider { + ApiProviderImp get _api => locator(); + + Future getShop() => _api.get('/shop'); + + Future buyCard(String cardId) => + _api.post('/shop/buy-card', body: {'card_id': cardId}); + + Future selectCard(String cardId) => + _api.post('/shop/select-card', body: {'card_id': cardId}); + + Future purchase({ + required String store, + required String kind, + required String productId, + required String token, + }) => + _api.post('/shop/purchase', body: { + 'store': store, + 'kind': kind, + 'product_id': productId, + 'token': token, + }); + + Future adReward(String token) => + _api.post('/rewards/ad', body: {'token': token}); +} diff --git a/lib/feature/shop/data/model/shop_models.dart b/lib/feature/shop/data/model/shop_models.dart new file mode 100644 index 0000000..6502bff --- /dev/null +++ b/lib/feature/shop/data/model/shop_models.dart @@ -0,0 +1,59 @@ +import '../../domain/entities/shop_entities.dart'; + +/// نگاشتِ JSON کاتالوگ فروشگاه به موجودیت‌ها. +class ShopMapper { + static CoinPackage coin(Map j) => CoinPackage( + id: j['id'] as String, + title: j['title'] as String, + coins: (j['coins'] ?? 0) as int, + vipDays: (j['vip_days'] ?? 0) as int, + priceToman: (j['price_toman'] ?? 0) as int, + bonusPct: (j['bonus_pct'] ?? 0) as int, + ); + + static TicketPackage ticket(Map j) => TicketPackage( + id: j['id'] as String, + title: j['title'] as String, + tickets: (j['tickets'] ?? 0) as int, + priceToman: (j['price_toman'] ?? 0) as int, + ); + + static CardSkin card(Map j) => CardSkin( + id: j['id'] as String, + title: j['title'] as String, + priceCoins: (j['price_coins'] ?? 0) as int, + ); + + static Booster booster(Map j) => Booster( + id: j['id'] as String, + title: j['title'] as String, + multiplier: (j['multiplier'] ?? 1) as int, + hours: (j['hours'] ?? 0) as int, + priceToman: (j['price_toman'] ?? 0) as int, + ); + + static VipPackage vip(Map j) => VipPackage( + id: j['id'] as String, + title: j['title'] as String, + months: (j['months'] ?? 1) as int, + priceToman: (j['price_toman'] ?? 0) as int, + ); + + static ShopData shopData(Map j) { + final cat = Map.from(j['catalog'] as Map); + List parse(String key, T Function(Map) f) => + ((cat[key] as List?) ?? []) + .map((e) => f(Map.from(e as Map))) + .toList(); + return ShopData( + coinPackages: parse('coin_packages', coin), + ticketPackages: parse('ticket_packages', ticket), + cardSkins: parse('card_skins', card), + boosters: parse('boosters', booster), + vipPackages: parse('vip_packages', vip), + ownedCards: + ((j['owned_cards'] as List?) ?? []).map((e) => e as String).toList(), + selectedCard: (j['selected_card'] ?? 'simple') as String, + ); + } +} diff --git a/lib/feature/shop/data/repository/shop_repository_impl.dart b/lib/feature/shop/data/repository/shop_repository_impl.dart new file mode 100644 index 0000000..1d1db24 --- /dev/null +++ b/lib/feature/shop/data/repository/shop_repository_impl.dart @@ -0,0 +1,65 @@ +import 'package:dio/dio.dart'; + +import '../../../../core/error/custom_error.dart'; +import '../../../../core/resources/data_state.dart'; +import '../../../../core/usecase/use_case.dart'; +import '../../domain/entities/shop_entities.dart'; +import '../../domain/repository/shop_repository.dart'; +import '../data_source/remote/shop_api_provider.dart'; +import '../model/shop_models.dart'; + +class ShopRepositoryImpl extends ShopRepository { + final ShopApiProvider api; + ShopRepositoryImpl(this.api); + + @override + Future> getShop() async { + final Response res = await api.getShop(); + if (res.statusCode == 200) { + return DataSuccess( + ShopMapper.shopData(Map.from(res.data as Map))); + } + return DataError(errorConvertor(res.statusCode, _msg(res))); + } + + @override + Future> buyCard(String cardId) async { + final Response res = await api.buyCard(cardId); + if (res.statusCode == 200) return const DataSuccess('کارت خریداری شد'); + return DataError(errorConvertor(res.statusCode, _msg(res))); + } + + @override + Future> selectCard(String cardId) async { + final Response res = await api.selectCard(cardId); + if (res.statusCode == 200) return const DataSuccess('کارت انتخاب شد'); + return DataError(errorConvertor(res.statusCode, _msg(res))); + } + + @override + Future> purchase(PurchaseParams params) async { + final Response res = await api.purchase( + store: params.store, + kind: params.kind, + productId: params.productId, + token: params.token, + ); + if (res.statusCode == 200) return const DataSuccess('خرید با موفقیت انجام شد'); + return DataError(errorConvertor(res.statusCode, _msg(res))); + } + + @override + Future> adReward(String token) async { + final Response res = await api.adReward(token); + if (res.statusCode == 200) { + return DataSuccess((res.data['amount'] ?? 0) as int); + } + return DataError(errorConvertor(res.statusCode, _msg(res))); + } + + String? _msg(Response res) { + final d = res.data; + if (d is Map && d['message'] != null) return d['message'].toString(); + return null; + } +} diff --git a/lib/feature/shop/domain/entities/shop_entities.dart b/lib/feature/shop/domain/entities/shop_entities.dart new file mode 100644 index 0000000..cce472c --- /dev/null +++ b/lib/feature/shop/domain/entities/shop_entities.dart @@ -0,0 +1,89 @@ +/// موجودیت‌های کاتالوگ فروشگاه (مستقل از JSON). +class CoinPackage { + final String id; + final String title; + final int coins; + final int vipDays; + final int priceToman; + final int bonusPct; + const CoinPackage({ + required this.id, + required this.title, + required this.coins, + required this.vipDays, + required this.priceToman, + required this.bonusPct, + }); +} + +class TicketPackage { + final String id; + final String title; + final int tickets; + final int priceToman; + const TicketPackage({ + required this.id, + required this.title, + required this.tickets, + required this.priceToman, + }); +} + +class CardSkin { + final String id; + final String title; + final int priceCoins; + const CardSkin( + {required this.id, required this.title, required this.priceCoins}); +} + +class Booster { + final String id; + final String title; + final int multiplier; + final int hours; + final int priceToman; + const Booster({ + required this.id, + required this.title, + required this.multiplier, + required this.hours, + required this.priceToman, + }); +} + +class VipPackage { + final String id; + final String title; + final int months; + final int priceToman; + const VipPackage({ + required this.id, + required this.title, + required this.months, + required this.priceToman, + }); +} + +/// کلِ داده‌ی فروشگاه: کاتالوگ + کارت‌های متعلق به کاربر + کارت انتخابی. +class ShopData { + final List coinPackages; + final List ticketPackages; + final List cardSkins; + final List boosters; + final List vipPackages; + final List ownedCards; + final String selectedCard; + + const ShopData({ + required this.coinPackages, + required this.ticketPackages, + required this.cardSkins, + required this.boosters, + required this.vipPackages, + required this.ownedCards, + required this.selectedCard, + }); + + bool owns(String cardId) => cardId == 'simple' || ownedCards.contains(cardId); +} diff --git a/lib/feature/shop/domain/repository/shop_repository.dart b/lib/feature/shop/domain/repository/shop_repository.dart new file mode 100644 index 0000000..8e2bfb5 --- /dev/null +++ b/lib/feature/shop/domain/repository/shop_repository.dart @@ -0,0 +1,11 @@ +import '../../../../core/resources/data_state.dart'; +import '../../../../core/usecase/use_case.dart'; +import '../entities/shop_entities.dart'; + +abstract class ShopRepository { + Future> getShop(); + Future> buyCard(String cardId); + Future> selectCard(String cardId); + Future> purchase(PurchaseParams params); + Future> adReward(String token); +} diff --git a/lib/feature/shop/domain/use_cases/ad_reward_usecase.dart b/lib/feature/shop/domain/use_cases/ad_reward_usecase.dart new file mode 100644 index 0000000..af154a7 --- /dev/null +++ b/lib/feature/shop/domain/use_cases/ad_reward_usecase.dart @@ -0,0 +1,11 @@ +import '../../../../core/resources/data_state.dart'; +import '../../../../core/usecase/use_case.dart'; +import '../repository/shop_repository.dart'; + +class AdRewardUseCase implements UseCase, String> { + final ShopRepository repository; + AdRewardUseCase(this.repository); + + @override + Future> call(String params) => repository.adReward(params); +} diff --git a/lib/feature/shop/domain/use_cases/buy_card_usecase.dart b/lib/feature/shop/domain/use_cases/buy_card_usecase.dart new file mode 100644 index 0000000..7d35833 --- /dev/null +++ b/lib/feature/shop/domain/use_cases/buy_card_usecase.dart @@ -0,0 +1,11 @@ +import '../../../../core/resources/data_state.dart'; +import '../../../../core/usecase/use_case.dart'; +import '../repository/shop_repository.dart'; + +class BuyCardUseCase implements UseCase, String> { + final ShopRepository repository; + BuyCardUseCase(this.repository); + + @override + Future> call(String params) => repository.buyCard(params); +} diff --git a/lib/feature/shop/domain/use_cases/get_shop_usecase.dart b/lib/feature/shop/domain/use_cases/get_shop_usecase.dart new file mode 100644 index 0000000..e4473ca --- /dev/null +++ b/lib/feature/shop/domain/use_cases/get_shop_usecase.dart @@ -0,0 +1,12 @@ +import '../../../../core/resources/data_state.dart'; +import '../../../../core/usecase/use_case.dart'; +import '../entities/shop_entities.dart'; +import '../repository/shop_repository.dart'; + +class GetShopUseCase implements UseCase, NoParams> { + final ShopRepository repository; + GetShopUseCase(this.repository); + + @override + Future> call(NoParams params) => repository.getShop(); +} diff --git a/lib/feature/shop/domain/use_cases/purchase_usecase.dart b/lib/feature/shop/domain/use_cases/purchase_usecase.dart new file mode 100644 index 0000000..7189d37 --- /dev/null +++ b/lib/feature/shop/domain/use_cases/purchase_usecase.dart @@ -0,0 +1,12 @@ +import '../../../../core/resources/data_state.dart'; +import '../../../../core/usecase/use_case.dart'; +import '../repository/shop_repository.dart'; + +class PurchaseUseCase implements UseCase, PurchaseParams> { + final ShopRepository repository; + PurchaseUseCase(this.repository); + + @override + Future> call(PurchaseParams params) => + repository.purchase(params); +} diff --git a/lib/feature/shop/domain/use_cases/select_card_usecase.dart b/lib/feature/shop/domain/use_cases/select_card_usecase.dart new file mode 100644 index 0000000..3384605 --- /dev/null +++ b/lib/feature/shop/domain/use_cases/select_card_usecase.dart @@ -0,0 +1,12 @@ +import '../../../../core/resources/data_state.dart'; +import '../../../../core/usecase/use_case.dart'; +import '../repository/shop_repository.dart'; + +class SelectCardUseCase implements UseCase, String> { + final ShopRepository repository; + SelectCardUseCase(this.repository); + + @override + Future> call(String params) => + repository.selectCard(params); +} diff --git a/lib/feature/shop/presentation/bloc/shop_bloc.dart b/lib/feature/shop/presentation/bloc/shop_bloc.dart new file mode 100644 index 0000000..b82655e --- /dev/null +++ b/lib/feature/shop/presentation/bloc/shop_bloc.dart @@ -0,0 +1,84 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../core/resources/data_state.dart'; +import '../../../../core/usecase/use_case.dart'; +import '../../domain/use_cases/ad_reward_usecase.dart'; +import '../../domain/use_cases/buy_card_usecase.dart'; +import '../../domain/use_cases/get_shop_usecase.dart'; +import '../../domain/use_cases/purchase_usecase.dart'; +import '../../domain/use_cases/select_card_usecase.dart'; +import 'shop_event.dart'; +import 'shop_state.dart'; +import 'shop_status.dart'; + +class ShopBloc extends Bloc { + final GetShopUseCase getShopUseCase; + final BuyCardUseCase buyCardUseCase; + final SelectCardUseCase selectCardUseCase; + final PurchaseUseCase purchaseUseCase; + final AdRewardUseCase adRewardUseCase; + + ShopBloc( + this.getShopUseCase, + this.buyCardUseCase, + this.selectCardUseCase, + this.purchaseUseCase, + this.adRewardUseCase, + ) : super(ShopBlocState.initial()) { + on((event, emit) => _load(emit)); + + on((event, emit) => + _action(emit, () => buyCardUseCase(event.cardId))); + + on((event, emit) => + _action(emit, () => selectCardUseCase(event.cardId))); + + on((event, emit) => _action( + emit, + () => purchaseUseCase(PurchaseParams( + store: 'bazaar', + kind: event.kind, + productId: event.productId, + token: + 'dev-${event.kind}-${event.productId}-${DateTime.now().millisecondsSinceEpoch}', + )))); + + on((event, emit) => _action( + emit, + () async { + final res = await adRewardUseCase( + 'dev-ad-${DateTime.now().millisecondsSinceEpoch}'); + if (res is DataSuccess) { + return const DataSuccess('سکه رایگان دریافت شد'); + } + return DataError(res.error!); + }, + )); + } + + Future _load(Emitter emit) async { + emit(state.copyWith(loadStatus: ShopLoading())); + final res = await getShopUseCase(const NoParams()); + if (res is DataSuccess) { + emit(state.copyWith(loadStatus: ShopLoaded(res.data!))); + } else { + emit(state.copyWith(loadStatus: ShopLoadError(res.error!))); + } + } + + /// اجرای یک عملیات، نمایش وضعیت و سپس بازخوانی کاتالوگ. + Future _action( + Emitter emit, + Future> Function() action, + ) async { + if (state.busy) return; + emit(state.copyWith(actionStatus: ActionLoading())); + final res = await action(); + if (res is DataSuccess) { + emit(state.copyWith(actionStatus: ActionSuccess(res.data!))); + await _load(emit); + } else { + emit(state.copyWith(actionStatus: ActionError(res.error!))); + } + } +} diff --git a/lib/feature/shop/presentation/bloc/shop_event.dart b/lib/feature/shop/presentation/bloc/shop_event.dart new file mode 100644 index 0000000..3f60964 --- /dev/null +++ b/lib/feature/shop/presentation/bloc/shop_event.dart @@ -0,0 +1,21 @@ +abstract class ShopEvent {} + +class LoadShopEvent extends ShopEvent {} + +class BuyCardEvent extends ShopEvent { + final String cardId; + BuyCardEvent(this.cardId); +} + +class SelectCardEvent extends ShopEvent { + final String cardId; + SelectCardEvent(this.cardId); +} + +class PurchaseEvent extends ShopEvent { + final String kind; + final String productId; + PurchaseEvent(this.kind, this.productId); +} + +class AdRewardEvent extends ShopEvent {} diff --git a/lib/feature/shop/presentation/bloc/shop_state.dart b/lib/feature/shop/presentation/bloc/shop_state.dart new file mode 100644 index 0000000..38dc532 --- /dev/null +++ b/lib/feature/shop/presentation/bloc/shop_state.dart @@ -0,0 +1,22 @@ +import 'shop_status.dart'; + +class ShopBlocState { + final ShopLoadStatus loadStatus; + final ShopActionStatus actionStatus; + + ShopBlocState({required this.loadStatus, required this.actionStatus}); + + factory ShopBlocState.initial() => + ShopBlocState(loadStatus: ShopInitial(), actionStatus: ActionIdle()); + + bool get busy => actionStatus is ActionLoading; + + ShopBlocState copyWith({ + ShopLoadStatus? loadStatus, + ShopActionStatus? actionStatus, + }) => + ShopBlocState( + loadStatus: loadStatus ?? this.loadStatus, + actionStatus: actionStatus ?? this.actionStatus, + ); +} diff --git a/lib/feature/shop/presentation/bloc/shop_status.dart b/lib/feature/shop/presentation/bloc/shop_status.dart new file mode 100644 index 0000000..226a355 --- /dev/null +++ b/lib/feature/shop/presentation/bloc/shop_status.dart @@ -0,0 +1,34 @@ +import '../../domain/entities/shop_entities.dart'; + +abstract class ShopLoadStatus {} + +class ShopInitial extends ShopLoadStatus {} + +class ShopLoading extends ShopLoadStatus {} + +class ShopLoaded extends ShopLoadStatus { + final ShopData data; + ShopLoaded(this.data); +} + +class ShopLoadError extends ShopLoadStatus { + final String message; + ShopLoadError(this.message); +} + +/// وضعیتِ یک عملیات (خرید/انتخاب/تبلیغ). +abstract class ShopActionStatus {} + +class ActionIdle extends ShopActionStatus {} + +class ActionLoading extends ShopActionStatus {} + +class ActionSuccess extends ShopActionStatus { + final String message; + ActionSuccess(this.message); +} + +class ActionError extends ShopActionStatus { + final String message; + ActionError(this.message); +} diff --git a/lib/features/shop/shop_screen.dart b/lib/feature/shop/presentation/screen/shop_screen.dart similarity index 58% rename from lib/features/shop/shop_screen.dart rename to lib/feature/shop/presentation/screen/shop_screen.dart index ba42d3f..6b6a801 100644 --- a/lib/features/shop/shop_screen.dart +++ b/lib/feature/shop/presentation/screen/shop_screen.dart @@ -2,13 +2,19 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; -import '../../core/theme/app_theme.dart'; -import '../../core/widgets/game_ui.dart'; -import '../lobby/wallet_cubit.dart'; -import 'shop_cubit.dart'; -import 'shop_models.dart'; +import '../../../../core/theme/app_theme.dart'; +import '../../../../core/widgets/game_ui.dart'; +import '../../../wallet/presentation/bloc/wallet_bloc.dart'; +import '../../../wallet/presentation/bloc/wallet_event.dart'; +import '../../../wallet/presentation/bloc/wallet_state.dart'; +import '../../../wallet/presentation/bloc/wallet_status.dart'; +import '../../domain/entities/shop_entities.dart'; +import '../bloc/shop_bloc.dart'; +import '../bloc/shop_event.dart'; +import '../bloc/shop_state.dart'; +import '../bloc/shop_status.dart'; -/// فروشگاه با تب‌های سکه/بلیط/کارت/تجهیزات و ظاهرِ بازی‌گونه. +/// فروشگاه با تب‌های سکه/بلیط/کارت/تجهیزات/VIP. class ShopScreen extends StatelessWidget { const ShopScreen({super.key}); @@ -19,72 +25,80 @@ class ShopScreen extends StatelessWidget { child: Scaffold( backgroundColor: Colors.transparent, body: GameBackground( - child: Column( - children: [ - _header(context), - Expanded( - child: Container( - margin: const EdgeInsets.fromLTRB(8, 0, 8, 8), - decoration: BoxDecoration( - gradient: const LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0xFF4A0C16), Color(0xFF2A0710)], - ), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.goldDark, width: 1.5), - ), - child: Column( - children: [ - const _ShopTabs(), - Expanded( - child: BlocBuilder( - builder: (context, state) { - if (state.status == ShopStatus.loading || - state.status == ShopStatus.initial) { - return const Center( - child: CircularProgressIndicator( - color: AppColors.gold)); - } - if (state.status == ShopStatus.error || - state.data == null) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text('خطا در بارگذاری فروشگاه'), - TextButton( - onPressed: () => - context.read().load(), - child: const Text('تلاش مجدد'), - ), - ]), - ); - } - final d = state.data!; - return TabBarView( - children: [ - _CoinsTab(packages: d.coinPackages), - _TicketsTab(packages: d.ticketPackages), - _CardsTab(data: d), - _BoostersTab(boosters: d.boosters), - _VipTab(packages: d.vipPackages), - ], - ); - }, + child: BlocConsumer( + listenWhen: (a, b) => a.actionStatus != b.actionStatus, + listener: (context, state) { + final s = state.actionStatus; + if (s is ActionSuccess) { + context.read().add(LoadWalletEvent()); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(s.message))); + } else if (s is ActionError) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(s.message))); + } + }, + builder: (context, state) { + return Column( + children: [ + _header(context), + Expanded( + child: Container( + margin: const EdgeInsets.fromLTRB(8, 0, 8, 8), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF4A0C16), Color(0xFF2A0710)], ), + borderRadius: BorderRadius.circular(16), + border: + Border.all(color: AppColors.goldDark, width: 1.5), ), - ], + child: Column( + children: [ + const _ShopTabs(), + Expanded(child: _body(context, state.loadStatus)), + ], + ), + ), ), - ), - ), - ], + ], + ); + }, ), ), ), ); } + Widget _body(BuildContext context, ShopLoadStatus status) { + if (status is ShopLoaded) { + final d = status.data; + return TabBarView( + children: [ + _CoinsTab(packages: d.coinPackages), + _TicketsTab(packages: d.ticketPackages), + _CardsTab(data: d), + _BoostersTab(boosters: d.boosters), + _VipTab(packages: d.vipPackages), + ], + ); + } + if (status is ShopLoadError) { + return Center( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Text(status.message), + TextButton( + onPressed: () => context.read().add(LoadShopEvent()), + child: const Text('تلاش مجدد'), + ), + ]), + ); + } + return const Center(child: CircularProgressIndicator(color: AppColors.gold)); + } + Widget _header(BuildContext context) { return Padding( padding: const EdgeInsets.all(8), @@ -103,15 +117,19 @@ class ShopScreen extends StatelessWidget { ), ), const Spacer(), - BlocBuilder( - builder: (context, s) => Row(children: [ - StatChip( - icon: Icons.confirmation_number, - value: '${s.wallet?.tickets ?? 0}'), - const SizedBox(width: 8), - StatChip( - icon: Icons.monetization_on, value: '${s.wallet?.coins ?? 0}'), - ]), + BlocBuilder( + builder: (context, s) { + final w = s.walletStatus is WalletLoaded + ? (s.walletStatus as WalletLoaded).wallet + : null; + return Row(children: [ + StatChip( + icon: Icons.confirmation_number, + value: '${w?.tickets ?? 0}'), + const SizedBox(width: 8), + StatChip(icon: Icons.monetization_on, value: '${w?.coins ?? 0}'), + ]); + }, ), ]), ); @@ -136,6 +154,8 @@ class _ShopTabs extends StatelessWidget { unselectedLabelColor: Colors.white60, labelStyle: TextStyle(fontWeight: FontWeight.bold), dividerColor: Colors.transparent, + isScrollable: true, + tabAlignment: TabAlignment.center, tabs: [ Tab(text: 'سکه'), Tab(text: 'بلیط'), @@ -147,15 +167,6 @@ class _ShopTabs extends StatelessWidget { } } -/// اجرای یک عملیات فروشگاه، نمایش نتیجه و بازخوانی کیف‌پول. -Future _do(BuildContext context, Future Function() action) async { - final msg = await action(); - if (!context.mounted || msg.isEmpty) return; - await context.read().load(); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); -} - // ===== تب‌ها ===== class _CoinsTab extends StatelessWidget { @@ -174,7 +185,7 @@ class _CoinsTab extends StatelessWidget { action: _PriceButton( label: 'رایگان', green: true, - onTap: () => _do(context, () => context.read().claimAd()), + onTap: () => context.read().add(AdRewardEvent()), ), ), for (final p in packages) @@ -183,11 +194,12 @@ class _CoinsTab extends StatelessWidget { glowColor: const Color(0xFF1B5E20), icon: Icons.savings, ribbon: p.bonusPct > 0 ? '+${p.bonusPct}٪' : null, - subtitle: '${p.coins} سکه${p.vipDays > 0 ? ' + ${p.vipDays} روز VIP' : ''}', + subtitle: + '${p.coins} سکه${p.vipDays > 0 ? ' + ${p.vipDays} روز VIP' : ''}', action: _PriceButton( label: '${p.priceToman} تومان', - onTap: () => _do( - context, () => context.read().purchase('coin', p.id)), + onTap: () => + context.read().add(PurchaseEvent('coin', p.id)), ), ), ], @@ -211,8 +223,8 @@ class _TicketsTab extends StatelessWidget { subtitle: '${p.tickets} بلیط', action: _PriceButton( label: '${p.priceToman} تومان', - onTap: () => _do(context, - () => context.read().purchase('ticket', p.id)), + onTap: () => + context.read().add(PurchaseEvent('ticket', p.id)), ), ), ], @@ -236,8 +248,8 @@ class _BoostersTab extends StatelessWidget { subtitle: 'تجربه ×${b.multiplier} — ${b.hours} ساعت', action: _PriceButton( label: '${b.priceToman} تومان', - onTap: () => _do(context, - () => context.read().purchase('booster', b.id)), + onTap: () => + context.read().add(PurchaseEvent('booster', b.id)), ), ), ], @@ -246,95 +258,31 @@ class _BoostersTab extends StatelessWidget { } class _VipTab extends StatelessWidget { - final List packages; + final List packages; const _VipTab({required this.packages}); @override Widget build(BuildContext context) { - final isVip = context.select((WalletCubit c) => c.state.wallet?.vip ?? false); - return Column( + return _grid( + note: 'با VIP: میز خصوصی نامحدود، آمار کامل و ۱۰٪ سکه‌ی هدیه.', children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [Color(0xFF5A3A00), Color(0xFF2A1A00)]), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.gold, width: 1.3), - ), - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.workspace_premium, - color: AppColors.gold, size: 22), - const SizedBox(width: 6), - Text(isVip ? 'شما کاربر VIP هستید' : 'مزایای اشتراک VIP', - style: const TextStyle( - color: AppColors.gold, - fontWeight: FontWeight.bold, - fontSize: 15)), - ], - ), - const SizedBox(height: 8), - const _Benefit('میزهای خصوصی نامحدود'), - const _Benefit('مشاهده‌ی کامل آمار بازی در پروفایل'), - const _Benefit('۱۰٪ سکه‌ی هدیه در هر خرید'), - ], + for (final p in packages) + _ItemCard( + title: p.title, + glowColor: const Color(0xFF8A6D00), + icon: Icons.workspace_premium, + ribbon: p.months >= 6 ? 'بهترین' : null, + subtitle: '${p.months} ماه اشتراک', + action: _PriceButton( + label: '${p.priceToman} تومان', + onTap: () => + context.read().add(PurchaseEvent('vip', p.id)), ), ), - ), - Expanded( - child: GridView.count( - crossAxisCount: 2, - padding: const EdgeInsets.all(12), - childAspectRatio: 0.74, - mainAxisSpacing: 12, - crossAxisSpacing: 12, - children: [ - for (final p in packages) - _ItemCard( - title: p.title, - glowColor: const Color(0xFF8A6D00), - icon: Icons.workspace_premium, - ribbon: p.months >= 6 ? 'بهترین' : null, - subtitle: '${p.months} ماه اشتراک', - action: _PriceButton( - label: '${p.priceToman} تومان', - onTap: () => _do(context, - () => context.read().purchase('vip', p.id)), - ), - ), - ], - ), - ), ], ); } } -class _Benefit extends StatelessWidget { - final String text; - const _Benefit(this.text); - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Row( - children: [ - const Icon(Icons.check_circle, color: Color(0xFF6FCF7A), size: 16), - const SizedBox(width: 6), - Expanded( - child: Text(text, - style: const TextStyle(color: Colors.white, fontSize: 13))), - ], - ), - ); - } -} - class _CardsTab extends StatelessWidget { final ShopData data; const _CardsTab({required this.data}); @@ -363,15 +311,14 @@ class _CardsTab extends StatelessWidget { return _PriceButton( label: 'انتخاب', green: true, - onTap: () => - _do(context, () => context.read().selectCard(c.id)), + onTap: () => context.read().add(SelectCardEvent(c.id)), ); } return _PriceButton( label: '${c.priceCoins} سکه', green: true, coin: true, - onTap: () => _do(context, () => context.read().buyCard(c.id)), + onTap: () => context.read().add(BuyCardEvent(c.id)), ); } } @@ -475,7 +422,6 @@ class _ItemCard extends StatelessWidget { } } -/// قابِ هنریِ آیتم با درخششِ شعاعی و آیکن. class _GlowArt extends StatelessWidget { final Color color; final IconData icon; @@ -492,9 +438,7 @@ class _GlowArt extends StatelessWidget { ), border: Border.all(color: Colors.black26), ), - child: Center( - child: Icon(icon, size: 44, color: AppColors.gold), - ), + child: Center(child: Icon(icon, size: 44, color: AppColors.gold)), ); } } @@ -515,7 +459,7 @@ class _PriceButton extends StatelessWidget { @override Widget build(BuildContext context) { - final busy = context.select((ShopCubit c) => c.state.busy); + final busy = context.select((ShopBloc c) => c.state.busy); final colors = disabled ? const [Color(0xFF555555), Color(0xFF333333)] : green @@ -540,8 +484,7 @@ class _PriceButton extends StatelessWidget { ), child: Row(mainAxisSize: MainAxisSize.min, children: [ if (coin) ...[ - const Icon(Icons.monetization_on, - color: AppColors.gold, size: 16), + const Icon(Icons.monetization_on, color: AppColors.gold, size: 16), const SizedBox(width: 4), ], Flexible( diff --git a/lib/feature/shop/presentation/screen/vip_screen.dart b/lib/feature/shop/presentation/screen/vip_screen.dart new file mode 100644 index 0000000..2296631 --- /dev/null +++ b/lib/feature/shop/presentation/screen/vip_screen.dart @@ -0,0 +1,205 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../../core/theme/app_theme.dart'; +import '../../../../core/widgets/game_ui.dart'; +import '../../../wallet/presentation/bloc/wallet_bloc.dart'; +import '../../../wallet/presentation/bloc/wallet_event.dart'; +import '../../../wallet/presentation/bloc/wallet_status.dart'; +import '../bloc/shop_bloc.dart'; +import '../bloc/shop_event.dart'; +import '../bloc/shop_state.dart'; +import '../bloc/shop_status.dart'; + +/// صفحه‌ی اشتراک VIP: نمایش بسته‌ها و خرید. +class VipScreen extends StatelessWidget { + const VipScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: GameBackground( + child: SafeArea( + child: BlocConsumer( + listenWhen: (a, b) => a.actionStatus != b.actionStatus, + listener: (context, state) { + final s = state.actionStatus; + if (s is ActionSuccess) { + context.read().add(LoadWalletEvent()); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(s.message))); + } else if (s is ActionError) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(s.message))); + } + }, + builder: (context, state) { + final st = state.loadStatus; + if (st is ShopLoadError) { + return Center( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Text(st.message, + style: const TextStyle(color: Colors.white70)), + TextButton( + onPressed: () => + context.read().add(LoadShopEvent()), + child: const Text('تلاش مجدد')), + ]), + ); + } + if (st is! ShopLoaded) { + return const Center( + child: CircularProgressIndicator(color: AppColors.gold)); + } + final packages = st.data.vipPackages; + final isVip = context.select((WalletBloc c) { + final ws = c.state.walletStatus; + return ws is WalletLoaded ? ws.wallet.vip : false; + }); + return Column( + children: [ + Row(children: [ + IconButton( + onPressed: () => context.pop(), + icon: const Icon(Icons.arrow_back, color: AppColors.gold), + ), + const Spacer(), + const GlowText('اشتراک VIP', size: 24), + const Spacer(), + const SizedBox(width: 48), + ]), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + gradient: const LinearGradient(colors: [ + Color(0xFF5A3A00), + Color(0xFF2A1A00) + ]), + borderRadius: BorderRadius.circular(14), + border: + Border.all(color: AppColors.gold, width: 1.3), + ), + child: Column(children: [ + const Icon(Icons.workspace_premium, + color: AppColors.gold, size: 40), + const SizedBox(height: 6), + Text( + isVip + ? 'شما کاربر VIP هستید' + : 'با VIP بازی حرفه‌ای‌تری داشته باش', + style: const TextStyle( + color: AppColors.gold, + fontWeight: FontWeight.bold, + fontSize: 16)), + const SizedBox(height: 10), + const _Benefit('میزهای خصوصی نامحدود'), + const _Benefit('مشاهده‌ی کامل آمار بازی'), + const _Benefit('۱۰٪ سکه‌ی هدیه در هر خرید'), + ]), + ), + const SizedBox(height: 18), + for (final p in packages) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _VipPackageTile( + title: p.title, + months: p.months, + price: p.priceToman, + busy: state.busy, + onBuy: () => context + .read() + .add(PurchaseEvent('vip', p.id)), + ), + ), + if (packages.isEmpty) + const Padding( + padding: EdgeInsets.only(top: 30), + child: Text('فعلاً بسته‌ای موجود نیست', + style: TextStyle(color: Colors.white54)), + ), + ], + ), + ), + ), + ], + ); + }, + ), + ), + ), + ); + } +} + +class _VipPackageTile extends StatelessWidget { + final String title; + final int months; + final int price; + final bool busy; + final VoidCallback onBuy; + const _VipPackageTile({ + required this.title, + required this.months, + required this.price, + required this.busy, + required this.onBuy, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF6E1322), Color(0xFF3A0A12)], + ), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.gold, width: 1.4), + ), + child: Row(children: [ + const Icon(Icons.workspace_premium, color: AppColors.gold, size: 34), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: const TextStyle( + color: AppColors.gold, + fontWeight: FontWeight.bold, + fontSize: 16)), + Text('$months ماه اشتراک', + style: const TextStyle(color: Colors.white70, fontSize: 12)), + ], + ), + const Spacer(), + GameButton(label: '$price تومان', onTap: busy ? null : onBuy), + ]), + ); + } +} + +class _Benefit extends StatelessWidget { + final String text; + const _Benefit(this.text); + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row(children: [ + const Icon(Icons.check_circle, color: Color(0xFF6FCF7A), size: 16), + const SizedBox(width: 6), + Expanded( + child: Text(text, + style: const TextStyle(color: Colors.white, fontSize: 13))), + ]), + ); + } +} diff --git a/lib/feature/wallet/data/data_source/remote/wallet_api_provider.dart b/lib/feature/wallet/data/data_source/remote/wallet_api_provider.dart new file mode 100644 index 0000000..e1f7548 --- /dev/null +++ b/lib/feature/wallet/data/data_source/remote/wallet_api_provider.dart @@ -0,0 +1,13 @@ +import 'package:dio/dio.dart'; + +import '../../../../../core/locator/locator.dart'; +import '../../../../../core/network/api_provider_imp.dart'; + +/// تماس‌های خامِ HTTP مربوط به کیف‌پول و پاداش روزانه. +class WalletApiProvider { + ApiProviderImp get _api => locator(); + + Future getWallet() => _api.get('/wallet'); + Future getMe() => _api.get('/me'); + Future claimDaily() => _api.post('/rewards/daily'); +} diff --git a/lib/feature/wallet/data/model/wallet_model.dart b/lib/feature/wallet/data/model/wallet_model.dart new file mode 100644 index 0000000..4a04a95 --- /dev/null +++ b/lib/feature/wallet/data/model/wallet_model.dart @@ -0,0 +1,39 @@ +import '../../domain/entities/wallet_entity.dart'; + +/// مدلِ کیف‌پول؛ از پاسخِ /wallet و /me ساخته می‌شود. +class WalletModel extends WalletEntity { + const WalletModel({ + required super.coins, + required super.tickets, + required super.xp, + required super.trophies, + required super.level, + required super.xpIntoLevel, + required super.xpForNext, + required super.vip, + required super.selectedCard, + required super.name, + required super.avatar, + }); + + factory WalletModel.fromJson( + Map wallet, + Map user, + ) { + final name = (user['first_name'] as String?)?.trim(); + final avatar = (user['avatar'] as String?)?.trim(); + return WalletModel( + coins: (wallet['coins'] ?? 0) as int, + tickets: (wallet['tickets'] ?? 0) as int, + xp: (wallet['xp'] ?? 0) as int, + trophies: (wallet['trophies'] ?? 0) as int, + level: (wallet['level'] ?? 1) as int, + xpIntoLevel: (wallet['xp_into_level'] ?? 0) as int, + xpForNext: (wallet['xp_for_next'] ?? 1) as int, + vip: (wallet['vip'] ?? false) as bool, + selectedCard: (wallet['selected_card'] ?? 'simple') as String, + name: (name == null || name.isEmpty) ? 'بازیکن' : name, + avatar: (avatar == null || avatar.isEmpty) ? 'hakem-1' : avatar, + ); + } +} diff --git a/lib/feature/wallet/data/repository/wallet_repository_impl.dart b/lib/feature/wallet/data/repository/wallet_repository_impl.dart new file mode 100644 index 0000000..aa053a8 --- /dev/null +++ b/lib/feature/wallet/data/repository/wallet_repository_impl.dart @@ -0,0 +1,38 @@ +import 'package:dio/dio.dart'; + +import '../../../../core/error/custom_error.dart'; +import '../../../../core/resources/data_state.dart'; +import '../../domain/entities/wallet_entity.dart'; +import '../../domain/repository/wallet_repository.dart'; +import '../data_source/remote/wallet_api_provider.dart'; +import '../model/wallet_model.dart'; + +class WalletRepositoryImpl extends WalletRepository { + final WalletApiProvider api; + WalletRepositoryImpl(this.api); + + @override + Future> getWallet() async { + final results = await Future.wait([api.getWallet(), api.getMe()]); + final Response wallet = results[0]; + final Response me = results[1]; + if (wallet.statusCode == 200 && me.statusCode == 200) { + return DataSuccess(WalletModel.fromJson( + Map.from(wallet.data as Map), + Map.from((me.data['user'] ?? {}) as Map), + )); + } + return DataError(errorConvertor(wallet.statusCode, null)); + } + + @override + Future> claimDaily() async { + final Response res = await api.claimDaily(); + if (res.statusCode == 200) { + return DataSuccess((res.data['amount'] ?? 0) as int); + } + final d = res.data; + final msg = (d is Map && d['message'] != null) ? d['message'].toString() : null; + return DataError(errorConvertor(res.statusCode, msg)); + } +} diff --git a/lib/feature/wallet/domain/entities/wallet_entity.dart b/lib/feature/wallet/domain/entities/wallet_entity.dart new file mode 100644 index 0000000..1a71575 --- /dev/null +++ b/lib/feature/wallet/domain/entities/wallet_entity.dart @@ -0,0 +1,28 @@ +/// موجودیتِ کیف‌پول و وضعیت اقتصادیِ کاربر (به‌همراه نام و آواتار برای نوار لابی). +class WalletEntity { + final int coins; + final int tickets; + final int xp; + final int trophies; + final int level; + final int xpIntoLevel; + final int xpForNext; + final bool vip; + final String selectedCard; + final String name; + final String avatar; + + const WalletEntity({ + required this.coins, + required this.tickets, + required this.xp, + required this.trophies, + required this.level, + required this.xpIntoLevel, + required this.xpForNext, + required this.vip, + required this.selectedCard, + required this.name, + required this.avatar, + }); +} diff --git a/lib/feature/wallet/domain/repository/wallet_repository.dart b/lib/feature/wallet/domain/repository/wallet_repository.dart new file mode 100644 index 0000000..8e420a7 --- /dev/null +++ b/lib/feature/wallet/domain/repository/wallet_repository.dart @@ -0,0 +1,9 @@ +import '../../../../core/resources/data_state.dart'; +import '../entities/wallet_entity.dart'; + +abstract class WalletRepository { + Future> getWallet(); + + /// دریافت سکه روزانه؛ مقدار دریافتی را برمی‌گرداند. + Future> claimDaily(); +} diff --git a/lib/feature/wallet/domain/use_cases/claim_daily_usecase.dart b/lib/feature/wallet/domain/use_cases/claim_daily_usecase.dart new file mode 100644 index 0000000..4cff7f7 --- /dev/null +++ b/lib/feature/wallet/domain/use_cases/claim_daily_usecase.dart @@ -0,0 +1,11 @@ +import '../../../../core/resources/data_state.dart'; +import '../../../../core/usecase/use_case.dart'; +import '../repository/wallet_repository.dart'; + +class ClaimDailyUseCase implements UseCase, NoParams> { + final WalletRepository repository; + ClaimDailyUseCase(this.repository); + + @override + Future> call(NoParams params) => repository.claimDaily(); +} diff --git a/lib/feature/wallet/domain/use_cases/get_wallet_usecase.dart b/lib/feature/wallet/domain/use_cases/get_wallet_usecase.dart new file mode 100644 index 0000000..e431027 --- /dev/null +++ b/lib/feature/wallet/domain/use_cases/get_wallet_usecase.dart @@ -0,0 +1,13 @@ +import '../../../../core/resources/data_state.dart'; +import '../../../../core/usecase/use_case.dart'; +import '../entities/wallet_entity.dart'; +import '../repository/wallet_repository.dart'; + +class GetWalletUseCase implements UseCase, NoParams> { + final WalletRepository repository; + GetWalletUseCase(this.repository); + + @override + Future> call(NoParams params) => + repository.getWallet(); +} diff --git a/lib/feature/wallet/presentation/bloc/daily_status.dart b/lib/feature/wallet/presentation/bloc/daily_status.dart new file mode 100644 index 0000000..125bb82 --- /dev/null +++ b/lib/feature/wallet/presentation/bloc/daily_status.dart @@ -0,0 +1,15 @@ +abstract class DailyStatus {} + +class DailyInitial extends DailyStatus {} + +class DailyLoading extends DailyStatus {} + +class DailySuccess extends DailyStatus { + final int amount; + DailySuccess(this.amount); +} + +class DailyError extends DailyStatus { + final String message; + DailyError(this.message); +} diff --git a/lib/feature/wallet/presentation/bloc/wallet_bloc.dart b/lib/feature/wallet/presentation/bloc/wallet_bloc.dart new file mode 100644 index 0000000..13e7885 --- /dev/null +++ b/lib/feature/wallet/presentation/bloc/wallet_bloc.dart @@ -0,0 +1,43 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../core/resources/data_state.dart'; +import '../../../../core/usecase/use_case.dart'; +import '../../domain/use_cases/claim_daily_usecase.dart'; +import '../../domain/use_cases/get_wallet_usecase.dart'; +import 'daily_status.dart'; +import 'wallet_event.dart'; +import 'wallet_state.dart'; +import 'wallet_status.dart'; + +class WalletBloc extends Bloc { + final GetWalletUseCase getWalletUseCase; + final ClaimDailyUseCase claimDailyUseCase; + + WalletBloc(this.getWalletUseCase, this.claimDailyUseCase) + : super(WalletBlocState.initial()) { + on((event, emit) async { + emit(state.copyWith(walletStatus: WalletLoading())); + final res = await getWalletUseCase(const NoParams()); + if (res is DataSuccess) { + emit(state.copyWith(walletStatus: WalletLoaded(res.data!))); + } else { + emit(state.copyWith(walletStatus: WalletError(res.error!))); + } + }); + + on((event, emit) async { + emit(state.copyWith(dailyStatus: DailyLoading())); + final res = await claimDailyUseCase(const NoParams()); + if (res is DataSuccess) { + emit(state.copyWith(dailyStatus: DailySuccess(res.data!))); + // پس از دریافت، کیف‌پول به‌روزرسانی شود. + final w = await getWalletUseCase(const NoParams()); + if (w is DataSuccess) { + emit(state.copyWith(walletStatus: WalletLoaded(w.data!))); + } + } else { + emit(state.copyWith(dailyStatus: DailyError(res.error!))); + } + }); + } +} diff --git a/lib/feature/wallet/presentation/bloc/wallet_event.dart b/lib/feature/wallet/presentation/bloc/wallet_event.dart new file mode 100644 index 0000000..d54f970 --- /dev/null +++ b/lib/feature/wallet/presentation/bloc/wallet_event.dart @@ -0,0 +1,5 @@ +abstract class WalletEvent {} + +class LoadWalletEvent extends WalletEvent {} + +class ClaimDailyEvent extends WalletEvent {} diff --git a/lib/feature/wallet/presentation/bloc/wallet_state.dart b/lib/feature/wallet/presentation/bloc/wallet_state.dart new file mode 100644 index 0000000..39c719b --- /dev/null +++ b/lib/feature/wallet/presentation/bloc/wallet_state.dart @@ -0,0 +1,23 @@ +import 'daily_status.dart'; +import 'wallet_status.dart'; + +class WalletBlocState { + final WalletStatus walletStatus; + final DailyStatus dailyStatus; + + WalletBlocState({required this.walletStatus, required this.dailyStatus}); + + factory WalletBlocState.initial() => WalletBlocState( + walletStatus: WalletInitial(), + dailyStatus: DailyInitial(), + ); + + WalletBlocState copyWith({ + WalletStatus? walletStatus, + DailyStatus? dailyStatus, + }) => + WalletBlocState( + walletStatus: walletStatus ?? this.walletStatus, + dailyStatus: dailyStatus ?? this.dailyStatus, + ); +} diff --git a/lib/feature/wallet/presentation/bloc/wallet_status.dart b/lib/feature/wallet/presentation/bloc/wallet_status.dart new file mode 100644 index 0000000..e76c33a --- /dev/null +++ b/lib/feature/wallet/presentation/bloc/wallet_status.dart @@ -0,0 +1,17 @@ +import '../../domain/entities/wallet_entity.dart'; + +abstract class WalletStatus {} + +class WalletInitial extends WalletStatus {} + +class WalletLoading extends WalletStatus {} + +class WalletLoaded extends WalletStatus { + final WalletEntity wallet; + WalletLoaded(this.wallet); +} + +class WalletError extends WalletStatus { + final String message; + WalletError(this.message); +} diff --git a/lib/features/lobby/lobby_screen.dart b/lib/feature/wallet/presentation/screen/lobby_screen.dart similarity index 75% rename from lib/features/lobby/lobby_screen.dart rename to lib/feature/wallet/presentation/screen/lobby_screen.dart index 9207479..f4139e4 100644 --- a/lib/features/lobby/lobby_screen.dart +++ b/lib/feature/wallet/presentation/screen/lobby_screen.dart @@ -3,12 +3,18 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:random_avatar/random_avatar.dart'; -import '../../core/theme/app_theme.dart'; -import '../../core/widgets/game_ui.dart'; -import '../auth/auth_cubit.dart'; -import 'wallet_cubit.dart'; +import '../../../../core/theme/app_theme.dart'; +import '../../../../core/widgets/game_ui.dart'; +import '../../../auth/presentation/bloc/auth_bloc.dart'; +import '../../../auth/presentation/bloc/auth_event.dart'; +import '../../domain/entities/wallet_entity.dart'; +import '../bloc/daily_status.dart'; +import '../bloc/wallet_bloc.dart'; +import '../bloc/wallet_event.dart'; +import '../bloc/wallet_state.dart'; +import '../bloc/wallet_status.dart'; -/// لابی اصلی: کیف‌پول، دکمه بازی، فروشگاه، سکه روزانه (ظاهرِ بازی‌گونه). +/// لابی اصلی: کیف‌پول، دکمه بازی/دورهمی/فروشگاه/سکه روزانه. class LobbyScreen extends StatefulWidget { const LobbyScreen({super.key}); @@ -20,18 +26,33 @@ class _LobbyScreenState extends State { @override void initState() { super.initState(); - context.read().load(); + context.read().add(LoadWalletEvent()); } + void _reload() => context.read().add(LoadWalletEvent()); + @override Widget build(BuildContext context) { return Scaffold( body: GameBackground( - child: BlocBuilder( + child: BlocConsumer( + listenWhen: (a, b) => a.dailyStatus != b.dailyStatus, + listener: (context, state) { + final d = state.dailyStatus; + if (d is DailySuccess) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('سکه روزانه دریافت شد: +${d.amount}'))); + } else if (d is DailyError) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(d.message))); + } + }, builder: (context, state) { + final st = state.walletStatus; + final wallet = st is WalletLoaded ? st.wallet : null; return Column( children: [ - _TopBar(walletState: state, onCoinTap: () => _openShop(context)), + _TopBar(wallet: wallet, onCoinTap: () => _openShop(context)), Expanded( child: Center( child: SingleChildScrollView( @@ -48,9 +69,7 @@ class _LobbyScreenState extends State { colors: const [Color(0xFFC2185B), Color(0xFF6A0D38)], onTap: () async { await context.push('/game/tiers'); - if (context.mounted) { - context.read().load(); - } + if (context.mounted) _reload(); }, ), const SizedBox(height: 16), @@ -61,9 +80,7 @@ class _LobbyScreenState extends State { colors: const [Color(0xFF1565C0), Color(0xFF0A2E57)], onTap: () async { await context.push('/private'); - if (context.mounted) { - context.read().load(); - } + if (context.mounted) _reload(); }, ), const SizedBox(height: 16), @@ -80,7 +97,8 @@ class _LobbyScreenState extends State { icon: Icons.monetization_on, width: double.infinity, colors: const [Color(0xFF3FA34D), Color(0xFF1B5E20)], - onTap: () => _claimDaily(context), + onTap: () => + context.read().add(ClaimDailyEvent()), ), ], ), @@ -88,9 +106,9 @@ class _LobbyScreenState extends State { ), ), TextButton.icon( - onPressed: () async { - await context.read().logout(); - if (context.mounted) context.go('/login'); + onPressed: () { + context.read().add(LogoutEvent()); + context.go('/login'); }, icon: const Icon(Icons.logout, color: Colors.white54), label: const Text('خروج', @@ -107,30 +125,18 @@ class _LobbyScreenState extends State { Future _openShop(BuildContext context) async { await context.push('/shop'); - if (context.mounted) context.read().load(); - } - - Future _claimDaily(BuildContext context) async { - final amount = await context.read().claimDaily(); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(amount != null - ? 'سکه روزانه دریافت شد: +$amount' - : 'سکه روزانه را قبلاً امروز گرفته‌اید'), - ), - ); + if (context.mounted) _reload(); } } class _TopBar extends StatelessWidget { - final WalletState walletState; + final WalletEntity? wallet; final VoidCallback onCoinTap; - const _TopBar({required this.walletState, required this.onCoinTap}); + const _TopBar({required this.wallet, required this.onCoinTap}); @override Widget build(BuildContext context) { - final w = walletState.wallet; + final w = wallet; return Container( margin: const EdgeInsets.all(8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), @@ -149,7 +155,9 @@ class _TopBar extends StatelessWidget { GestureDetector( onTap: () async { await context.push('/profile'); - if (context.mounted) context.read().load(); + if (context.mounted) { + context.read().add(LoadWalletEvent()); + } }, child: Container( width: 48, @@ -160,9 +168,9 @@ class _TopBar extends StatelessWidget { color: AppColors.panel, border: Border.all(color: AppColors.gold, width: 2), ), - child: walletState.avatar.isEmpty + child: (w == null || w.avatar.isEmpty) ? const Icon(Icons.person, color: AppColors.gold) - : ClipOval(child: RandomAvatar(walletState.avatar)), + : ClipOval(child: RandomAvatar(w.avatar)), ), ), const SizedBox(width: 10), @@ -171,8 +179,8 @@ class _TopBar extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Row(children: [ - if (walletState.name.isNotEmpty) ...[ - Text(walletState.name, + if (w != null && w.name.isNotEmpty) ...[ + Text(w.name, style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold)), const SizedBox(width: 6), @@ -222,7 +230,6 @@ class _TopBar extends StatelessWidget { } } -/// نشانِ کوچکِ VIP کنار نام در نوار بالا. class _VipTag extends StatelessWidget { const _VipTag(); @override diff --git a/lib/features/auth/auth_cubit.dart b/lib/features/auth/auth_cubit.dart deleted file mode 100644 index 37f1847..0000000 --- a/lib/features/auth/auth_cubit.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:equatable/equatable.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import 'auth_repository.dart'; - -enum AuthStatus { initial, loading, otpSent, authenticated, error } - -class AuthState extends Equatable { - final AuthStatus status; - final String mobile; - final bool needsProfile; // پس از ورود، آیا کاربر باید نام/آواتار انتخاب کند - final String? error; - - const AuthState({ - this.status = AuthStatus.initial, - this.mobile = '', - this.needsProfile = false, - this.error, - }); - - AuthState copyWith( - {AuthStatus? status, - String? mobile, - bool? needsProfile, - String? error}) => - AuthState( - status: status ?? this.status, - mobile: mobile ?? this.mobile, - needsProfile: needsProfile ?? this.needsProfile, - error: error, - ); - - @override - List get props => [status, mobile, needsProfile, error]; -} - -class AuthCubit extends Cubit { - final AuthRepository _repo; - AuthCubit(this._repo) : super(const AuthState()); - - Future requestOtp(String mobile) async { - emit(state.copyWith(status: AuthStatus.loading, mobile: mobile)); - try { - await _repo.requestOtp(mobile); - emit(state.copyWith(status: AuthStatus.otpSent, mobile: mobile)); - } catch (e) { - emit(state.copyWith(status: AuthStatus.error, error: _msg(e))); - } - } - - Future verifyOtp(String code) async { - emit(state.copyWith(status: AuthStatus.loading)); - try { - final hasName = await _repo.verifyOtp(state.mobile, code); - emit(state.copyWith( - status: AuthStatus.authenticated, needsProfile: !hasName)); - } catch (e) { - emit(state.copyWith(status: AuthStatus.error, error: _msg(e))); - } - } - - /// ذخیره‌ی نام و آواتار؛ سپس نیازی به صفحه‌ی پروفایل نیست. - Future saveProfile(String name, String avatar) async { - try { - await _repo.updateProfile(name, avatar); - emit(state.copyWith(needsProfile: false)); - return true; - } catch (_) { - return false; - } - } - - Future logout() async { - await _repo.logout(); - emit(const AuthState()); - } - - /// بازنشانی وضعیت خطا به حالت مناسب فرم. - void resetError({required bool onOtpScreen}) { - emit(state.copyWith( - status: onOtpScreen ? AuthStatus.otpSent : AuthStatus.initial)); - } - - String _msg(Object e) { - if (e is DioException) { - final data = e.response?.data; - if (data is Map && data['message'] != null) { - return data['message'].toString(); - } - return 'خطا در ارتباط با سرور'; - } - return 'خطای نامشخص'; - } -} diff --git a/lib/features/auth/auth_repository.dart b/lib/features/auth/auth_repository.dart deleted file mode 100644 index 97be381..0000000 --- a/lib/features/auth/auth_repository.dart +++ /dev/null @@ -1,45 +0,0 @@ -import '../../core/network/api_client.dart'; -import '../../core/storage/token_storage.dart'; - -/// دسترسی به endpointهای احراز هویت (login-otp / check-otp). -class AuthRepository { - final ApiClient _api; - final TokenStorage _storage; - - AuthRepository(this._api, this._storage); - - /// درخواست ارسال کد یک‌بارمصرف. - Future requestOtp(String mobile) async { - await _api.dio.post('/auth/login-otp', data: {'mobile': mobile}); - } - - /// اعتبارسنجی کد، ذخیره‌ی توکن JWT و بازگرداندنِ اینکه کاربر نام دارد یا نه. - /// اگر نام نداشته باشد، فرانت کاربر را به صفحه‌ی انتخاب نام/آواتار می‌برد. - Future verifyOtp(String mobile, String code) async { - final res = await _api.dio.post( - '/auth/check-otp', - data: {'mobile': mobile, 'token': code}, - ); - final token = res.data['token'] as String?; - if (token == null || token.isEmpty) { - throw Exception('no token in response'); - } - await _storage.write(token); - final user = res.data['user']; - final name = (user is Map) ? user['first_name'] : null; - return name is String && name.trim().isNotEmpty; - } - - /// تنظیم نام نمایشی و آواتار. - Future updateProfile(String firstName, String avatar) async { - await _api.dio.post('/profile', - data: {'first_name': firstName, 'avatar': avatar}); - } - - Future isLoggedIn() async { - final t = await _storage.read(); - return t != null && t.isNotEmpty; - } - - Future logout() => _storage.clear(); -} diff --git a/lib/features/auth/profile_setup_screen.dart b/lib/features/auth/profile_setup_screen.dart deleted file mode 100644 index 9f42bf1..0000000 --- a/lib/features/auth/profile_setup_screen.dart +++ /dev/null @@ -1,144 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; -import 'package:random_avatar/random_avatar.dart'; - -import '../../core/theme/app_theme.dart'; -import '../../core/widgets/game_ui.dart'; -import 'auth_cubit.dart'; - -/// صفحه‌ی انتخاب نام و آواتار پس از اولین ورود. -/// آواتارها با پکیج random_avatar تولید می‌شوند (رایگان، بدون نیاز به asset). -class ProfileSetupScreen extends StatefulWidget { - const ProfileSetupScreen({super.key}); - - @override - State createState() => _ProfileSetupScreenState(); -} - -class _ProfileSetupScreenState extends State { - final _name = TextEditingController(); - bool _saving = false; - - // مجموعه‌ای از seedها؛ هر seed یک آواتارِ یکتا می‌سازد. - late List _seeds; - int _selected = 0; - - @override - void initState() { - super.initState(); - _seeds = List.generate(12, (i) => 'hakem-${i + 1}'); - } - - @override - void dispose() { - _name.dispose(); - super.dispose(); - } - - bool get _valid => _name.text.trim().length >= 2; - - Future _save() async { - setState(() => _saving = true); - final ok = await context - .read() - .saveProfile(_name.text.trim(), _seeds[_selected]); - if (!mounted) return; - setState(() => _saving = false); - if (ok) { - context.go('/lobby'); - } else { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('ذخیره نشد، دوباره تلاش کنید')), - ); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: GameBackground( - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const GlowText('انتخاب نام و آواتار', size: 26), - const SizedBox(height: 20), - GamePanel( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // پیش‌نمایشِ آواتارِ انتخاب‌شده - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: AppColors.gold, width: 2), - ), - child: RandomAvatar(_seeds[_selected], - height: 84, width: 84), - ), - const SizedBox(height: 14), - TextField( - controller: _name, - textAlign: TextAlign.center, - maxLength: 20, - inputFormatters: [ - LengthLimitingTextInputFormatter(20), - ], - decoration: const InputDecoration( - hintText: 'نام نمایشی (۲ تا ۲۰ حرف)', - counterText: ''), - onChanged: (_) => setState(() {}), - ), - const SizedBox(height: 14), - const Text('یک آواتار انتخاب کن', - style: TextStyle(color: AppColors.gold)), - const SizedBox(height: 10), - GridView.count( - crossAxisCount: 4, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - mainAxisSpacing: 10, - crossAxisSpacing: 10, - children: [ - for (var i = 0; i < _seeds.length; i++) - GestureDetector( - onTap: () => setState(() => _selected = i), - child: Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: AppColors.bgDark, - border: Border.all( - color: _selected == i - ? AppColors.gold - : Colors.transparent, - width: 2.5, - ), - ), - child: RandomAvatar(_seeds[i]), - ), - ), - ], - ), - const SizedBox(height: 18), - GameButton( - label: _saving ? 'در حال ذخیره…' : 'تأیید و ورود', - width: double.infinity, - colors: const [Color(0xFF3FA34D), Color(0xFF1B5E20)], - onTap: (!_valid || _saving) ? null : _save, - ), - ], - ), - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/lib/features/game/game_cubit.dart b/lib/features/game/game_cubit.dart deleted file mode 100644 index 14bf294..0000000 --- a/lib/features/game/game_cubit.dart +++ /dev/null @@ -1,188 +0,0 @@ -import 'dart:async'; - -import 'package:equatable/equatable.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import '../../core/network/ws_client.dart'; -import 'game_models.dart'; - -/// یک بازیکن در اتاق انتظارِ میز خصوصی. -class LobbyPlayer { - final String name; - final bool host; - const LobbyPlayer(this.name, this.host); -} - -/// وضعیت اتاق انتظارِ میز خصوصی (دورهمی). -class TableLobby { - final String code; - final List players; - final bool isHost; - final int remaining; - final bool unlimited; - const TableLobby({ - required this.code, - required this.players, - required this.isHost, - required this.remaining, - required this.unlimited, - }); - - factory TableLobby.fromJson(Map j) => TableLobby( - code: (j['code'] ?? '') as String, - players: ((j['players'] as List?) ?? []) - .map((e) => LobbyPlayer( - (e['name'] ?? '') as String, (e['host'] ?? false) as bool)) - .toList(), - isHost: (j['host'] ?? false) as bool, - remaining: (j['remaining'] ?? 0) as int, - unlimited: (j['unlimited'] ?? false) as bool, - ); - - String get sig => '$code|$isHost|$remaining|$unlimited|' - '${players.map((p) => '${p.name}${p.host ? '*' : ''}').join(',')}'; -} - -class GameUiState extends Equatable { - final WsStatus connection; - final GameState? state; - final HandResult? handResult; // اوورلی نتیجه‌ی هَند (گذرا) - final GameOver? gameOver; // اوورلی پایان بازی - final String? notice; // پیام گذرا (خطا/خروج بازیکن) - final TableLobby? lobby; // اتاق انتظارِ میز خصوصی (پیش از شروع) - final int? countdown; // شمارش معکوس پیش از شروعِ بازیِ خصوصی - final bool tableClosed; // میز خصوصی منحل شد (میزبان خارج شد) - - const GameUiState({ - this.connection = WsStatus.connecting, - this.state, - this.handResult, - this.gameOver, - this.notice, - this.lobby, - this.countdown, - this.tableClosed = false, - }); - - GameUiState copyWith({ - WsStatus? connection, - GameState? state, - HandResult? handResult, - GameOver? gameOver, - String? notice, - TableLobby? lobby, - int? countdown, - bool? tableClosed, - bool clearHandResult = false, - bool clearNotice = false, - }) => - GameUiState( - connection: connection ?? this.connection, - state: state ?? this.state, - handResult: clearHandResult ? null : (handResult ?? this.handResult), - gameOver: gameOver ?? this.gameOver, - notice: clearNotice ? null : (notice ?? this.notice), - lobby: lobby ?? this.lobby, - countdown: countdown ?? this.countdown, - tableClosed: tableClosed ?? this.tableClosed, - ); - - @override - List get props => [ - connection, - state, - handResult, - gameOver, - notice, - lobby?.sig, - countdown, - tableClosed, - ]; -} - -class GameCubit extends Cubit { - final WsClient _ws; - final String tier; - - /// اقدامِ ورود پس از اتصال (یک‌بار). پیش‌فرض: ورود به صفِ عمومی. - /// برای میز خصوصی: {'type':'create_table'} یا {'type':'join_table','code':...}. - final Map _joinAction; - bool _joined = false; - - late final StreamSubscription _msgSub; - late final StreamSubscription _statusSub; - - GameCubit(this._ws, this.tier, {Map? joinAction}) - : _joinAction = joinAction ?? {'type': 'join_queue', 'tier': tier}, - super(const GameUiState()) { - _msgSub = _ws.messages.listen(_onMessage); - _statusSub = _ws.status.listen(_onStatus); - _ws.connect(); - } - - /// سازنده‌ی میز خصوصی: ساختِ میز جدید. - GameCubit.createPrivate(WsClient ws) - : this(ws, 'private', joinAction: {'type': 'create_table'}); - - /// سازنده‌ی میز خصوصی: پیوستن با کد. - GameCubit.joinPrivate(WsClient ws, String code) - : this(ws, 'private', joinAction: {'type': 'join_table', 'code': code}); - - void _onStatus(WsStatus s) { - emit(state.copyWith(connection: s)); - // اقدامِ ورود فقط یک‌بار در اولین اتصال؛ در reconnect سرور خودش بازیکن را - // به میز برمی‌گرداند (نباید دوباره create/join فرستاده شود). - if (s == WsStatus.connected && !_joined) { - _joined = true; - _ws.send(_joinAction); - } - } - - void _onMessage(Map msg) { - switch (msg['type']) { - case 'state': - final gs = GameState.fromJson(msg); - // با شروع دست/هَند جدید، اوورلی نتیجه پاک می‌شود. - final clear = gs.phase == 'choose_trump' || gs.phase == 'playing'; - emit(state.copyWith(state: gs, clearHandResult: clear)); - case 'hand_over': - emit(state.copyWith(handResult: HandResult.fromJson(msg))); - case 'game_over': - emit(state.copyWith(gameOver: GameOver.fromJson(msg))); - case 'table_lobby': - emit(state.copyWith(lobby: TableLobby.fromJson(msg))); - case 'countdown': - emit(state.copyWith(countdown: (msg['seconds'] ?? 3) as int)); - case 'table_closed': - emit(state.copyWith(tableClosed: true, notice: 'میز توسط میزبان بسته شد')); - case 'player_disconnected': - emit(state.copyWith(notice: 'یک بازیکن قطع شد')); - case 'player_reconnected': - emit(state.copyWith(notice: 'بازیکن بازگشت')); - case 'player_left': - emit(state.copyWith(notice: 'یک بازیکن میز را ترک کرد')); - case 'error': - emit(state.copyWith(notice: (msg['message'] ?? 'خطا').toString())); - } - } - - void chooseTrump(String suit) => _ws.send({'type': 'choose_trump', 'suit': suit}); - - void playCard(String card) => _ws.send({'type': 'play_card', 'card': card}); - - void leave() => _ws.send({'type': 'leave'}); - - void startTable() => _ws.send({'type': 'start_table'}); - - void leaveTable() => _ws.send({'type': 'leave_table'}); - - void clearNotice() => emit(state.copyWith(clearNotice: true)); - - @override - Future close() { - _msgSub.cancel(); - _statusSub.cancel(); - _ws.dispose(); - return super.close(); - } -} diff --git a/lib/features/game/game_repository.dart b/lib/features/game/game_repository.dart deleted file mode 100644 index e9ddf4b..0000000 --- a/lib/features/game/game_repository.dart +++ /dev/null @@ -1,17 +0,0 @@ -import '../../core/network/api_client.dart'; -import 'tier.dart'; - -/// واکشی انواع میز برای صفحه‌ی لیست میزها. -class GameRepository { - final ApiClient _api; - GameRepository(this._api); - - Future> getTiers() async { - final res = await _api.dio.get('/shop'); - final cat = Map.from(res.data['catalog'] as Map); - final list = (cat['table_tiers'] as List?) ?? []; - return list - .map((e) => TableTier.fromJson(Map.from(e as Map))) - .toList(); - } -} diff --git a/lib/features/game/tier.dart b/lib/features/game/tier.dart deleted file mode 100644 index a4a9e58..0000000 --- a/lib/features/game/tier.dart +++ /dev/null @@ -1,19 +0,0 @@ -// نوع میز (از catalog.table_tiers در GET /api/shop). -class TableTier { - final String id; - final String title; - final int hands; - final int entry; - final int prize; - final int xp; - final int trophy; - - TableTier.fromJson(Map j) - : id = j['id'] as String, - title = j['title'] as String, - hands = (j['hands'] ?? 0) as int, - entry = (j['entry'] ?? 0) as int, - prize = (j['prize'] ?? 0) as int, - xp = (j['xp'] ?? 0) as int, - trophy = (j['trophy'] ?? 0) as int; -} diff --git a/lib/features/lobby/wallet.dart b/lib/features/lobby/wallet.dart deleted file mode 100644 index f567046..0000000 --- a/lib/features/lobby/wallet.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// وضعیت اقتصادی کاربر (پاسخ GET /api/wallet). -class Wallet extends Equatable { - final int coins; - final int tickets; - final int xp; - final int trophies; - final int level; - final int xpIntoLevel; - final int xpForNext; - final bool vip; - final String selectedCard; - - const Wallet({ - required this.coins, - required this.tickets, - required this.xp, - required this.trophies, - required this.level, - required this.xpIntoLevel, - required this.xpForNext, - required this.vip, - required this.selectedCard, - }); - - factory Wallet.fromJson(Map j) => Wallet( - coins: (j['coins'] ?? 0) as int, - tickets: (j['tickets'] ?? 0) as int, - xp: (j['xp'] ?? 0) as int, - trophies: (j['trophies'] ?? 0) as int, - level: (j['level'] ?? 1) as int, - xpIntoLevel: (j['xp_into_level'] ?? 0) as int, - xpForNext: (j['xp_for_next'] ?? 1) as int, - vip: (j['vip'] ?? false) as bool, - selectedCard: (j['selected_card'] ?? 'simple') as String, - ); - - @override - List get props => - [coins, tickets, xp, trophies, level, xpIntoLevel, xpForNext, vip, selectedCard]; -} diff --git a/lib/features/lobby/wallet_cubit.dart b/lib/features/lobby/wallet_cubit.dart deleted file mode 100644 index 67eb5d2..0000000 --- a/lib/features/lobby/wallet_cubit.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:equatable/equatable.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import '../../core/network/api_client.dart'; -import 'wallet.dart'; - -enum WalletStatus { initial, loading, loaded, error } - -class WalletState extends Equatable { - final WalletStatus status; - final Wallet? wallet; - final String name; // نام نمایشی (برای نوار بالای لابی) - final String avatar; // seed آواتار - - const WalletState({ - this.status = WalletStatus.initial, - this.wallet, - this.name = '', - this.avatar = '', - }); - - @override - List get props => [status, wallet, name, avatar]; -} - -class WalletCubit extends Cubit { - final ApiClient _api; - WalletCubit(this._api) : super(const WalletState()); - - Future load() async { - emit(WalletState( - status: WalletStatus.loading, - wallet: state.wallet, - name: state.name, - avatar: state.avatar)); - try { - final results = await Future.wait([ - _api.dio.get('/wallet'), - _api.dio.get('/me'), - ]); - final user = (results[1].data['user'] ?? {}) as Map; - final name = (user['first_name'] as String?)?.trim(); - final avatar = (user['avatar'] as String?)?.trim(); - emit(WalletState( - status: WalletStatus.loaded, - wallet: Wallet.fromJson(Map.from(results[0].data)), - name: (name == null || name.isEmpty) ? 'بازیکن' : name, - avatar: (avatar == null || avatar.isEmpty) ? 'hakem-1' : avatar, - )); - } catch (_) { - emit(const WalletState(status: WalletStatus.error)); - } - } - - /// دریافت سکه روزانه و سپس به‌روزرسانی کیف‌پول. - Future claimDaily() async { - try { - final res = await _api.dio.post('/rewards/daily'); - await load(); - return (res.data['amount'] ?? 0) as int; - } catch (_) { - return null; - } - } -} diff --git a/lib/features/private/private_entry_screen.dart b/lib/features/private/private_entry_screen.dart deleted file mode 100644 index 795a722..0000000 --- a/lib/features/private/private_entry_screen.dart +++ /dev/null @@ -1,162 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:go_router/go_router.dart'; - -import '../../core/network/api_client.dart'; -import '../../core/theme/app_theme.dart'; -import '../../core/widgets/game_ui.dart'; - -/// صفحه‌ی دورهمی: پیوستن با شماره میز یا ساختِ میز جدید. -class PrivateEntryScreen extends StatefulWidget { - final ApiClient api; - const PrivateEntryScreen({super.key, required this.api}); - - @override - State createState() => _PrivateEntryScreenState(); -} - -class _PrivateEntryScreenState extends State { - final _code = TextEditingController(); - int _remaining = 0; - bool _unlimited = false; - bool _loading = true; - - @override - void initState() { - super.initState(); - _loadInfo(); - } - - @override - void dispose() { - _code.dispose(); - super.dispose(); - } - - Future _loadInfo() async { - try { - final res = await widget.api.dio.get('/tables/info'); - final d = res.data as Map; - setState(() { - _remaining = (d['remaining'] ?? 0) as int; - _unlimited = (d['unlimited'] ?? false) as bool; - _loading = false; - }); - } catch (_) { - setState(() => _loading = false); - } - } - - bool get _canCreate => _unlimited || _remaining > 0; - - void _join() { - final code = _code.text.trim(); - if (code.length < 4) return; - context.push('/private/room?join=$code'); - } - - void _create() { - if (!_canCreate) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar( - content: Text('سهمیه‌ی میزهای رایگان تمام شده؛ با VIP نامحدود بسازید'))); - return; - } - context.push('/private/room?create=1').then((_) => _loadInfo()); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: GameBackground( - child: SafeArea( - child: Column( - children: [ - Align( - alignment: Alignment.centerRight, - child: Padding( - padding: const EdgeInsets.all(10), - child: GestureDetector( - onTap: () => context.pop(), - child: Container( - width: 46, - height: 46, - decoration: BoxDecoration( - color: AppColors.panel, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.gold, width: 1.5), - ), - child: const Icon(Icons.arrow_back, color: AppColors.gold), - ), - ), - ), - ), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Column( - children: [ - const SizedBox(height: 8), - const Icon(Icons.person, color: AppColors.gold, size: 56), - const SizedBox(height: 12), - TextField( - controller: _code, - textAlign: TextAlign.center, - keyboardType: TextInputType.number, - style: const TextStyle(fontSize: 22, letterSpacing: 6), - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(5), - ], - decoration: const InputDecoration(hintText: 'شماره میز'), - onChanged: (_) => setState(() {}), - ), - const SizedBox(height: 16), - GameButton( - label: 'پیوستن', - width: double.infinity, - colors: const [Color(0xFFC2185B), Color(0xFF6A0D38)], - onTap: _code.text.trim().length >= 4 ? _join : null, - ), - const SizedBox(height: 8), - const Text('برای ورود، شماره میز را وارد کنید.', - style: TextStyle(color: Colors.white60, fontSize: 13)), - const SizedBox(height: 24), - Divider(color: AppColors.goldDark.withValues(alpha: 0.5)), - const SizedBox(height: 16), - Text( - _loading - ? '...' - : _unlimited - ? 'میزهای نامحدود (VIP)' - : 'میزهای رایگان باقیمانده: $_remaining', - style: const TextStyle( - color: AppColors.gold, - fontSize: 14, - fontWeight: FontWeight.bold), - ), - const SizedBox(height: 12), - const Icon(Icons.groups, color: AppColors.gold, size: 56), - const SizedBox(height: 12), - GameButton( - label: 'ساخت میز', - width: double.infinity, - colors: _canCreate - ? const [Color(0xFFC2185B), Color(0xFF6A0D38)] - : const [Color(0xFF555555), Color(0xFF333333)], - onTap: _loading ? null : _create, - ), - const SizedBox(height: 8), - const Text('میز جدید بساز و دوستانت را دعوت کن', - style: TextStyle(color: Colors.white60, fontSize: 13)), - const SizedBox(height: 24), - ], - ), - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/features/profile/profile_screen.dart b/lib/features/profile/profile_screen.dart deleted file mode 100644 index 85c3bd1..0000000 --- a/lib/features/profile/profile_screen.dart +++ /dev/null @@ -1,535 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart' show LengthLimitingTextInputFormatter; -import 'package:go_router/go_router.dart'; -import 'package:random_avatar/random_avatar.dart'; - -import '../../core/network/api_client.dart'; -import '../../core/theme/app_theme.dart'; -import '../../core/widgets/game_ui.dart'; - -/// صفحه‌ی پروفایل: نام، آواتار، سطح، جام‌ها و آمارِ بازی. -/// نمایشِ آمار ویژه‌ی کاربرانِ VIP است (سرور هم این محدودیت را اعمال می‌کند). -class ProfileScreen extends StatefulWidget { - final ApiClient api; - const ProfileScreen({super.key, required this.api}); - - @override - State createState() => _ProfileScreenState(); -} - -class _ProfileData { - final String name; - final String avatar; - final String mobile; - final int level; - final int trophies; - final int xpInto; - final int xpNext; - final bool vip; - final Map? stats; // null یعنی قفل (غیر VIP) - - _ProfileData({ - required this.name, - required this.avatar, - required this.mobile, - required this.level, - required this.trophies, - required this.xpInto, - required this.xpNext, - required this.vip, - required this.stats, - }); -} - -class _ProfileScreenState extends State { - late Future<_ProfileData> _future; - - @override - void initState() { - super.initState(); - _future = _load(); - } - - Future<_ProfileData> _load() async { - final dio = widget.api.dio; - final res = await Future.wait([ - dio.get('/me'), - dio.get('/wallet'), - dio.get('/stats'), - ]); - final user = (res[0].data['user'] ?? {}) as Map; - final w = (res[1].data ?? {}) as Map; - final s = (res[2].data ?? {}) as Map; - final name = (user['first_name'] as String?)?.trim(); - final avatar = (user['avatar'] as String?)?.trim(); - return _ProfileData( - name: (name == null || name.isEmpty) ? 'بازیکن' : name, - avatar: (avatar == null || avatar.isEmpty) ? 'hakem-1' : avatar, - mobile: (user['mobile'] as String?) ?? '', - level: (w['level'] ?? 1) as int, - trophies: (w['trophies'] ?? 0) as int, - xpInto: (w['xp_into_level'] ?? 0) as int, - xpNext: (w['xp_for_next'] ?? 1) as int, - vip: (s['vip'] ?? false) as bool, - stats: (s['stats'] as Map?)?.cast(), - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: GameBackground( - child: SafeArea( - child: FutureBuilder<_ProfileData>( - future: _future, - builder: (context, snap) { - if (snap.connectionState != ConnectionState.done) { - return const Center( - child: CircularProgressIndicator(color: AppColors.gold), - ); - } - if (snap.hasError || !snap.hasData) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - 'خطا در دریافت پروفایل', - style: TextStyle(color: Colors.white70), - ), - const SizedBox(height: 12), - GameButton( - label: 'تلاش دوباره', - onTap: () => setState(() { _future = _load(); }), - ), - ], - ), - ); - } - return _content(context, snap.data!); - }, - ), - ), - ), - ); - } - - // ویرایش نام و آواتار؛ پس از ذخیره، پروفایل دوباره بارگذاری می‌شود. - Future _editProfile(_ProfileData d) async { - final result = await showModalBottomSheet>( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (_) => _EditProfileSheet(name: d.name, avatar: d.avatar), - ); - if (result == null || !mounted) return; - try { - await widget.api.dio.post( - '/profile', - data: {'first_name': result['name'], 'avatar': result['avatar']}, - ); - if (!mounted) return; - setState(() { _future = _load(); }); - } catch (e) { - print(e); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('ذخیره نشد، دوباره تلاش کنید')), - ); - } - } - - Widget _content(BuildContext context, _ProfileData d) { - return SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - Row( - children: [ - IconButton( - onPressed: () => context.pop(), - icon: const Icon(Icons.arrow_back, color: AppColors.gold), - ), - const Spacer(), - const GlowText('پروفایل', size: 24), - const Spacer(), - const SizedBox(width: 48), - ], - ), - const SizedBox(height: 8), - GamePanel( - child: Column( - children: [ - Stack( - children: [ - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: AppColors.gold, width: 2.5), - ), - child: RandomAvatar(d.avatar, height: 92, width: 92), - ), - Positioned( - bottom: 0, - right: 0, - child: GestureDetector( - onTap: () => _editProfile(d), - child: Container( - padding: const EdgeInsets.all(6), - decoration: const BoxDecoration( - shape: BoxShape.circle, - gradient: LinearGradient( - colors: [Color(0xFFFFD54F), Color(0xFFB8860B)], - ), - ), - child: const Icon( - Icons.edit, - color: Color(0xFF3A0A12), - size: 18, - ), - ), - ), - ), - ], - ), - const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Flexible(child: GlowText(d.name, size: 22)), - if (d.vip) ...[const SizedBox(width: 8), const _VipBadge()], - ], - ), - if (d.mobile.isNotEmpty) - Text( - d.mobile, - style: const TextStyle(color: Colors.white38, fontSize: 12), - ), - const SizedBox(height: 14), - Row( - children: [ - Expanded( - child: _MiniStat( - icon: Icons.star, - label: 'سطح', - value: '${d.level}', - ), - ), - Expanded( - child: _MiniStat( - icon: Icons.emoji_events, - label: 'جام', - value: '${d.trophies}', - ), - ), - ], - ), - const SizedBox(height: 10), - ClipRRect( - borderRadius: BorderRadius.circular(5), - child: LinearProgressIndicator( - value: d.xpNext == 0 ? 0 : d.xpInto / d.xpNext, - minHeight: 8, - backgroundColor: Colors.white10, - valueColor: const AlwaysStoppedAnimation(AppColors.gold), - ), - ), - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( - '${d.xpInto} / ${d.xpNext} XP', - style: const TextStyle(color: Colors.white38, fontSize: 11), - ), - ), - ], - ), - ), - const SizedBox(height: 16), - const Align( - alignment: Alignment.centerRight, - child: GlowText('آمار بازی', size: 18), - ), - const SizedBox(height: 8), - _statsSection(context, d), - ], - ), - ); - } - - Widget _statsSection(BuildContext context, _ProfileData d) { - final rows = [ - _StatRow('بازی کل', d.stats?['games'], Icons.casino), - _StatRow('برد کل', d.stats?['wins'], Icons.thumb_up), - _StatRow('باخت کل', d.stats?['losses'], Icons.thumb_down), - _StatRow('کُت کردن', d.stats?['kot_made'], Icons.flash_on), - _StatRow('کُت شدن', d.stats?['kot_received'], Icons.flash_off), - _StatRow('بریدن', d.stats?['cuts'], Icons.bolt), - _StatRow('دست حاکم', d.stats?['hakem_count'], Icons.workspace_premium), - ]; - - final panel = GamePanel(child: Column(children: rows)); - if (d.vip) return panel; - - // غیر VIP: آمار قفل است؛ روی آن لایه‌ی قفل و دعوت به اشتراک نشان بده. - return Stack( - children: [ - // محتوای محو زیرِ قفل (مقادیر نامشخص) - Opacity(opacity: 0.35, child: IgnorePointer(child: panel)), - Positioned.fill( - child: Container( - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.45), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.goldDark), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.lock, color: AppColors.gold, size: 36), - const SizedBox(height: 8), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 24), - child: Text( - 'مشاهده‌ی آمار ویژه‌ی کاربران VIP است', - textAlign: TextAlign.center, - style: TextStyle(color: Colors.white, fontSize: 14), - ), - ), - const SizedBox(height: 12), - GameButton( - label: 'تهیه اشتراک VIP', - icon: Icons.workspace_premium, - colors: const [Color(0xFFFFC107), Color(0xFFB8860B)], - onTap: () async { - await context.push('/vip'); - if (context.mounted) setState(() { _future = _load(); }); - }, - ), - ], - ), - ), - ), - ], - ); - } -} - -class _StatRow extends StatelessWidget { - final String label; - final Object? value; - final IconData icon; - const _StatRow(this.label, this.value, this.icon); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 7), - child: Row( - children: [ - Icon(icon, color: AppColors.gold, size: 20), - const SizedBox(width: 10), - Text( - label, - style: const TextStyle(color: Colors.white, fontSize: 15), - ), - const Spacer(), - Text( - '${value ?? '—'}', - style: const TextStyle( - color: AppColors.gold, - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ); - } -} - -class _MiniStat extends StatelessWidget { - final IconData icon; - final String label; - final String value; - const _MiniStat({ - required this.icon, - required this.label, - required this.value, - }); - - @override - Widget build(BuildContext context) { - return Column( - children: [ - Icon(icon, color: AppColors.gold, size: 22), - const SizedBox(height: 2), - Text( - value, - style: const TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - Text( - label, - style: const TextStyle(color: Colors.white54, fontSize: 12), - ), - ], - ); - } -} - -/// شیتِ ویرایش نام و آواتار (با کلیک «تأیید» مقدار جدید برگردانده می‌شود). -class _EditProfileSheet extends StatefulWidget { - final String name; - final String avatar; - const _EditProfileSheet({required this.name, required this.avatar}); - - @override - State<_EditProfileSheet> createState() => _EditProfileSheetState(); -} - -class _EditProfileSheetState extends State<_EditProfileSheet> { - late final TextEditingController _name; - late final List _seeds; - late String _selected; - - @override - void initState() { - super.initState(); - _name = TextEditingController(text: widget.name); - _seeds = List.generate(12, (i) => 'hakem-${i + 1}'); - // آواتارِ فعلی را در شبکه نگه دار حتی اگر جزو seedهای پیش‌فرض نباشد. - if (!_seeds.contains(widget.avatar)) _seeds.insert(0, widget.avatar); - _selected = widget.avatar; - } - - @override - void dispose() { - _name.dispose(); - super.dispose(); - } - - bool get _valid => _name.text.trim().length >= 2; - - @override - Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: Container( - decoration: const BoxDecoration( - color: AppColors.bgDark, - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - border: Border(top: BorderSide(color: AppColors.gold, width: 2)), - ), - padding: const EdgeInsets.all(18), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const GlowText('ویرایش پروفایل', size: 20), - const SizedBox(height: 14), - Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: AppColors.gold, width: 2), - ), - child: RandomAvatar(_selected, height: 72, width: 72), - ), - const SizedBox(height: 12), - TextField( - controller: _name, - textAlign: TextAlign.center, - maxLength: 20, - inputFormatters: [LengthLimitingTextInputFormatter(20)], - decoration: const InputDecoration( - hintText: 'نام نمایشی (۲ تا ۲۰ حرف)', - counterText: '', - ), - onChanged: (_) => setState(() {}), - ), - const SizedBox(height: 10), - const Align( - alignment: Alignment.centerRight, - child: Text( - 'انتخاب آواتار', - style: TextStyle(color: AppColors.gold), - ), - ), - const SizedBox(height: 8), - GridView.count( - crossAxisCount: 4, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - mainAxisSpacing: 10, - crossAxisSpacing: 10, - children: [ - for (final s in _seeds) - GestureDetector( - onTap: () => setState(() => _selected = s), - child: Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: AppColors.panel, - border: Border.all( - color: - _selected == s - ? AppColors.gold - : Colors.transparent, - width: 2.5, - ), - ), - child: RandomAvatar(s), - ), - ), - ], - ), - const SizedBox(height: 16), - GameButton( - label: 'تأیید', - width: double.infinity, - colors: const [Color(0xFF3FA34D), Color(0xFF1B5E20)], - onTap: - _valid - ? () => Navigator.pop(context, { - 'name': _name.text.trim(), - 'avatar': _selected, - }) - : null, - ), - ], - ), - ), - ), - ); - } -} - -class _VipBadge extends StatelessWidget { - const _VipBadge(); - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [Color(0xFFFFD54F), Color(0xFFB8860B)], - ), - borderRadius: BorderRadius.circular(8), - ), - child: const Text( - 'VIP', - style: TextStyle( - color: Color(0xFF3A0A12), - fontWeight: FontWeight.bold, - fontSize: 12, - ), - ), - ); - } -} diff --git a/lib/features/shop/shop_cubit.dart b/lib/features/shop/shop_cubit.dart deleted file mode 100644 index 6eac90a..0000000 --- a/lib/features/shop/shop_cubit.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:equatable/equatable.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import 'shop_models.dart'; -import 'shop_repository.dart'; - -enum ShopStatus { initial, loading, loaded, error } - -class ShopState extends Equatable { - final ShopStatus status; - final ShopData? data; - final bool busy; // در حال انجام یک عملیات (خرید/انتخاب) - - const ShopState({this.status = ShopStatus.initial, this.data, this.busy = false}); - - ShopState copyWith({ShopStatus? status, ShopData? data, bool? busy}) => ShopState( - status: status ?? this.status, - data: data ?? this.data, - busy: busy ?? this.busy, - ); - - @override - List get props => [status, data, busy]; -} - -class ShopCubit extends Cubit { - final ShopRepository _repo; - ShopCubit(this._repo) : super(const ShopState()); - - Future load() async { - emit(state.copyWith(status: ShopStatus.loading)); - try { - emit(state.copyWith(status: ShopStatus.loaded, data: await _repo.getShop())); - } catch (_) { - emit(state.copyWith(status: ShopStatus.error)); - } - } - - /// یک عملیات را اجرا، فروشگاه را بازخوانی و پیام نتیجه را برمی‌گرداند. - Future _run(Future Function() action, String okMsg) async { - if (state.busy) return ''; - emit(state.copyWith(busy: true)); - try { - await action(); - final data = await _repo.getShop(); - emit(state.copyWith(status: ShopStatus.loaded, data: data, busy: false)); - return okMsg; - } on DioException catch (e) { - emit(state.copyWith(busy: false)); - final d = e.response?.data; - if (d is Map && d['message'] != null) return d['message'].toString(); - return 'خطا در ارتباط با سرور'; - } catch (_) { - emit(state.copyWith(busy: false)); - return 'خطای نامشخص'; - } - } - - Future buyCard(String id) => _run(() => _repo.buyCard(id), 'کارت خریداری شد'); - - Future selectCard(String id) => - _run(() => _repo.selectCard(id), 'کارت انتخاب شد'); - - Future purchase(String kind, String id) => _run( - () => _repo.purchase( - store: 'bazaar', - kind: kind, - productId: id, - token: 'dev-$kind-$id-${DateTime.now().millisecondsSinceEpoch}', - ), - 'خرید با موفقیت انجام شد', - ); - - Future claimAd() => _run( - () => _repo.adReward('dev-ad-${DateTime.now().millisecondsSinceEpoch}'), - 'سکه رایگان دریافت شد', - ); -} diff --git a/lib/features/shop/shop_models.dart b/lib/features/shop/shop_models.dart deleted file mode 100644 index 4ded4a2..0000000 --- a/lib/features/shop/shop_models.dart +++ /dev/null @@ -1,110 +0,0 @@ -// مدل‌های کاتالوگ فروشگاه (پاسخ GET /api/shop). - -class CoinPackage { - final String id; - final String title; - final int coins; - final int vipDays; - final int priceToman; - final int bonusPct; - - CoinPackage.fromJson(Map j) - : id = j['id'] as String, - title = j['title'] as String, - coins = (j['coins'] ?? 0) as int, - vipDays = (j['vip_days'] ?? 0) as int, - priceToman = (j['price_toman'] ?? 0) as int, - bonusPct = (j['bonus_pct'] ?? 0) as int; -} - -class TicketPackage { - final String id; - final String title; - final int tickets; - final int priceToman; - - TicketPackage.fromJson(Map j) - : id = j['id'] as String, - title = j['title'] as String, - tickets = (j['tickets'] ?? 0) as int, - priceToman = (j['price_toman'] ?? 0) as int; -} - -class CardSkin { - final String id; - final String title; - final int priceCoins; - - CardSkin.fromJson(Map j) - : id = j['id'] as String, - title = j['title'] as String, - priceCoins = (j['price_coins'] ?? 0) as int; -} - -class Booster { - final String id; - final String title; - final int multiplier; - final int hours; - final int priceToman; - - Booster.fromJson(Map j) - : id = j['id'] as String, - title = j['title'] as String, - multiplier = (j['multiplier'] ?? 1) as int, - hours = (j['hours'] ?? 0) as int, - priceToman = (j['price_toman'] ?? 0) as int; -} - -class VIPPackage { - final String id; - final String title; - final int months; - final int priceToman; - - VIPPackage.fromJson(Map j) - : id = j['id'] as String, - title = j['title'] as String, - months = (j['months'] ?? 1) as int, - priceToman = (j['price_toman'] ?? 0) as int; -} - -/// داده‌ی کامل فروشگاه: کاتالوگ + کارت‌های متعلق به کاربر + کارت انتخابی. -class ShopData { - final List coinPackages; - final List ticketPackages; - final List cardSkins; - final List boosters; - final List vipPackages; - final List ownedCards; - final String selectedCard; - - ShopData({ - required this.coinPackages, - required this.ticketPackages, - required this.cardSkins, - required this.boosters, - required this.vipPackages, - required this.ownedCards, - required this.selectedCard, - }); - - factory ShopData.fromJson(Map j) { - final cat = Map.from(j['catalog'] as Map); - List parse(String key, T Function(Map) f) => - ((cat[key] as List?) ?? []) - .map((e) => f(Map.from(e as Map))) - .toList(); - return ShopData( - coinPackages: parse('coin_packages', CoinPackage.fromJson), - ticketPackages: parse('ticket_packages', TicketPackage.fromJson), - cardSkins: parse('card_skins', CardSkin.fromJson), - boosters: parse('boosters', Booster.fromJson), - vipPackages: parse('vip_packages', VIPPackage.fromJson), - ownedCards: ((j['owned_cards'] as List?) ?? []).map((e) => e as String).toList(), - selectedCard: (j['selected_card'] ?? 'simple') as String, - ); - } - - bool owns(String cardId) => cardId == 'simple' || ownedCards.contains(cardId); -} diff --git a/lib/features/shop/shop_repository.dart b/lib/features/shop/shop_repository.dart deleted file mode 100644 index a7a87bb..0000000 --- a/lib/features/shop/shop_repository.dart +++ /dev/null @@ -1,40 +0,0 @@ -import '../../core/network/api_client.dart'; -import 'shop_models.dart'; - -/// دسترسی به endpointهای فروشگاه و پاداش‌ها. -class ShopRepository { - final ApiClient _api; - ShopRepository(this._api); - - Future getShop() async { - final res = await _api.dio.get('/shop'); - return ShopData.fromJson(Map.from(res.data)); - } - - Future buyCard(String cardId) => - _api.dio.post('/shop/buy-card', data: {'card_id': cardId}); - - Future selectCard(String cardId) => - _api.dio.post('/shop/select-card', data: {'card_id': cardId}); - - /// تأیید خرید IAP. در حالت واقعی token از SDK بازار/مایکت می‌آید؛ - /// فعلاً توکن توسعه‌ای فرستاده می‌شود (بک‌اند در حالت dev هر توکن غیرخالی را می‌پذیرد). - Future purchase({ - required String store, - required String kind, - required String productId, - required String token, - }) => - _api.dio.post('/shop/purchase', data: { - 'store': store, - 'kind': kind, - 'product_id': productId, - 'token': token, - }); - - /// سکه رایگان پس از تبلیغ rewarded. token از SDK تپسل می‌آید (فعلاً توسعه‌ای). - Future adReward(String token) async { - final res = await _api.dio.post('/rewards/ad', data: {'token': token}); - return (res.data['amount'] ?? 0) as int; - } -} diff --git a/lib/features/shop/vip_screen.dart b/lib/features/shop/vip_screen.dart deleted file mode 100644 index b8f7758..0000000 --- a/lib/features/shop/vip_screen.dart +++ /dev/null @@ -1,201 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; - -import '../../core/theme/app_theme.dart'; -import '../../core/widgets/game_ui.dart'; -import '../lobby/wallet_cubit.dart'; -import 'shop_cubit.dart'; - -/// صفحه‌ی اشتراک VIP: نمایش بسته‌ها و خرید. -class VipScreen extends StatelessWidget { - const VipScreen({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - body: GameBackground( - child: SafeArea( - child: BlocBuilder( - builder: (context, state) { - if (state.status == ShopStatus.loading || - state.status == ShopStatus.initial) { - return const Center( - child: CircularProgressIndicator(color: AppColors.gold)); - } - if (state.status == ShopStatus.error || state.data == null) { - return Center( - child: Column(mainAxisSize: MainAxisSize.min, children: [ - const Text('خطا در بارگذاری', - style: TextStyle(color: Colors.white70)), - TextButton( - onPressed: () => context.read().load(), - child: const Text('تلاش مجدد')), - ]), - ); - } - final packages = state.data!.vipPackages; - final isVip = - context.select((WalletCubit c) => c.state.wallet?.vip ?? false); - return Column( - children: [ - Row( - children: [ - IconButton( - onPressed: () => context.pop(), - icon: const Icon(Icons.arrow_back, color: AppColors.gold), - ), - const Spacer(), - const GlowText('اشتراک VIP', size: 24), - const Spacer(), - const SizedBox(width: 48), - ], - ), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [Color(0xFF5A3A00), Color(0xFF2A1A00)]), - borderRadius: BorderRadius.circular(14), - border: - Border.all(color: AppColors.gold, width: 1.3), - ), - child: Column( - children: [ - const Icon(Icons.workspace_premium, - color: AppColors.gold, size: 40), - const SizedBox(height: 6), - Text( - isVip - ? 'شما کاربر VIP هستید' - : 'با VIP بازی حرفه‌ای‌تری داشته باش', - style: const TextStyle( - color: AppColors.gold, - fontWeight: FontWeight.bold, - fontSize: 16)), - const SizedBox(height: 10), - const _Benefit('میزهای خصوصی نامحدود'), - const _Benefit('مشاهده‌ی کامل آمار بازی'), - const _Benefit('۱۰٪ سکه‌ی هدیه در هر خرید'), - ], - ), - ), - const SizedBox(height: 18), - for (final p in packages) - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: _VipPackageTile( - title: p.title, - months: p.months, - price: p.priceToman, - busy: state.busy, - onBuy: () => _buy(context, p.id), - ), - ), - if (packages.isEmpty) - const Padding( - padding: EdgeInsets.only(top: 30), - child: Text('فعلاً بسته‌ای موجود نیست', - style: TextStyle(color: Colors.white54)), - ), - ], - ), - ), - ), - ], - ); - }, - ), - ), - ), - ); - } - - Future _buy(BuildContext context, String id) async { - final msg = await context.read().purchase('vip', id); - if (!context.mounted || msg.isEmpty) return; - await context.read().load(); - if (!context.mounted) return; - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text(msg))); - } -} - -class _VipPackageTile extends StatelessWidget { - final String title; - final int months; - final int price; - final bool busy; - final VoidCallback onBuy; - const _VipPackageTile({ - required this.title, - required this.months, - required this.price, - required this.busy, - required this.onBuy, - }); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - gradient: const LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0xFF6E1322), Color(0xFF3A0A12)], - ), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: AppColors.gold, width: 1.4), - ), - child: Row( - children: [ - const Icon(Icons.workspace_premium, color: AppColors.gold, size: 34), - const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, - style: const TextStyle( - color: AppColors.gold, - fontWeight: FontWeight.bold, - fontSize: 16)), - Text('$months ماه اشتراک', - style: const TextStyle(color: Colors.white70, fontSize: 12)), - ], - ), - const Spacer(), - GameButton( - label: '$price تومان', - onTap: busy ? null : onBuy, - ), - ], - ), - ); - } -} - -class _Benefit extends StatelessWidget { - final String text; - const _Benefit(this.text); - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Row( - children: [ - const Icon(Icons.check_circle, color: Color(0xFF6FCF7A), size: 16), - const SizedBox(width: 6), - Expanded( - child: Text(text, - style: const TextStyle(color: Colors.white, fontSize: 13))), - ], - ), - ); - } -} diff --git a/lib/main.dart b/lib/main.dart index c819d85..606e64a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,22 +1,12 @@ import 'package:flutter/material.dart'; import 'app.dart'; -import 'core/network/api_client.dart'; -import 'core/storage/token_storage.dart'; -import 'features/auth/auth_repository.dart'; +import 'core/locator/locator.dart'; +import 'feature/auth/domain/repository/auth_repository.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); - - final storage = TokenStorage(); - final api = ApiClient(storage); - final authRepo = AuthRepository(api, storage); - final loggedIn = await authRepo.isLoggedIn(); - - runApp(HakemApp( - api: api, - authRepo: authRepo, - tokenStorage: storage, - loggedIn: loggedIn, - )); + await setupLocator(); + final loggedIn = await locator().isLoggedIn(); + runApp(HakemApp(loggedIn: loggedIn)); } diff --git a/pubspec.lock b/pubspec.lock index ea4ea85..7940c0f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -288,6 +288,14 @@ packages: description: flutter source: sdk version: "0.0.0" + get_it: + dependency: "direct main" + description: + name: get_it + sha256: ae78de7c3f2304b8d81f2bb6e320833e5e81de942188542328f074978cc0efa9 + url: "https://pub.myket.ir" + source: hosted + version: "8.3.0" go_router: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 49b9440..4623f43 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,7 @@ dependencies: go_router: ^17.0.0 flutter_secure_storage: ^10.3.1 equatable: ^2.0.8 + get_it: ^8.0.3 flame: ^1.30.1 flame_audio: ^2.11.14 random_avatar: ^0.0.8 diff --git a/test/widget_test.dart b/test/widget_test.dart index 26547fd..f21d47a 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,9 +1,9 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:hakemsho/features/lobby/wallet.dart'; +import 'package:hakemsho/feature/wallet/data/model/wallet_model.dart'; void main() { - test('Wallet.fromJson parses server response', () { - final w = Wallet.fromJson(const { + test('WalletModel.fromJson parses wallet + user response', () { + final w = WalletModel.fromJson(const { 'coins': 4560, 'tickets': 3, 'xp': 325, @@ -13,18 +13,25 @@ void main() { 'xp_for_next': 400, 'vip': true, 'selected_card': 'swiss', + }, const { + 'first_name': 'امیر', + 'avatar': 'hakem-3', }); expect(w.coins, 4560); expect(w.tickets, 3); expect(w.level, 8); expect(w.vip, true); expect(w.selectedCard, 'swiss'); + expect(w.name, 'امیر'); + expect(w.avatar, 'hakem-3'); }); - test('Wallet.fromJson uses defaults for missing fields', () { - final w = Wallet.fromJson(const {}); + test('WalletModel.fromJson uses defaults for missing fields', () { + final w = WalletModel.fromJson(const {}, const {}); expect(w.coins, 0); expect(w.level, 1); expect(w.selectedCard, 'simple'); + expect(w.name, 'بازیکن'); + expect(w.avatar, 'hakem-1'); }); }