feat: refactor code

This commit is contained in:
2026-06-17 12:57:28 +03:30
parent e9f294fa19
commit 519752b478
106 changed files with 3459 additions and 2309 deletions
+64 -76
View File
@@ -2,42 +2,33 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'core/network/api_client.dart'; import 'core/locator/locator.dart';
import 'core/network/ws_client.dart';
import 'core/storage/token_storage.dart';
import 'core/theme/app_theme.dart'; import 'core/theme/app_theme.dart';
import 'features/auth/auth_cubit.dart'; import 'feature/auth/presentation/bloc/auth_bloc.dart';
import 'features/auth/auth_repository.dart'; import 'feature/auth/presentation/screen/mobile_screen.dart';
import 'features/auth/mobile_screen.dart'; import 'feature/auth/presentation/screen/otp_screen.dart';
import 'features/auth/otp_screen.dart'; import 'feature/auth/presentation/screen/profile_setup_screen.dart';
import 'features/auth/profile_setup_screen.dart'; import 'feature/game/presentation/bloc/game_bloc.dart';
import 'features/game/game_cubit.dart'; import 'feature/game/presentation/bloc/game_event.dart';
import 'features/game/game_repository.dart'; import 'feature/game/presentation/bloc/private_info_bloc.dart';
import 'features/game/game_screen.dart'; import 'feature/game/presentation/bloc/tier_bloc.dart';
import 'features/game/tier_list_screen.dart'; import 'feature/game/presentation/screen/game_screen.dart';
import 'features/lobby/lobby_screen.dart'; import 'feature/game/presentation/screen/private_entry_screen.dart';
import 'features/lobby/wallet_cubit.dart'; import 'feature/game/presentation/screen/private_table_screen.dart';
import 'features/private/private_entry_screen.dart'; import 'feature/game/presentation/screen/tier_list_screen.dart';
import 'features/private/private_table_screen.dart'; import 'feature/profile/presentation/bloc/profile_bloc.dart';
import 'features/profile/profile_screen.dart'; import 'feature/profile/presentation/bloc/profile_event.dart';
import 'features/shop/shop_cubit.dart'; import 'feature/profile/presentation/screen/profile_screen.dart';
import 'features/shop/shop_repository.dart'; import 'feature/shop/presentation/bloc/shop_bloc.dart';
import 'features/shop/shop_screen.dart'; import 'feature/shop/presentation/bloc/shop_event.dart';
import 'features/shop/vip_screen.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 { class HakemApp extends StatelessWidget {
final ApiClient api;
final AuthRepository authRepo;
final TokenStorage tokenStorage;
final bool loggedIn; final bool loggedIn;
const HakemApp({super.key, required this.loggedIn});
const HakemApp({
super.key,
required this.api,
required this.authRepo,
required this.tokenStorage,
required this.loggedIn,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -49,67 +40,65 @@ class HakemApp extends StatelessWidget {
GoRoute(path: '/setup', builder: (_, __) => const ProfileSetupScreen()), GoRoute(path: '/setup', builder: (_, __) => const ProfileSetupScreen()),
GoRoute(path: '/lobby', builder: (_, __) => const LobbyScreen()), GoRoute(path: '/lobby', builder: (_, __) => const LobbyScreen()),
GoRoute( GoRoute(
path: '/profile', path: '/profile',
builder: (_, __) => ProfileScreen(api: api)), builder: (_, __) => BlocProvider(
GoRoute( create: (_) => locator<ProfileBloc>()..add(LoadProfileEvent()),
path: '/private', child: const ProfileScreen(),
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<String?>(
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,
);
},
);
},
), ),
GoRoute( GoRoute(
path: '/shop', path: '/shop',
builder: (_, __) => BlocProvider( builder: (_, __) => BlocProvider(
create: (_) => ShopCubit(ShopRepository(api))..load(), create: (_) => locator<ShopBloc>()..add(LoadShopEvent()),
child: const ShopScreen(), child: const ShopScreen(),
), ),
), ),
GoRoute( GoRoute(
path: '/vip', path: '/vip',
builder: (_, __) => BlocProvider( builder: (_, __) => BlocProvider(
create: (_) => ShopCubit(ShopRepository(api))..load(), create: (_) => locator<ShopBloc>()..add(LoadShopEvent()),
child: const VipScreen(), child: const VipScreen(),
), ),
), ),
GoRoute(
path: '/private',
builder: (_, __) => BlocProvider(
create: (_) => locator<PrivateInfoBloc>(),
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<GameBloc>()..add(ConnectGameEvent(action)),
child: const PrivateTableScreen(),
);
},
),
GoRoute( GoRoute(
path: '/game/tiers', path: '/game/tiers',
builder: (_, __) => TierListScreen(repo: GameRepository(api)), builder: (_, __) => BlocProvider(
create: (_) => locator<TierBloc>()..add(LoadTiersEvent()),
child: const TierListScreen(),
),
), ),
GoRoute( GoRoute(
path: '/game/:tier', path: '/game/:tier',
builder: (_, st) { builder: (_, st) {
final tier = st.pathParameters['tier']!; final tier = st.pathParameters['tier']!;
final prize = int.tryParse(st.uri.queryParameters['prize'] ?? '') ?? 0; final prize =
return FutureBuilder<String?>( int.tryParse(st.uri.queryParameters['prize'] ?? '') ?? 0;
future: tokenStorage.read(), return BlocProvider(
builder: (context, snap) { create: (_) => locator<GameBloc>()
if (!snap.hasData) { ..add(ConnectGameEvent({'type': 'join_queue', 'tier': tier})),
return const Scaffold( child: GameScreen(prize: prize),
body: Center(child: CircularProgressIndicator()));
}
return BlocProvider(
create: (_) => GameCubit(WsClient(snap.data!), tier),
child: GameScreen(prize: prize),
);
},
); );
}, },
), ),
@@ -118,15 +107,14 @@ class HakemApp extends StatelessWidget {
return MultiBlocProvider( return MultiBlocProvider(
providers: [ providers: [
BlocProvider(create: (_) => AuthCubit(authRepo)), BlocProvider(create: (_) => locator<AuthBloc>()),
BlocProvider(create: (_) => WalletCubit(api)), BlocProvider(create: (_) => locator<WalletBloc>()),
], ],
child: MaterialApp.router( child: MaterialApp.router(
title: 'سلطان حکم', title: 'سلطان حکم',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: AppTheme.build(), theme: AppTheme.build(),
routerConfig: router, routerConfig: router,
// اعمال راست‌به‌چپ برای کل اپ.
builder: (context, child) => Directionality( builder: (context, child) => Directionality(
textDirection: TextDirection.rtl, textDirection: TextDirection.rtl,
child: child!, child: child!,
+28
View File
@@ -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 ?? 'خطا در ارتباط با سرور';
}
+103
View File
@@ -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<void> setupLocator() async {
// --- core ---
locator.registerSingleton<TokenStorage>(TokenStorage());
locator.registerSingleton<ApiProviderImp>(ApiProviderImp(locator()));
// --- data sources ---
locator.registerSingleton<AuthLocalData>(AuthLocalData(locator()));
locator.registerSingleton<AuthApiProvider>(AuthApiProvider());
locator.registerSingleton<WalletApiProvider>(WalletApiProvider());
locator.registerSingleton<ShopApiProvider>(ShopApiProvider());
locator.registerSingleton<ProfileApiProvider>(ProfileApiProvider());
locator.registerSingleton<GameApiProvider>(GameApiProvider());
locator.registerSingleton<GameWsProvider>(GameWsProvider(locator()));
// --- repositories ---
locator.registerSingleton<AuthRepository>(
AuthRepositoryImpl(locator(), locator()));
locator.registerSingleton<WalletRepository>(
WalletRepositoryImpl(locator()));
locator.registerSingleton<ShopRepository>(ShopRepositoryImpl(locator()));
locator.registerSingleton<ProfileRepository>(
ProfileRepositoryImpl(locator()));
locator.registerSingleton<GameRepository>(
GameRepositoryImpl(locator(), locator()));
// --- use cases ---
locator.registerSingleton<LoginUseCase>(LoginUseCase(locator()));
locator.registerSingleton<CheckOtpUseCase>(CheckOtpUseCase(locator()));
locator.registerSingleton<UpdateProfileUseCase>(
UpdateProfileUseCase(locator()));
locator.registerSingleton<LogoutUseCase>(LogoutUseCase(locator()));
locator.registerSingleton<GetWalletUseCase>(GetWalletUseCase(locator()));
locator.registerSingleton<ClaimDailyUseCase>(ClaimDailyUseCase(locator()));
locator.registerSingleton<GetShopUseCase>(GetShopUseCase(locator()));
locator.registerSingleton<BuyCardUseCase>(BuyCardUseCase(locator()));
locator.registerSingleton<SelectCardUseCase>(SelectCardUseCase(locator()));
locator.registerSingleton<PurchaseUseCase>(PurchaseUseCase(locator()));
locator.registerSingleton<AdRewardUseCase>(AdRewardUseCase(locator()));
locator.registerSingleton<GetProfileUseCase>(GetProfileUseCase(locator()));
locator.registerSingleton<SaveProfileUseCase>(SaveProfileUseCase(locator()));
locator.registerSingleton<GetTiersUseCase>(GetTiersUseCase(locator()));
locator.registerSingleton<GetTablesInfoUseCase>(
GetTablesInfoUseCase(locator()));
// --- blocs (factory) ---
locator.registerFactory<AuthBloc>(
() => AuthBloc(locator(), locator(), locator(), locator()));
locator.registerFactory<WalletBloc>(() => WalletBloc(locator(), locator()));
locator.registerFactory<ShopBloc>(() =>
ShopBloc(locator(), locator(), locator(), locator(), locator()));
locator.registerFactory<ProfileBloc>(
() => ProfileBloc(locator(), locator()));
locator.registerFactory<GameBloc>(() => GameBloc(locator()));
locator.registerFactory<TierBloc>(() => TierBloc(locator()));
locator.registerFactory<PrivateInfoBloc>(() => PrivateInfoBloc(locator()));
}
-27
View File
@@ -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);
},
));
}
}
+41
View File
@@ -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<Response> get(String path, {Map<String, dynamic>? query}) =>
dio.get(path, queryParameters: query);
Future<Response> post(String path, {Object? body}) =>
dio.post(path, data: body);
Future<Response> put(String path, {Object? body}) =>
dio.put(path, data: body);
Future<Response> delete(String path, {Object? body}) =>
dio.delete(path, data: body);
}
+15
View File
@@ -0,0 +1,15 @@
/// نتیجه‌ی یک عملیات داده‌ای: موفق (داده) یا خطا (پیام).
/// لایه‌ی data همیشه DataState برمی‌گرداند تا لایه‌های بالا throw نگیرند.
abstract class DataState<T> {
final T? data;
final String? error;
const DataState(this.data, this.error);
}
class DataSuccess<T> extends DataState<T> {
const DataSuccess(T data) : super(data, null);
}
class DataError<T> extends DataState<T> {
const DataError(String error) : super(null, error);
}
+37
View File
@@ -0,0 +1,37 @@
/// قرارداد یوزکیس: هر یوزکیس با یک پارامتر فراخوانی می‌شود و یک Future برمی‌گرداند.
abstract class UseCase<T, P> {
Future<T> 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,
});
}
@@ -0,0 +1,16 @@
import '../../../../../core/storage/token_storage.dart';
/// ذخیره‌ی محلیِ توکن احراز هویت.
class AuthLocalData {
final TokenStorage _storage;
AuthLocalData(this._storage);
Future<void> saveToken(String token) => _storage.write(token);
Future<String?> readToken() => _storage.read();
Future<void> clearToken() => _storage.clear();
Future<bool> hasToken() async {
final t = await _storage.read();
return t != null && t.isNotEmpty;
}
}
@@ -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<ApiProviderImp>();
Future<Response> loginOtp(String mobile) =>
_api.post('/auth/login-otp', body: {'mobile': mobile});
Future<Response> checkOtp(String mobile, String token) =>
_api.post('/auth/check-otp', body: {'mobile': mobile, 'token': token});
Future<Response> updateProfile(String firstName, String avatar) =>
_api.post('/profile', body: {'first_name': firstName, 'avatar': avatar});
Future<Response> me() => _api.get('/me');
}
@@ -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<String, dynamic> j) => UserModel(
id: (j['id'] ?? 0) as int,
mobile: (j['mobile'] ?? '') as String,
firstName: j['first_name'] as String?,
avatar: j['avatar'] as String?,
);
}
@@ -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<DataState<String>> 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<DataState<UserEntity>> 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<String, dynamic>.from(res.data['user'] as Map)));
}
return DataError(errorConvertor(res.statusCode, _msg(res)));
}
@override
Future<DataState<UserEntity>> updateProfile(ProfileParams params) async {
final Response res = await api.updateProfile(params.firstName, params.avatar);
if (res.statusCode == 200) {
return DataSuccess(UserModel.fromJson(
Map<String, dynamic>.from(res.data['user'] as Map)));
}
return DataError(errorConvertor(res.statusCode, _msg(res)));
}
@override
Future<DataState<String>> logout() async {
await local.clearToken();
return const DataSuccess('ok');
}
@override
Future<bool> isLoggedIn() => local.hasToken();
String? _msg(Response res) {
final d = res.data;
if (d is Map && d['message'] != null) return d['message'].toString();
return null;
}
}
@@ -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;
}
@@ -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<DataState<String>> loginOtp(String mobile);
/// بررسی کد؛ توکن را ذخیره کرده و کاربرِ احرازشده را برمی‌گرداند.
Future<DataState<UserEntity>> checkOtp(OtpParams params);
Future<DataState<UserEntity>> updateProfile(ProfileParams params);
Future<DataState<String>> logout();
Future<bool> isLoggedIn();
}
@@ -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<DataState<UserEntity>, OtpParams> {
final AuthRepository repository;
CheckOtpUseCase(this.repository);
@override
Future<DataState<UserEntity>> call(OtpParams params) =>
repository.checkOtp(params);
}
@@ -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<DataState<String>, String> {
final AuthRepository repository;
LoginUseCase(this.repository);
@override
Future<DataState<String>> call(String params) => repository.loginOtp(params);
}
@@ -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<DataState<String>, NoParams> {
final AuthRepository repository;
LogoutUseCase(this.repository);
@override
Future<DataState<String>> call(NoParams params) => repository.logout();
}
@@ -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<DataState<UserEntity>, ProfileParams> {
final AuthRepository repository;
UpdateProfileUseCase(this.repository);
@override
Future<DataState<UserEntity>> call(ProfileParams params) =>
repository.updateProfile(params);
}
@@ -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<AuthEvent, AuthState> {
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<LoginOtpEvent>((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<CheckOtpEvent>((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<UpdateProfileEvent>((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<LogoutEvent>((event, emit) async {
await logoutUseCase(const NoParams());
emit(AuthState.initial());
});
}
}
@@ -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 {}
@@ -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,
);
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -3,8 +3,11 @@ import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../core/widgets/game_ui.dart'; import '../../../../core/widgets/game_ui.dart';
import 'auth_cubit.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 { class MobileScreen extends StatefulWidget {
@@ -29,18 +32,19 @@ class _MobileScreenState extends State<MobileScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
body: GameBackground( body: GameBackground(
child: BlocConsumer<AuthCubit, AuthState>( child: BlocConsumer<AuthBloc, AuthState>(
listenWhen: (a, b) => a.loginStatus != b.loginStatus,
listener: (context, state) { listener: (context, state) {
if (state.status == AuthStatus.otpSent) { final s = state.loginStatus;
if (s is LoginSuccess) {
context.push('/otp'); context.push('/otp');
} else if (state.status == AuthStatus.error) { } else if (s is LoginError) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context)
SnackBar(content: Text(state.error ?? 'خطا')), .showSnackBar(SnackBar(content: Text(s.message)));
);
} }
}, },
builder: (context, state) { builder: (context, state) {
final loading = state.status == AuthStatus.loading; final loading = state.loginStatus is LoginLoading;
return Center( return Center(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
@@ -61,12 +65,14 @@ class _MobileScreenState extends State<MobileScreen> {
controller: _controller, controller: _controller,
keyboardType: TextInputType.phone, keyboardType: TextInputType.phone,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle(fontSize: 20, letterSpacing: 2), style:
const TextStyle(fontSize: 20, letterSpacing: 2),
inputFormatters: [ inputFormatters: [
FilteringTextInputFormatter.digitsOnly, FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(11), LengthLimitingTextInputFormatter(11),
], ],
decoration: const InputDecoration(hintText: '09xxxxxxxxx'), decoration:
const InputDecoration(hintText: '09xxxxxxxxx'),
onChanged: (_) => setState(() {}), onChanged: (_) => setState(() {}),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
@@ -77,8 +83,8 @@ class _MobileScreenState extends State<MobileScreen> {
onTap: (!_valid || loading) onTap: (!_valid || loading)
? null ? null
: () => context : () => context
.read<AuthCubit>() .read<AuthBloc>()
.requestOtp(_controller.text.trim()), .add(LoginOtpEvent(_controller.text.trim())),
), ),
], ],
), ),
@@ -3,9 +3,12 @@ import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../core/theme/app_theme.dart'; import '../../../../core/theme/app_theme.dart';
import '../../core/widgets/game_ui.dart'; import '../../../../core/widgets/game_ui.dart';
import 'auth_cubit.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 { class OtpScreen extends StatefulWidget {
@@ -30,19 +33,19 @@ class _OtpScreenState extends State<OtpScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
body: GameBackground( body: GameBackground(
child: BlocConsumer<AuthCubit, AuthState>( child: BlocConsumer<AuthBloc, AuthState>(
listenWhen: (a, b) => a.otpStatus != b.otpStatus,
listener: (context, state) { listener: (context, state) {
if (state.status == AuthStatus.authenticated) { final s = state.otpStatus;
context.go(state.needsProfile ? '/setup' : '/lobby'); if (s is OtpSuccess) {
} else if (state.status == AuthStatus.error) { context.go(s.hasName ? '/lobby' : '/setup');
ScaffoldMessenger.of(context).showSnackBar( } else if (s is OtpError) {
SnackBar(content: Text(state.error ?? 'خطا')), ScaffoldMessenger.of(context)
); .showSnackBar(SnackBar(content: Text(s.message)));
context.read<AuthCubit>().resetError(onOtpScreen: true);
} }
}, },
builder: (context, state) { builder: (context, state) {
final loading = state.status == AuthStatus.loading; final loading = state.otpStatus is OtpLoading;
return Center( return Center(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
@@ -64,12 +67,14 @@ class _OtpScreenState extends State<OtpScreen> {
controller: _controller, controller: _controller,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle(fontSize: 28, letterSpacing: 12), style: const TextStyle(
fontSize: 28, letterSpacing: 12),
inputFormatters: [ inputFormatters: [
FilteringTextInputFormatter.digitsOnly, FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(5), LengthLimitingTextInputFormatter(5),
], ],
decoration: const InputDecoration(hintText: '- - - - -'), decoration:
const InputDecoration(hintText: '- - - - -'),
onChanged: (_) => setState(() {}), onChanged: (_) => setState(() {}),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
@@ -80,8 +85,8 @@ class _OtpScreenState extends State<OtpScreen> {
onTap: (!_valid || loading) onTap: (!_valid || loading)
? null ? null
: () => context : () => context
.read<AuthCubit>() .read<AuthBloc>()
.verifyOtp(_controller.text.trim()), .add(CheckOtpEvent(_controller.text.trim())),
), ),
TextButton( TextButton(
onPressed: loading ? null : () => context.pop(), onPressed: loading ? null : () => context.pop(),
@@ -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<ProfileSetupScreen> createState() => _ProfileSetupScreenState();
}
class _ProfileSetupScreenState extends State<ProfileSetupScreen> {
final _name = TextEditingController();
late List<String> _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<AuthBloc, AuthState>(
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<AuthBloc>().add(
UpdateProfileEvent(
_name.text.trim(), _seeds[_selected])),
),
],
),
),
],
),
),
);
},
),
),
);
}
}
@@ -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<ApiProviderImp>();
Future<Response> getShop() => _api.get('/shop');
Future<Response> getTablesInfo() => _api.get('/tables/info');
}
@@ -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<Map<String, dynamic>>.broadcast();
final _status = StreamController<WsStatus>.broadcast();
Stream<Map<String, dynamic>> get messages => _messages.stream;
Stream<WsStatus> get status => _status.stream;
Future<void> 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<String, dynamic> msg) => _ws?.send(msg);
Future<void> _teardown() async {
await _msgSub?.cancel();
await _statusSub?.cancel();
_msgSub = null;
_statusSub = null;
_ws?.dispose();
_ws = null;
}
Future<void> disconnect() => _teardown();
}
@@ -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<Map<String, dynamic>> get messages => ws.messages;
@override
Stream<WsStatus> get status => ws.status;
@override
Future<void> connect() => ws.connect();
@override
void send(Map<String, dynamic> msg) => ws.send(msg);
@override
Future<void> disconnect() => ws.disconnect();
@override
Future<DataState<List<TableTier>>> getTiers() async {
final Response res = await api.getShop();
if (res.statusCode == 200) {
final cat = Map<String, dynamic>.from(res.data['catalog'] as Map);
final list = ((cat['table_tiers'] as List?) ?? [])
.map((e) => TableTier.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
return DataSuccess(list);
}
return DataError(errorConvertor(res.statusCode, null));
}
@override
Future<DataState<TablesInfo>> getTablesInfo() async {
final Response res = await api.getTablesInfo();
if (res.statusCode == 200) {
return DataSuccess(
TablesInfo.fromJson(Map<String, dynamic>.from(res.data as Map)));
}
return DataError(errorConvertor(res.statusCode, null));
}
}
@@ -1,4 +1,4 @@
// مدل‌های وضعیت بازی (پیام‌های WebSocket سرور). // موجودیت‌های وضعیت بازی (نگاشت از پیام‌های WebSocket سرور).
class GamePlayer { class GamePlayer {
final int seat; final int seat;
@@ -28,8 +28,8 @@ class GameState {
final int yourSeat; final int yourSeat;
final int hakem; final int hakem;
final int turn; final int turn;
final String? trump; // پس از انتخاب حکم final String? trump;
final bool trickDone; // دستِ کامل در حال نمایش (بازی ممنوع) final bool trickDone;
final List<String> yourHand; final List<String> yourHand;
final List<int> handCounts; final List<int> handCounts;
final List<TrickCard> trick; final List<TrickCard> trick;
@@ -68,7 +68,8 @@ class GameState {
turn: (j['turn'] ?? 0) as int, turn: (j['turn'] ?? 0) as int,
trump: j['trump'] as String?, trump: j['trump'] as String?,
trickDone: (j['trick_done'] ?? false) as bool, 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']), handCounts: ints(j['hand_counts']),
trick: ((j['trick'] as List?) ?? []) trick: ((j['trick'] as List?) ?? [])
.map((e) => TrickCard.fromJson(Map<String, dynamic>.from(e as Map))) .map((e) => TrickCard.fromJson(Map<String, dynamic>.from(e as Map)))
@@ -101,9 +102,8 @@ class HandResult {
kot = (j['kot'] ?? false) as bool, kot = (j['kot'] ?? false) as bool,
hakemKot = (j['hakem_kot'] ?? false) as bool, hakemKot = (j['hakem_kot'] ?? false) as bool,
points = (j['points'] ?? 0) as int, points = (j['points'] ?? 0) as int,
scores = ((j['scores'] as List?) ?? []) scores =
.map((e) => (e as num).toInt()) ((j['scores'] as List?) ?? []).map((e) => (e as num).toInt()).toList();
.toList();
} }
/// نتیجه‌ی پایان بازی (پیام type=game_over). /// نتیجه‌ی پایان بازی (پیام type=game_over).
@@ -112,12 +112,11 @@ class GameOver {
final List<int> scores; final List<int> scores;
GameOver.fromJson(Map<String, dynamic> j) GameOver.fromJson(Map<String, dynamic> j)
: winnerTeam = (j['winner_team'] ?? 0) as int, : winnerTeam = (j['winner_team'] ?? 0) as int,
scores = ((j['scores'] as List?) ?? []) scores =
.map((e) => (e as num).toInt()) ((j['scores'] as List?) ?? []).map((e) => (e as num).toInt()).toList();
.toList();
} }
extension _FirstOrNull<E> on Iterable<E> { extension FirstOrNullExt<E> on Iterable<E> {
E? get firstOrNull { E? get firstOrNull {
final it = iterator; final it = iterator;
return it.moveNext() ? it.current : null; return it.moveNext() ? it.current : null;
@@ -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<String, dynamic> 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<String, dynamic> j) => TablesInfo(
(j['remaining'] ?? 0) as int,
(j['unlimited'] ?? false) as bool,
);
}
@@ -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<Map<String, dynamic>> get messages;
Stream<WsStatus> get status;
Future<void> connect();
void send(Map<String, dynamic> msg);
Future<void> disconnect();
// --- HTTP ---
Future<DataState<List<TableTier>>> getTiers();
Future<DataState<TablesInfo>> getTablesInfo();
}
@@ -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<DataState<TablesInfo>, NoParams> {
final GameRepository repository;
GetTablesInfoUseCase(this.repository);
@override
Future<DataState<TablesInfo>> call(NoParams params) =>
repository.getTablesInfo();
}
@@ -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<DataState<List<TableTier>>, NoParams> {
final GameRepository repository;
GetTiersUseCase(this.repository);
@override
Future<DataState<List<TableTier>>> call(NoParams params) =>
repository.getTiers();
}
@@ -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<GameEvent, GameUiState> {
final GameRepository repository;
late final StreamSubscription _msgSub;
late final StreamSubscription _statusSub;
Map<String, dynamic> _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<ConnectGameEvent>((event, emit) async {
_joinAction = event.joinAction;
_joined = false;
await repository.connect();
});
on<GameStatusChanged>((event, emit) {
emit(state.copyWith(connection: event.status));
if (event.status == WsStatus.connected && !_joined) {
_joined = true;
repository.send(_joinAction);
}
});
on<GameMessageReceived>((event, emit) => _onMessage(event.message, emit));
on<ChooseTrumpEvent>(
(event, emit) => repository.send({'type': 'choose_trump', 'suit': event.suit}));
on<PlayCardEvent>(
(event, emit) => repository.send({'type': 'play_card', 'card': event.card}));
on<LeaveGameEvent>((event, emit) => repository.send({'type': 'leave'}));
on<StartTableEvent>((event, emit) => repository.send({'type': 'start_table'}));
on<LeaveTableEvent>((event, emit) => repository.send({'type': 'leave_table'}));
on<ClearNoticeEvent>((event, emit) => emit(state.copyWith(clearNotice: true)));
}
void _onMessage(Map<String, dynamic> msg, Emitter<GameUiState> 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<void> close() {
_msgSub.cancel();
_statusSub.cancel();
repository.disconnect();
return super.close();
}
}
@@ -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<String, dynamic> joinAction;
ConnectGameEvent(this.joinAction);
}
/// پیام دریافتی از سرور (داخلی).
class GameMessageReceived extends GameEvent {
final Map<String, dynamic> 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 {}
@@ -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<LobbyPlayer> 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<String, dynamic> 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<Object?> get props => [
connection,
state,
handResult,
gameOver,
notice,
lobby?.sig,
countdown,
tableClosed,
];
}
@@ -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<PrivateInfoEvent, PrivateInfoState> {
final GetTablesInfoUseCase getTablesInfoUseCase;
PrivateInfoBloc(this.getTablesInfoUseCase) : super(PrivateInfoInitial()) {
on<LoadTablesInfoEvent>((event, emit) async {
emit(PrivateInfoLoading());
final res = await getTablesInfoUseCase(const NoParams());
if (res is DataSuccess) {
emit(PrivateInfoLoaded(res.data!));
} else {
emit(PrivateInfoError(res.error!));
}
});
}
}
@@ -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<TableTier> tiers;
TierLoaded(this.tiers);
}
class TierError extends TierState {
final String message;
TierError(this.message);
}
class TierBloc extends Bloc<TierEvent, TierState> {
final GetTiersUseCase getTiersUseCase;
TierBloc(this.getTiersUseCase) : super(TierInitial()) {
on<LoadTiersEvent>((event, emit) async {
emit(TierLoading());
final res = await getTiersUseCase(const NoParams());
if (res is DataSuccess) {
emit(TierLoaded(res.data!));
} else {
emit(TierError(res.error!));
}
});
}
}
@@ -5,16 +5,18 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../core/network/ws_client.dart'; import '../../../../core/network/ws_client.dart';
import '../../core/theme/app_theme.dart'; import '../../../../core/theme/app_theme.dart';
import '../lobby/wallet_cubit.dart'; import '../../../wallet/presentation/bloc/wallet_bloc.dart';
import 'flame/hokm_game.dart'; import '../../../wallet/presentation/bloc/wallet_event.dart';
import 'game_cubit.dart'; import '../../domain/entities/game_entities.dart';
import 'game_models.dart'; import '../bloc/game_bloc.dart';
import '../bloc/game_state.dart';
import '../widgets/flame/hokm_game.dart';
/// صفحه‌ی میز بازی: صحنه‌ی Flame + اوورلی‌های وضعیت (انتخاب حکم، نتیجه، پایان، اتصال). /// صفحه‌ی میز بازی: صحنه‌ی Flame + اوورلی‌های وضعیت.
class GameScreen extends StatefulWidget { class GameScreen extends StatefulWidget {
final int prize; // جایزه‌ی میز برای نمایش در دیالوگ جستجو final int prize;
const GameScreen({super.key, this.prize = 0}); const GameScreen({super.key, this.prize = 0});
@override @override
@@ -29,7 +31,7 @@ class _GameScreenState extends State<GameScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_game = HokmGame(context.read<GameCubit>()); _game = HokmGame(context.read<GameBloc>());
} }
@override @override
@@ -38,47 +40,46 @@ class _GameScreenState extends State<GameScreen> {
super.dispose(); super.dispose();
} }
// دیالوگ جستجو تا یافتن حریفان و کمی پس از آن نمایش داده می‌شود.
bool _showSearch(GameUiState s) => s.state == null || !_introHidden; bool _showSearch(GameUiState s) => s.state == null || !_introHidden;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return PopScope( return PopScope(
// خروج با دکمه‌ی back سیستم هم باید با تأیید باشد.
canPop: false, canPop: false,
onPopInvokedWithResult: (didPop, _) { onPopInvokedWithResult: (didPop, _) {
if (!didPop) _confirmLeave(context); if (!didPop) _confirmLeave(context);
}, },
child: Scaffold( child: Scaffold(
body: BlocConsumer<GameCubit, GameUiState>( body: BlocConsumer<GameBloc, GameUiState>(
listenWhen: (a, b) => a.notice != b.notice && b.notice != null, listenWhen: (a, b) => a.notice != b.notice && b.notice != null,
listener: (context, state) { listener: (context, state) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.notice!), duration: const Duration(seconds: 2)), SnackBar(
); content: Text(state.notice!),
context.read<GameCubit>().clearNotice(); duration: const Duration(seconds: 2)),
}, );
builder: (context, state) { context.read<GameBloc>().clearNotice();
// پس از یافتن حریفان، دیالوگ جستجو را کمی نگه می‌داریم بعد مخفی می‌کنیم. },
if (state.state != null && _introTimer == null) { builder: (context, state) {
_introTimer = Timer(const Duration(milliseconds: 1600), () { if (state.state != null && _introTimer == null) {
if (mounted) setState(() => _introHidden = true); _introTimer = Timer(const Duration(milliseconds: 1600), () {
}); if (mounted) setState(() => _introHidden = true);
} });
return Stack( }
children: [ return Stack(
GameWidget(game: _game), children: [
_backButton(context), GameWidget(game: _game),
if (state.connection == WsStatus.disconnected) _connBanner(), _backButton(context),
if (_showSearch(state)) _searchPanel(state), if (state.connection == WsStatus.disconnected) _connBanner(),
if (!_showSearch(state) && _showTrumpPicker(state)) if (_showSearch(state)) _searchPanel(state),
_trumpPicker(context), if (!_showSearch(state) && _showTrumpPicker(state))
if (state.handResult != null && state.gameOver == null) _trumpPicker(context),
_handResult(state), if (state.handResult != null && state.gameOver == null)
if (state.gameOver != null) _gameOver(context, state), _handResult(state),
], if (state.gameOver != null) _gameOver(context, state),
); ],
}, );
},
), ),
), ),
); );
@@ -102,8 +103,7 @@ class _GameScreenState extends State<GameScreen> {
); );
Future<void> _confirmLeave(BuildContext context) async { Future<void> _confirmLeave(BuildContext context) async {
// اگر بازی تمام شده، بدون تأیید خارج شو. if (context.read<GameBloc>().state.gameOver != null) {
if (context.read<GameCubit>().state.gameOver != null) {
_exitToLobby(context); _exitToLobby(context);
return; return;
} }
@@ -124,13 +124,13 @@ class _GameScreenState extends State<GameScreen> {
), ),
); );
if (yes == true && context.mounted) { if (yes == true && context.mounted) {
context.read<GameCubit>().leave(); context.read<GameBloc>().leave();
_exitToLobby(context); _exitToLobby(context);
} }
} }
void _exitToLobby(BuildContext context) { void _exitToLobby(BuildContext context) {
context.read<WalletCubit>().load(); context.read<WalletBloc>().add(LoadWalletEvent());
context.go('/lobby'); context.go('/lobby');
} }
@@ -152,7 +152,6 @@ class _GameScreenState extends State<GameScreen> {
), ),
); );
// دیالوگ «جستجوی حریف» مطابق اپ مرجع: ۴ جایگاه بازیکن + جایزه.
Widget _searchPanel(GameUiState state) { Widget _searchPanel(GameUiState state) {
final players = state.state?.players ?? const <GamePlayer>[]; final players = state.state?.players ?? const <GamePlayer>[];
final mySeat = state.state?.yourSeat ?? -1; final mySeat = state.state?.yourSeat ?? -1;
@@ -245,7 +244,9 @@ class _GameScreenState extends State<GameScreen> {
color: isYou ? AppColors.gold : AppColors.goldDark, width: 1.5), color: isYou ? AppColors.gold : AppColors.goldDark, width: 1.5),
), ),
child: Icon( 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, color: found ? AppColors.gold : Colors.white24,
size: 30, size: 30,
), ),
@@ -292,8 +293,7 @@ class _GameScreenState extends State<GameScreen> {
backgroundColor: AppColors.bgDark, backgroundColor: AppColors.bgDark,
minimumSize: const Size(120, 64), minimumSize: const Size(120, 64),
), ),
onPressed: () => onPressed: () => context.read<GameBloc>().chooseTrump(id),
context.read<GameCubit>().chooseTrump(id),
child: Row(mainAxisSize: MainAxisSize.min, children: [ child: Row(mainAxisSize: MainAxisSize.min, children: [
Text(sym, style: TextStyle(fontSize: 26, color: color)), Text(sym, style: TextStyle(fontSize: 26, color: color)),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -332,11 +332,14 @@ class _GameScreenState extends State<GameScreen> {
Padding( Padding(
padding: const EdgeInsets.only(top: 6), padding: const EdgeInsets.only(top: 6),
child: Text( child: Text(
r.hakemKot ? 'حاکم‌کُت! (${r.points} امتیاز)' : 'کُت! (${r.points} امتیاز)', r.hakemKot
? 'حاکم‌کُت! (${r.points} امتیاز)'
: 'کُت! (${r.points} امتیاز)',
style: const TextStyle(color: AppColors.gold)), style: const TextStyle(color: AppColors.gold)),
), ),
const SizedBox(height: 6), 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)), style: const TextStyle(color: Colors.white70)),
]), ]),
), ),
@@ -358,7 +361,8 @@ class _GameScreenState extends State<GameScreen> {
fontSize: 32, fontSize: 32,
fontWeight: FontWeight.bold)), fontWeight: FontWeight.bold)),
const SizedBox(height: 12), 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)), style: const TextStyle(color: Colors.white70, fontSize: 18)),
const SizedBox(height: 28), const SizedBox(height: 28),
SizedBox( SizedBox(
@@ -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<PrivateEntryScreen> createState() => _PrivateEntryScreenState();
}
class _PrivateEntryScreenState extends State<PrivateEntryScreen> {
final _code = TextEditingController();
@override
void initState() {
super.initState();
context.read<PrivateInfoBloc>().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<PrivateInfoBloc>().add(LoadTablesInfoEvent());
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: GameBackground(
child: SafeArea(
child: BlocBuilder<PrivateInfoBloc, PrivateInfoState>(
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),
],
),
),
),
],
);
},
),
),
),
);
}
}
@@ -5,42 +5,20 @@ import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../core/network/ws_client.dart'; import '../../../../core/network/ws_client.dart';
import '../../core/theme/app_theme.dart'; import '../../../../core/theme/app_theme.dart';
import '../../core/widgets/game_ui.dart'; import '../../../../core/widgets/game_ui.dart';
import '../game/game_cubit.dart'; import '../bloc/game_bloc.dart';
import '../game/game_screen.dart'; import '../bloc/game_state.dart';
import 'game_screen.dart';
/// میز خصوصی: اتاق انتظار (نمایش کد، بازیکنان، شروع) و سپس صحنه‌ی بازی. /// میز خصوصی: اتاق انتظار (کد، بازیکنان، شروع) سپس صحنه‌ی بازی (روی همان اتصال).
/// از همان اتصال WebSocket برای لابی و بازی استفاده می‌شود (بدون اتصال مجدد).
class PrivateTableScreen extends StatelessWidget { class PrivateTableScreen extends StatelessWidget {
final String token; const PrivateTableScreen({super.key});
final bool create;
final String? joinCode;
const PrivateTableScreen({
super.key,
required this.token,
required this.create,
this.joinCode,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider( return BlocConsumer<GameBloc, GameUiState>(
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<GameCubit, GameUiState>(
listenWhen: (a, b) => listenWhen: (a, b) =>
(a.notice != b.notice && b.notice != null) || (a.notice != b.notice && b.notice != null) ||
(!a.tableClosed && b.tableClosed), (!a.tableClosed && b.tableClosed),
@@ -50,14 +28,13 @@ class _PrivateTableView extends StatelessWidget {
return; return;
} }
if (state.notice != null) { if (state.notice != null) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(SnackBar(
SnackBar(content: Text(state.notice!), duration: const Duration(seconds: 2)), content: Text(state.notice!),
); duration: const Duration(seconds: 2)));
context.read<GameCubit>().clearNotice(); context.read<GameBloc>().clearNotice();
} }
}, },
builder: (context, state) { builder: (context, state) {
// بازی شروع شده ⇒ همان صحنه‌ی بازی روی همین اتصال.
if (state.state != null) { if (state.state != null) {
return const GameScreen(prize: 0); return const GameScreen(prize: 0);
} }
@@ -80,7 +57,7 @@ class _LobbyView extends StatelessWidget {
canPop: false, canPop: false,
onPopInvokedWithResult: (didPop, _) { onPopInvokedWithResult: (didPop, _) {
if (didPop) return; if (didPop) return;
context.read<GameCubit>().leaveTable(); context.read<GameBloc>().leaveTable();
if (context.canPop()) context.pop(); if (context.canPop()) context.pop();
}, },
child: Scaffold( child: Scaffold(
@@ -96,7 +73,7 @@ class _LobbyView extends StatelessWidget {
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(10),
child: GestureDetector( child: GestureDetector(
onTap: () { onTap: () {
context.read<GameCubit>().leaveTable(); context.read<GameBloc>().leaveTable();
if (context.canPop()) context.pop(); if (context.canPop()) context.pop();
}, },
child: Container( child: Container(
@@ -140,48 +117,35 @@ class _LobbyView extends StatelessWidget {
children: [ children: [
const GlowText('میز دورهمی', size: 26), const GlowText('میز دورهمی', size: 26),
const SizedBox(height: 16), const SizedBox(height: 16),
// کد میز برای اشتراک‌گذاری
GamePanel( GamePanel(
child: Column( child: Column(children: [
children: [ const Text('شماره میز', style: TextStyle(color: Colors.white70)),
const Text('شماره میز', const SizedBox(height: 6),
style: TextStyle(color: Colors.white70)), Row(mainAxisAlignment: MainAxisAlignment.center, children: [
const SizedBox(height: 6), SelectableText(lobby.code,
Row( style: const TextStyle(
mainAxisAlignment: MainAxisAlignment.center, color: AppColors.gold,
children: [ fontSize: 40,
SelectableText( fontWeight: FontWeight.bold,
lobby.code, letterSpacing: 8)),
style: const TextStyle( IconButton(
color: AppColors.gold, onPressed: () {
fontSize: 40, Clipboard.setData(ClipboardData(text: lobby.code));
fontWeight: FontWeight.bold, ScaffoldMessenger.of(context).showSnackBar(
letterSpacing: 8), const SnackBar(content: Text('کد کپی شد')));
), },
IconButton( icon: const Icon(Icons.copy, color: AppColors.gold),
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), const SizedBox(height: 16),
// فهرست بازیکنان (۴ جایگاه)
GamePanel( GamePanel(
child: Column( child: Column(children: [
children: [ for (var i = 0; i < 4; i++) _seatRow(i, lobby),
for (var i = 0; i < 4; i++) _seatRow(i, lobby), ]),
],
),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
if (lobby.isHost) if (lobby.isHost)
@@ -190,7 +154,7 @@ class _LobbyView extends StatelessWidget {
icon: Icons.play_arrow, icon: Icons.play_arrow,
width: double.infinity, width: double.infinity,
colors: const [Color(0xFF3FA34D), Color(0xFF1B5E20)], colors: const [Color(0xFF3FA34D), Color(0xFF1B5E20)],
onTap: () => context.read<GameCubit>().startTable(), onTap: () => context.read<GameBloc>().startTable(),
) )
else else
const Text('در انتظار شروع توسط میزبان…', const Text('در انتظار شروع توسط میزبان…',
@@ -210,23 +174,21 @@ class _LobbyView extends StatelessWidget {
final p = filled ? lobby.players[i] : null; final p = filled ? lobby.players[i] : null;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 6), padding: const EdgeInsets.symmetric(vertical: 6),
child: Row( child: Row(children: [
children: [ Icon(filled ? Icons.person : Icons.person_outline,
Icon(filled ? Icons.person : Icons.person_outline, color: filled ? AppColors.gold : Colors.white24, size: 24),
color: filled ? AppColors.gold : Colors.white24, size: 24), const SizedBox(width: 10),
const SizedBox(width: 10), Text(
Text( filled ? p!.name : 'در انتظار بازیکن…',
filled ? p!.name : 'در انتظار بازیکن…', style: TextStyle(
style: TextStyle( color: filled ? Colors.white : Colors.white38,
color: filled ? Colors.white : Colors.white38, fontSize: 15,
fontSize: 15, fontWeight: filled ? FontWeight.bold : FontWeight.normal),
fontWeight: filled ? FontWeight.bold : FontWeight.normal), ),
), const Spacer(),
const Spacer(), if (p?.host == true)
if (p?.host == true) const Icon(Icons.star, color: AppColors.gold, size: 18),
const Icon(Icons.star, color: AppColors.gold, size: 18), ]),
],
),
); );
} }
} }
@@ -1,28 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../core/theme/app_theme.dart'; import '../../../../core/theme/app_theme.dart';
import '../../core/widgets/game_ui.dart'; import '../../../../core/widgets/game_ui.dart';
import 'game_repository.dart'; import '../../domain/entities/table_entities.dart';
import 'tier.dart'; import '../bloc/tier_bloc.dart';
/// لیست میزها (tierها) با ورودی/جایزه؛ انتخاب → ورود به میز. /// لیست میزها (tierها) با ورودی/جایزه؛ انتخاب → ورود به میز.
class TierListScreen extends StatefulWidget { class TierListScreen extends StatelessWidget {
final GameRepository repo; const TierListScreen({super.key});
const TierListScreen({super.key, required this.repo});
@override
State<TierListScreen> createState() => _TierListScreenState();
}
class _TierListScreenState extends State<TierListScreen> {
late Future<List<TableTier>> _future;
@override
void initState() {
super.initState();
_future = widget.repo.getTiers();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -41,26 +28,26 @@ class _TierListScreenState extends State<TierListScreen> {
]), ]),
), ),
Expanded( Expanded(
child: FutureBuilder<List<TableTier>>( child: BlocBuilder<TierBloc, TierState>(
future: _future, builder: (context, state) {
builder: (context, snap) { if (state is TierError) {
if (snap.connectionState != ConnectionState.done) {
return const Center(
child: CircularProgressIndicator(color: AppColors.gold));
}
if (snap.hasError || snap.data == null) {
return Center( return Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [ child: Column(mainAxisSize: MainAxisSize.min, children: [
const Text('خطا در بارگذاری میزها'), Text(state.message),
TextButton( TextButton(
onPressed: () => onPressed: () =>
setState(() => _future = widget.repo.getTiers()), context.read<TierBloc>().add(LoadTiersEvent()),
child: const Text('تلاش مجدد'), 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( return ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 20), padding: const EdgeInsets.fromLTRB(16, 4, 16, 20),
itemCount: tiers.length, itemCount: tiers.length,
@@ -101,12 +88,11 @@ class _TierCard extends StatelessWidget {
final int index; final int index;
const _TierCard({required this.tier, required this.index}); const _TierCard({required this.tier, required this.index});
// پالتِ رنگیِ هر میز (مطابق اپ مرجع).
static const _palettes = [ static const _palettes = [
[Color(0xFF43A047), Color(0xFF1B5E20)], // سبز [Color(0xFF43A047), Color(0xFF1B5E20)],
[Color(0xFFE53935), Color(0xFF8E0E1B)], // قرمز [Color(0xFFE53935), Color(0xFF8E0E1B)],
[Color(0xFF1E88E5), Color(0xFF0D3C73)], // آبی [Color(0xFF1E88E5), Color(0xFF0D3C73)],
[Color(0xFF8E24AA), Color(0xFF4A0D5E)], // بنفش [Color(0xFF8E24AA), Color(0xFF4A0D5E)],
]; ];
@override @override
@@ -125,12 +111,12 @@ class _TierCard extends StatelessWidget {
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.gold, width: 2), border: Border.all(color: AppColors.gold, width: 2),
boxShadow: const [ boxShadow: const [
BoxShadow(color: Colors.black54, blurRadius: 10, offset: Offset(0, 5)), BoxShadow(
color: Colors.black54, blurRadius: 10, offset: Offset(0, 5)),
], ],
), ),
child: Row( child: Row(
children: [ children: [
// ریبونِ تعداد دست
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -191,8 +177,7 @@ class _TierCard extends StatelessWidget {
children: [ children: [
Icon(icon, size: 16, color: AppColors.gold), Icon(icon, size: 16, color: AppColors.gold),
const SizedBox(width: 4), const SizedBox(width: 4),
Text(text, Text(text, style: const TextStyle(color: Colors.white, fontSize: 12)),
style: const TextStyle(color: Colors.white, fontSize: 12)),
], ],
); );
} }
@@ -7,8 +7,8 @@ import 'package:flame/game.dart';
import 'package:flame_audio/flame_audio.dart'; import 'package:flame_audio/flame_audio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../game_cubit.dart'; import '../../../domain/entities/game_entities.dart';
import '../game_models.dart'; import '../../bloc/game_bloc.dart';
import 'card_codes.dart'; import 'card_codes.dart';
import 'card_component.dart'; import 'card_component.dart';
import 'table_pieces.dart'; import 'table_pieces.dart';
@@ -21,7 +21,7 @@ import 'table_pieces.dart';
/// - [_rebuildBacksAndInfo] عناصرِ بازساخته‌شونده (پشت‌کارت، شمارنده‌ها، برچسب‌ها). /// - [_rebuildBacksAndInfo] عناصرِ بازساخته‌شونده (پشت‌کارت، شمارنده‌ها، برچسب‌ها).
/// - [_checkCut] + [update]/[render] افکتِ «بریدن با حکم» (تکان + رعد). /// - [_checkCut] + [update]/[render] افکتِ «بریدن با حکم» (تکان + رعد).
class HokmGame extends FlameGame { class HokmGame extends FlameGame {
final GameCubit cubit; final GameBloc cubit;
// وضعیت بازی و اشتراکِ stream. // وضعیت بازی و اشتراکِ stream.
GameState? _s; GameState? _s;
@@ -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<ApiProviderImp>();
Future<Response> getMe() => _api.get('/me');
Future<Response> getWallet() => _api.get('/wallet');
Future<Response> getStats() => _api.get('/stats');
Future<Response> updateProfile(String firstName, String avatar) =>
_api.post('/profile', body: {'first_name': firstName, 'avatar': avatar});
}
@@ -0,0 +1,35 @@
import '../../domain/entities/profile_entity.dart';
/// نگاشتِ پاسخ‌های /me، /wallet و /stats به ProfileEntity.
class ProfileModel {
static ProfileEntity fromJson(
Map<String, dynamic> user,
Map<String, dynamic> wallet,
Map<String, dynamic> 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,
),
);
}
}
@@ -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<DataState<ProfileEntity>> 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<String, dynamic>.from((me.data['user'] ?? {}) as Map),
Map<String, dynamic>.from(wallet.data as Map),
Map<String, dynamic>.from(stats.data as Map),
));
}
return DataError(errorConvertor(me.statusCode, null));
}
@override
Future<DataState<String>> 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));
}
}
@@ -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,
});
}
@@ -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<DataState<ProfileEntity>> getProfile();
Future<DataState<String>> updateProfile(ProfileParams params);
}
@@ -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<DataState<ProfileEntity>, NoParams> {
final ProfileRepository repository;
GetProfileUseCase(this.repository);
@override
Future<DataState<ProfileEntity>> call(NoParams params) =>
repository.getProfile();
}
@@ -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<DataState<String>, ProfileParams> {
final ProfileRepository repository;
SaveProfileUseCase(this.repository);
@override
Future<DataState<String>> call(ProfileParams params) =>
repository.updateProfile(params);
}
@@ -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<ProfileEvent, ProfileBlocState> {
final GetProfileUseCase getProfileUseCase;
final SaveProfileUseCase saveProfileUseCase;
ProfileBloc(this.getProfileUseCase, this.saveProfileUseCase)
: super(ProfileBlocState.initial()) {
on<LoadProfileEvent>((event, emit) => _load(emit));
on<SaveProfileEvent>((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<void> _load(Emitter<ProfileBlocState> 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!)));
}
}
}
@@ -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);
}
@@ -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,
);
}
@@ -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);
}
@@ -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<ProfileBloc, ProfileBlocState>(
listenWhen: (a, b) => a.saveStatus != b.saveStatus,
listener: (context, state) {
final s = state.saveStatus;
if (s is ProfileSaveSuccess) {
context.read<WalletBloc>().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<ProfileBloc>().add(LoadProfileEvent())),
]),
);
}
if (st is! ProfileLoadLoaded) {
return const Center(
child: CircularProgressIndicator(color: AppColors.gold));
}
return _content(context, st.profile);
},
),
),
),
);
}
Future<void> _editProfile(BuildContext context, ProfileEntity d) async {
final result = await showModalBottomSheet<Map<String, String>>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => _EditProfileSheet(name: d.name, avatar: d.avatar),
);
if (result == null || !context.mounted) return;
context
.read<ProfileBloc>()
.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 = <Widget>[
_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<ProfileBloc>().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<String> _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,
),
]),
),
),
);
}
}
@@ -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<ApiProviderImp>();
Future<Response> getShop() => _api.get('/shop');
Future<Response> buyCard(String cardId) =>
_api.post('/shop/buy-card', body: {'card_id': cardId});
Future<Response> selectCard(String cardId) =>
_api.post('/shop/select-card', body: {'card_id': cardId});
Future<Response> 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<Response> adReward(String token) =>
_api.post('/rewards/ad', body: {'token': token});
}
@@ -0,0 +1,59 @@
import '../../domain/entities/shop_entities.dart';
/// نگاشتِ JSON کاتالوگ فروشگاه به موجودیت‌ها.
class ShopMapper {
static CoinPackage coin(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> j) => CardSkin(
id: j['id'] as String,
title: j['title'] as String,
priceCoins: (j['price_coins'] ?? 0) as int,
);
static Booster booster(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> j) {
final cat = Map<String, dynamic>.from(j['catalog'] as Map);
List<T> parse<T>(String key, T Function(Map<String, dynamic>) f) =>
((cat[key] as List?) ?? [])
.map((e) => f(Map<String, dynamic>.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,
);
}
}
@@ -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<DataState<ShopData>> getShop() async {
final Response res = await api.getShop();
if (res.statusCode == 200) {
return DataSuccess(
ShopMapper.shopData(Map<String, dynamic>.from(res.data as Map)));
}
return DataError(errorConvertor(res.statusCode, _msg(res)));
}
@override
Future<DataState<String>> 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<DataState<String>> 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<DataState<String>> 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<DataState<int>> 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;
}
}
@@ -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<CoinPackage> coinPackages;
final List<TicketPackage> ticketPackages;
final List<CardSkin> cardSkins;
final List<Booster> boosters;
final List<VipPackage> vipPackages;
final List<String> 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);
}
@@ -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<DataState<ShopData>> getShop();
Future<DataState<String>> buyCard(String cardId);
Future<DataState<String>> selectCard(String cardId);
Future<DataState<String>> purchase(PurchaseParams params);
Future<DataState<int>> adReward(String token);
}
@@ -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<DataState<int>, String> {
final ShopRepository repository;
AdRewardUseCase(this.repository);
@override
Future<DataState<int>> call(String params) => repository.adReward(params);
}
@@ -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<DataState<String>, String> {
final ShopRepository repository;
BuyCardUseCase(this.repository);
@override
Future<DataState<String>> call(String params) => repository.buyCard(params);
}
@@ -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<DataState<ShopData>, NoParams> {
final ShopRepository repository;
GetShopUseCase(this.repository);
@override
Future<DataState<ShopData>> call(NoParams params) => repository.getShop();
}
@@ -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<DataState<String>, PurchaseParams> {
final ShopRepository repository;
PurchaseUseCase(this.repository);
@override
Future<DataState<String>> call(PurchaseParams params) =>
repository.purchase(params);
}
@@ -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<DataState<String>, String> {
final ShopRepository repository;
SelectCardUseCase(this.repository);
@override
Future<DataState<String>> call(String params) =>
repository.selectCard(params);
}
@@ -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<ShopEvent, ShopBlocState> {
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<LoadShopEvent>((event, emit) => _load(emit));
on<BuyCardEvent>((event, emit) =>
_action(emit, () => buyCardUseCase(event.cardId)));
on<SelectCardEvent>((event, emit) =>
_action(emit, () => selectCardUseCase(event.cardId)));
on<PurchaseEvent>((event, emit) => _action(
emit,
() => purchaseUseCase(PurchaseParams(
store: 'bazaar',
kind: event.kind,
productId: event.productId,
token:
'dev-${event.kind}-${event.productId}-${DateTime.now().millisecondsSinceEpoch}',
))));
on<AdRewardEvent>((event, emit) => _action(
emit,
() async {
final res = await adRewardUseCase(
'dev-ad-${DateTime.now().millisecondsSinceEpoch}');
if (res is DataSuccess) {
return const DataSuccess('سکه رایگان دریافت شد');
}
return DataError<String>(res.error!);
},
));
}
Future<void> _load(Emitter<ShopBlocState> 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<void> _action(
Emitter<ShopBlocState> emit,
Future<DataState<String>> 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!)));
}
}
}
@@ -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 {}
@@ -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,
);
}
@@ -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);
}
@@ -2,13 +2,19 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../core/theme/app_theme.dart'; import '../../../../core/theme/app_theme.dart';
import '../../core/widgets/game_ui.dart'; import '../../../../core/widgets/game_ui.dart';
import '../lobby/wallet_cubit.dart'; import '../../../wallet/presentation/bloc/wallet_bloc.dart';
import 'shop_cubit.dart'; import '../../../wallet/presentation/bloc/wallet_event.dart';
import 'shop_models.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 { class ShopScreen extends StatelessWidget {
const ShopScreen({super.key}); const ShopScreen({super.key});
@@ -19,72 +25,80 @@ class ShopScreen extends StatelessWidget {
child: Scaffold( child: Scaffold(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
body: GameBackground( body: GameBackground(
child: Column( child: BlocConsumer<ShopBloc, ShopBlocState>(
children: [ listenWhen: (a, b) => a.actionStatus != b.actionStatus,
_header(context), listener: (context, state) {
Expanded( final s = state.actionStatus;
child: Container( if (s is ActionSuccess) {
margin: const EdgeInsets.fromLTRB(8, 0, 8, 8), context.read<WalletBloc>().add(LoadWalletEvent());
decoration: BoxDecoration( ScaffoldMessenger.of(context)
gradient: const LinearGradient( .showSnackBar(SnackBar(content: Text(s.message)));
begin: Alignment.topCenter, } else if (s is ActionError) {
end: Alignment.bottomCenter, ScaffoldMessenger.of(context)
colors: [Color(0xFF4A0C16), Color(0xFF2A0710)], .showSnackBar(SnackBar(content: Text(s.message)));
), }
borderRadius: BorderRadius.circular(16), },
border: Border.all(color: AppColors.goldDark, width: 1.5), builder: (context, state) {
), return Column(
child: Column( children: [
children: [ _header(context),
const _ShopTabs(), Expanded(
Expanded( child: Container(
child: BlocBuilder<ShopCubit, ShopState>( margin: const EdgeInsets.fromLTRB(8, 0, 8, 8),
builder: (context, state) { decoration: BoxDecoration(
if (state.status == ShopStatus.loading || gradient: const LinearGradient(
state.status == ShopStatus.initial) { begin: Alignment.topCenter,
return const Center( end: Alignment.bottomCenter,
child: CircularProgressIndicator( colors: [Color(0xFF4A0C16), Color(0xFF2A0710)],
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<ShopCubit>().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),
],
);
},
), ),
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<ShopBloc>().add(LoadShopEvent()),
child: const Text('تلاش مجدد'),
),
]),
);
}
return const Center(child: CircularProgressIndicator(color: AppColors.gold));
}
Widget _header(BuildContext context) { Widget _header(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
@@ -103,15 +117,19 @@ class ShopScreen extends StatelessWidget {
), ),
), ),
const Spacer(), const Spacer(),
BlocBuilder<WalletCubit, WalletState>( BlocBuilder<WalletBloc, WalletBlocState>(
builder: (context, s) => Row(children: [ builder: (context, s) {
StatChip( final w = s.walletStatus is WalletLoaded
icon: Icons.confirmation_number, ? (s.walletStatus as WalletLoaded).wallet
value: '${s.wallet?.tickets ?? 0}'), : null;
const SizedBox(width: 8), return Row(children: [
StatChip( StatChip(
icon: Icons.monetization_on, value: '${s.wallet?.coins ?? 0}'), 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, unselectedLabelColor: Colors.white60,
labelStyle: TextStyle(fontWeight: FontWeight.bold), labelStyle: TextStyle(fontWeight: FontWeight.bold),
dividerColor: Colors.transparent, dividerColor: Colors.transparent,
isScrollable: true,
tabAlignment: TabAlignment.center,
tabs: [ tabs: [
Tab(text: 'سکه'), Tab(text: 'سکه'),
Tab(text: 'بلیط'), Tab(text: 'بلیط'),
@@ -147,15 +167,6 @@ class _ShopTabs extends StatelessWidget {
} }
} }
/// اجرای یک عملیات فروشگاه، نمایش نتیجه و بازخوانی کیف‌پول.
Future<void> _do(BuildContext context, Future<String> Function() action) async {
final msg = await action();
if (!context.mounted || msg.isEmpty) return;
await context.read<WalletCubit>().load();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
}
// ===== تب‌ها ===== // ===== تب‌ها =====
class _CoinsTab extends StatelessWidget { class _CoinsTab extends StatelessWidget {
@@ -174,7 +185,7 @@ class _CoinsTab extends StatelessWidget {
action: _PriceButton( action: _PriceButton(
label: 'رایگان', label: 'رایگان',
green: true, green: true,
onTap: () => _do(context, () => context.read<ShopCubit>().claimAd()), onTap: () => context.read<ShopBloc>().add(AdRewardEvent()),
), ),
), ),
for (final p in packages) for (final p in packages)
@@ -183,11 +194,12 @@ class _CoinsTab extends StatelessWidget {
glowColor: const Color(0xFF1B5E20), glowColor: const Color(0xFF1B5E20),
icon: Icons.savings, icon: Icons.savings,
ribbon: p.bonusPct > 0 ? '+${p.bonusPct}٪' : null, 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( action: _PriceButton(
label: '${p.priceToman} تومان', label: '${p.priceToman} تومان',
onTap: () => _do( onTap: () =>
context, () => context.read<ShopCubit>().purchase('coin', p.id)), context.read<ShopBloc>().add(PurchaseEvent('coin', p.id)),
), ),
), ),
], ],
@@ -211,8 +223,8 @@ class _TicketsTab extends StatelessWidget {
subtitle: '${p.tickets} بلیط', subtitle: '${p.tickets} بلیط',
action: _PriceButton( action: _PriceButton(
label: '${p.priceToman} تومان', label: '${p.priceToman} تومان',
onTap: () => _do(context, onTap: () =>
() => context.read<ShopCubit>().purchase('ticket', p.id)), context.read<ShopBloc>().add(PurchaseEvent('ticket', p.id)),
), ),
), ),
], ],
@@ -236,8 +248,8 @@ class _BoostersTab extends StatelessWidget {
subtitle: 'تجربه ×${b.multiplier}${b.hours} ساعت', subtitle: 'تجربه ×${b.multiplier}${b.hours} ساعت',
action: _PriceButton( action: _PriceButton(
label: '${b.priceToman} تومان', label: '${b.priceToman} تومان',
onTap: () => _do(context, onTap: () =>
() => context.read<ShopCubit>().purchase('booster', b.id)), context.read<ShopBloc>().add(PurchaseEvent('booster', b.id)),
), ),
), ),
], ],
@@ -246,95 +258,31 @@ class _BoostersTab extends StatelessWidget {
} }
class _VipTab extends StatelessWidget { class _VipTab extends StatelessWidget {
final List<VIPPackage> packages; final List<VipPackage> packages;
const _VipTab({required this.packages}); const _VipTab({required this.packages});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isVip = context.select((WalletCubit c) => c.state.wallet?.vip ?? false); return _grid(
return Column( note: 'با VIP: میز خصوصی نامحدود، آمار کامل و ۱۰٪ سکه‌ی هدیه.',
children: [ children: [
Padding( for (final p in packages)
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), _ItemCard(
child: Container( title: p.title,
padding: const EdgeInsets.all(12), glowColor: const Color(0xFF8A6D00),
decoration: BoxDecoration( icon: Icons.workspace_premium,
gradient: const LinearGradient( ribbon: p.months >= 6 ? 'بهترین' : null,
colors: [Color(0xFF5A3A00), Color(0xFF2A1A00)]), subtitle: '${p.months} ماه اشتراک',
borderRadius: BorderRadius.circular(12), action: _PriceButton(
border: Border.all(color: AppColors.gold, width: 1.3), label: '${p.priceToman} تومان',
), onTap: () =>
child: Column( context.read<ShopBloc>().add(PurchaseEvent('vip', p.id)),
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('۱۰٪ سکه‌ی هدیه در هر خرید'),
],
), ),
), ),
),
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<ShopCubit>().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 { class _CardsTab extends StatelessWidget {
final ShopData data; final ShopData data;
const _CardsTab({required this.data}); const _CardsTab({required this.data});
@@ -363,15 +311,14 @@ class _CardsTab extends StatelessWidget {
return _PriceButton( return _PriceButton(
label: 'انتخاب', label: 'انتخاب',
green: true, green: true,
onTap: () => onTap: () => context.read<ShopBloc>().add(SelectCardEvent(c.id)),
_do(context, () => context.read<ShopCubit>().selectCard(c.id)),
); );
} }
return _PriceButton( return _PriceButton(
label: '${c.priceCoins} سکه', label: '${c.priceCoins} سکه',
green: true, green: true,
coin: true, coin: true,
onTap: () => _do(context, () => context.read<ShopCubit>().buyCard(c.id)), onTap: () => context.read<ShopBloc>().add(BuyCardEvent(c.id)),
); );
} }
} }
@@ -475,7 +422,6 @@ class _ItemCard extends StatelessWidget {
} }
} }
/// قابِ هنریِ آیتم با درخششِ شعاعی و آیکن.
class _GlowArt extends StatelessWidget { class _GlowArt extends StatelessWidget {
final Color color; final Color color;
final IconData icon; final IconData icon;
@@ -492,9 +438,7 @@ class _GlowArt extends StatelessWidget {
), ),
border: Border.all(color: Colors.black26), border: Border.all(color: Colors.black26),
), ),
child: Center( child: Center(child: Icon(icon, size: 44, color: AppColors.gold)),
child: Icon(icon, size: 44, color: AppColors.gold),
),
); );
} }
} }
@@ -515,7 +459,7 @@ class _PriceButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { 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 final colors = disabled
? const [Color(0xFF555555), Color(0xFF333333)] ? const [Color(0xFF555555), Color(0xFF333333)]
: green : green
@@ -540,8 +484,7 @@ class _PriceButton extends StatelessWidget {
), ),
child: Row(mainAxisSize: MainAxisSize.min, children: [ child: Row(mainAxisSize: MainAxisSize.min, children: [
if (coin) ...[ if (coin) ...[
const Icon(Icons.monetization_on, const Icon(Icons.monetization_on, color: AppColors.gold, size: 16),
color: AppColors.gold, size: 16),
const SizedBox(width: 4), const SizedBox(width: 4),
], ],
Flexible( Flexible(
@@ -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<ShopBloc, ShopBlocState>(
listenWhen: (a, b) => a.actionStatus != b.actionStatus,
listener: (context, state) {
final s = state.actionStatus;
if (s is ActionSuccess) {
context.read<WalletBloc>().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<ShopBloc>().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<ShopBloc>()
.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))),
]),
);
}
}
@@ -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<ApiProviderImp>();
Future<Response> getWallet() => _api.get('/wallet');
Future<Response> getMe() => _api.get('/me');
Future<Response> claimDaily() => _api.post('/rewards/daily');
}
@@ -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<String, dynamic> wallet,
Map<String, dynamic> 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,
);
}
}
@@ -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<DataState<WalletEntity>> 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<String, dynamic>.from(wallet.data as Map),
Map<String, dynamic>.from((me.data['user'] ?? {}) as Map),
));
}
return DataError(errorConvertor(wallet.statusCode, null));
}
@override
Future<DataState<int>> 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));
}
}
@@ -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,
});
}
@@ -0,0 +1,9 @@
import '../../../../core/resources/data_state.dart';
import '../entities/wallet_entity.dart';
abstract class WalletRepository {
Future<DataState<WalletEntity>> getWallet();
/// دریافت سکه روزانه؛ مقدار دریافتی را برمی‌گرداند.
Future<DataState<int>> claimDaily();
}
@@ -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<DataState<int>, NoParams> {
final WalletRepository repository;
ClaimDailyUseCase(this.repository);
@override
Future<DataState<int>> call(NoParams params) => repository.claimDaily();
}
@@ -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<DataState<WalletEntity>, NoParams> {
final WalletRepository repository;
GetWalletUseCase(this.repository);
@override
Future<DataState<WalletEntity>> call(NoParams params) =>
repository.getWallet();
}
@@ -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);
}
@@ -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<WalletEvent, WalletBlocState> {
final GetWalletUseCase getWalletUseCase;
final ClaimDailyUseCase claimDailyUseCase;
WalletBloc(this.getWalletUseCase, this.claimDailyUseCase)
: super(WalletBlocState.initial()) {
on<LoadWalletEvent>((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<ClaimDailyEvent>((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!)));
}
});
}
}
@@ -0,0 +1,5 @@
abstract class WalletEvent {}
class LoadWalletEvent extends WalletEvent {}
class ClaimDailyEvent extends WalletEvent {}
@@ -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,
);
}
@@ -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);
}
@@ -3,12 +3,18 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:random_avatar/random_avatar.dart'; import 'package:random_avatar/random_avatar.dart';
import '../../core/theme/app_theme.dart'; import '../../../../core/theme/app_theme.dart';
import '../../core/widgets/game_ui.dart'; import '../../../../core/widgets/game_ui.dart';
import '../auth/auth_cubit.dart'; import '../../../auth/presentation/bloc/auth_bloc.dart';
import 'wallet_cubit.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 { class LobbyScreen extends StatefulWidget {
const LobbyScreen({super.key}); const LobbyScreen({super.key});
@@ -20,18 +26,33 @@ class _LobbyScreenState extends State<LobbyScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
context.read<WalletCubit>().load(); context.read<WalletBloc>().add(LoadWalletEvent());
} }
void _reload() => context.read<WalletBloc>().add(LoadWalletEvent());
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
body: GameBackground( body: GameBackground(
child: BlocBuilder<WalletCubit, WalletState>( child: BlocConsumer<WalletBloc, WalletBlocState>(
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) { builder: (context, state) {
final st = state.walletStatus;
final wallet = st is WalletLoaded ? st.wallet : null;
return Column( return Column(
children: [ children: [
_TopBar(walletState: state, onCoinTap: () => _openShop(context)), _TopBar(wallet: wallet, onCoinTap: () => _openShop(context)),
Expanded( Expanded(
child: Center( child: Center(
child: SingleChildScrollView( child: SingleChildScrollView(
@@ -48,9 +69,7 @@ class _LobbyScreenState extends State<LobbyScreen> {
colors: const [Color(0xFFC2185B), Color(0xFF6A0D38)], colors: const [Color(0xFFC2185B), Color(0xFF6A0D38)],
onTap: () async { onTap: () async {
await context.push('/game/tiers'); await context.push('/game/tiers');
if (context.mounted) { if (context.mounted) _reload();
context.read<WalletCubit>().load();
}
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -61,9 +80,7 @@ class _LobbyScreenState extends State<LobbyScreen> {
colors: const [Color(0xFF1565C0), Color(0xFF0A2E57)], colors: const [Color(0xFF1565C0), Color(0xFF0A2E57)],
onTap: () async { onTap: () async {
await context.push('/private'); await context.push('/private');
if (context.mounted) { if (context.mounted) _reload();
context.read<WalletCubit>().load();
}
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -80,7 +97,8 @@ class _LobbyScreenState extends State<LobbyScreen> {
icon: Icons.monetization_on, icon: Icons.monetization_on,
width: double.infinity, width: double.infinity,
colors: const [Color(0xFF3FA34D), Color(0xFF1B5E20)], colors: const [Color(0xFF3FA34D), Color(0xFF1B5E20)],
onTap: () => _claimDaily(context), onTap: () =>
context.read<WalletBloc>().add(ClaimDailyEvent()),
), ),
], ],
), ),
@@ -88,9 +106,9 @@ class _LobbyScreenState extends State<LobbyScreen> {
), ),
), ),
TextButton.icon( TextButton.icon(
onPressed: () async { onPressed: () {
await context.read<AuthCubit>().logout(); context.read<AuthBloc>().add(LogoutEvent());
if (context.mounted) context.go('/login'); context.go('/login');
}, },
icon: const Icon(Icons.logout, color: Colors.white54), icon: const Icon(Icons.logout, color: Colors.white54),
label: const Text('خروج', label: const Text('خروج',
@@ -107,30 +125,18 @@ class _LobbyScreenState extends State<LobbyScreen> {
Future<void> _openShop(BuildContext context) async { Future<void> _openShop(BuildContext context) async {
await context.push('/shop'); await context.push('/shop');
if (context.mounted) context.read<WalletCubit>().load(); if (context.mounted) _reload();
}
Future<void> _claimDaily(BuildContext context) async {
final amount = await context.read<WalletCubit>().claimDaily();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(amount != null
? 'سکه روزانه دریافت شد: +$amount'
: 'سکه روزانه را قبلاً امروز گرفته‌اید'),
),
);
} }
} }
class _TopBar extends StatelessWidget { class _TopBar extends StatelessWidget {
final WalletState walletState; final WalletEntity? wallet;
final VoidCallback onCoinTap; final VoidCallback onCoinTap;
const _TopBar({required this.walletState, required this.onCoinTap}); const _TopBar({required this.wallet, required this.onCoinTap});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final w = walletState.wallet; final w = wallet;
return Container( return Container(
margin: const EdgeInsets.all(8), margin: const EdgeInsets.all(8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
@@ -149,7 +155,9 @@ class _TopBar extends StatelessWidget {
GestureDetector( GestureDetector(
onTap: () async { onTap: () async {
await context.push('/profile'); await context.push('/profile');
if (context.mounted) context.read<WalletCubit>().load(); if (context.mounted) {
context.read<WalletBloc>().add(LoadWalletEvent());
}
}, },
child: Container( child: Container(
width: 48, width: 48,
@@ -160,9 +168,9 @@ class _TopBar extends StatelessWidget {
color: AppColors.panel, color: AppColors.panel,
border: Border.all(color: AppColors.gold, width: 2), 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) ? const Icon(Icons.person, color: AppColors.gold)
: ClipOval(child: RandomAvatar(walletState.avatar)), : ClipOval(child: RandomAvatar(w.avatar)),
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
@@ -171,8 +179,8 @@ class _TopBar extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Row(children: [ Row(children: [
if (walletState.name.isNotEmpty) ...[ if (w != null && w.name.isNotEmpty) ...[
Text(walletState.name, Text(w.name,
style: const TextStyle( style: const TextStyle(
color: Colors.white, fontWeight: FontWeight.bold)), color: Colors.white, fontWeight: FontWeight.bold)),
const SizedBox(width: 6), const SizedBox(width: 6),
@@ -222,7 +230,6 @@ class _TopBar extends StatelessWidget {
} }
} }
/// نشانِ کوچکِ VIP کنار نام در نوار بالا.
class _VipTag extends StatelessWidget { class _VipTag extends StatelessWidget {
const _VipTag(); const _VipTag();
@override @override
-95
View File
@@ -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<Object?> get props => [status, mobile, needsProfile, error];
}
class AuthCubit extends Cubit<AuthState> {
final AuthRepository _repo;
AuthCubit(this._repo) : super(const AuthState());
Future<void> 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<void> 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<bool> saveProfile(String name, String avatar) async {
try {
await _repo.updateProfile(name, avatar);
emit(state.copyWith(needsProfile: false));
return true;
} catch (_) {
return false;
}
}
Future<void> 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 'خطای نامشخص';
}
}
-45
View File
@@ -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<void> requestOtp(String mobile) async {
await _api.dio.post('/auth/login-otp', data: {'mobile': mobile});
}
/// اعتبارسنجی کد، ذخیره‌ی توکن JWT و بازگرداندنِ اینکه کاربر نام دارد یا نه.
/// اگر نام نداشته باشد، فرانت کاربر را به صفحه‌ی انتخاب نام/آواتار می‌برد.
Future<bool> 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<void> updateProfile(String firstName, String avatar) async {
await _api.dio.post('/profile',
data: {'first_name': firstName, 'avatar': avatar});
}
Future<bool> isLoggedIn() async {
final t = await _storage.read();
return t != null && t.isNotEmpty;
}
Future<void> logout() => _storage.clear();
}
-144
View File
@@ -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<ProfileSetupScreen> createState() => _ProfileSetupScreenState();
}
class _ProfileSetupScreenState extends State<ProfileSetupScreen> {
final _name = TextEditingController();
bool _saving = false;
// مجموعه‌ای از seedها؛ هر seed یک آواتارِ یکتا می‌سازد.
late List<String> _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<void> _save() async {
setState(() => _saving = true);
final ok = await context
.read<AuthCubit>()
.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,
),
],
),
),
],
),
),
),
),
);
}
}
-188
View File
@@ -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<LobbyPlayer> 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<String, dynamic> 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<Object?> get props => [
connection,
state,
handResult,
gameOver,
notice,
lobby?.sig,
countdown,
tableClosed,
];
}
class GameCubit extends Cubit<GameUiState> {
final WsClient _ws;
final String tier;
/// اقدامِ ورود پس از اتصال (یک‌بار). پیش‌فرض: ورود به صفِ عمومی.
/// برای میز خصوصی: {'type':'create_table'} یا {'type':'join_table','code':...}.
final Map<String, dynamic> _joinAction;
bool _joined = false;
late final StreamSubscription _msgSub;
late final StreamSubscription _statusSub;
GameCubit(this._ws, this.tier, {Map<String, dynamic>? 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<String, dynamic> 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<void> close() {
_msgSub.cancel();
_statusSub.cancel();
_ws.dispose();
return super.close();
}
}
-17
View File
@@ -1,17 +0,0 @@
import '../../core/network/api_client.dart';
import 'tier.dart';
/// واکشی انواع میز برای صفحه‌ی لیست میزها.
class GameRepository {
final ApiClient _api;
GameRepository(this._api);
Future<List<TableTier>> getTiers() async {
final res = await _api.dio.get('/shop');
final cat = Map<String, dynamic>.from(res.data['catalog'] as Map);
final list = (cat['table_tiers'] as List?) ?? [];
return list
.map((e) => TableTier.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
}
}
-19
View File
@@ -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<String, dynamic> 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;
}
-42
View File
@@ -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<String, dynamic> 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<Object?> get props =>
[coins, tickets, xp, trophies, level, xpIntoLevel, xpForNext, vip, selectedCard];
}
-65
View File
@@ -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<Object?> get props => [status, wallet, name, avatar];
}
class WalletCubit extends Cubit<WalletState> {
final ApiClient _api;
WalletCubit(this._api) : super(const WalletState());
Future<void> 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<String, dynamic>.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<int?> claimDaily() async {
try {
final res = await _api.dio.post('/rewards/daily');
await load();
return (res.data['amount'] ?? 0) as int;
} catch (_) {
return 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<PrivateEntryScreen> createState() => _PrivateEntryScreenState();
}
class _PrivateEntryScreenState extends State<PrivateEntryScreen> {
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<void> _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),
],
),
),
),
],
),
),
),
);
}
}
-535
View File
@@ -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<ProfileScreen> 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<String, dynamic>? 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<ProfileScreen> {
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<String, dynamic>(),
);
}
@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<void> _editProfile(_ProfileData d) async {
final result = await showModalBottomSheet<Map<String, String>>(
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 = <Widget>[
_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<String> _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,
),
),
);
}
}
-79
View File
@@ -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<Object?> get props => [status, data, busy];
}
class ShopCubit extends Cubit<ShopState> {
final ShopRepository _repo;
ShopCubit(this._repo) : super(const ShopState());
Future<void> 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<String> _run(Future<void> 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<String> buyCard(String id) => _run(() => _repo.buyCard(id), 'کارت خریداری شد');
Future<String> selectCard(String id) =>
_run(() => _repo.selectCard(id), 'کارت انتخاب شد');
Future<String> purchase(String kind, String id) => _run(
() => _repo.purchase(
store: 'bazaar',
kind: kind,
productId: id,
token: 'dev-$kind-$id-${DateTime.now().millisecondsSinceEpoch}',
),
'خرید با موفقیت انجام شد',
);
Future<String> claimAd() => _run(
() => _repo.adReward('dev-ad-${DateTime.now().millisecondsSinceEpoch}'),
'سکه رایگان دریافت شد',
);
}
-110
View File
@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<CoinPackage> coinPackages;
final List<TicketPackage> ticketPackages;
final List<CardSkin> cardSkins;
final List<Booster> boosters;
final List<VIPPackage> vipPackages;
final List<String> 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<String, dynamic> j) {
final cat = Map<String, dynamic>.from(j['catalog'] as Map);
List<T> parse<T>(String key, T Function(Map<String, dynamic>) f) =>
((cat[key] as List?) ?? [])
.map((e) => f(Map<String, dynamic>.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);
}

Some files were not shown because too many files have changed in this diff Show More