diff --git a/assets/audio/cyberwave-orchestra-fantasy-game-sword-cut-sound-effect-get-more-on-my-patreon-339824.mp3 b/assets/audio/cyberwave-orchestra-fantasy-game-sword-cut-sound-effect-get-more-on-my-patreon-339824.mp3 new file mode 100644 index 0000000..260ccf4 Binary files /dev/null and b/assets/audio/cyberwave-orchestra-fantasy-game-sword-cut-sound-effect-get-more-on-my-patreon-339824.mp3 differ diff --git a/assets/audio/dragon-studio-button-press-382713.mp3 b/assets/audio/dragon-studio-button-press-382713.mp3 new file mode 100644 index 0000000..7da3220 Binary files /dev/null and b/assets/audio/dragon-studio-button-press-382713.mp3 differ diff --git a/assets/audio/puyopuyomegafan1234-winner-game-sound-404167.mp3 b/assets/audio/puyopuyomegafan1234-winner-game-sound-404167.mp3 new file mode 100644 index 0000000..a5d7004 Binary files /dev/null and b/assets/audio/puyopuyomegafan1234-winner-game-sound-404167.mp3 differ diff --git a/lib/app.dart b/lib/app.dart index 08a6a04..b686684 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -10,15 +10,20 @@ import 'features/auth/auth_cubit.dart'; import 'features/auth/auth_repository.dart'; import 'features/auth/mobile_screen.dart'; import 'features/auth/otp_screen.dart'; +import 'features/auth/profile_setup_screen.dart'; import 'features/game/game_cubit.dart'; import 'features/game/game_repository.dart'; import 'features/game/game_screen.dart'; import 'features/game/tier_list_screen.dart'; import 'features/lobby/lobby_screen.dart'; import 'features/lobby/wallet_cubit.dart'; +import 'features/private/private_entry_screen.dart'; +import 'features/private/private_table_screen.dart'; +import 'features/profile/profile_screen.dart'; import 'features/shop/shop_cubit.dart'; import 'features/shop/shop_repository.dart'; import 'features/shop/shop_screen.dart'; +import 'features/shop/vip_screen.dart'; class HakemApp extends StatelessWidget { final ApiClient api; @@ -41,7 +46,35 @@ class HakemApp extends StatelessWidget { routes: [ GoRoute(path: '/login', builder: (_, __) => const MobileScreen()), GoRoute(path: '/otp', builder: (_, __) => const OtpScreen()), + GoRoute(path: '/setup', builder: (_, __) => const ProfileSetupScreen()), GoRoute(path: '/lobby', builder: (_, __) => const LobbyScreen()), + GoRoute( + path: '/profile', + builder: (_, __) => ProfileScreen(api: api)), + GoRoute( + path: '/private', + builder: (_, __) => PrivateEntryScreen(api: api)), + GoRoute( + path: '/private/room', + builder: (_, st) { + final create = st.uri.queryParameters['create'] == '1'; + final joinCode = st.uri.queryParameters['join']; + return FutureBuilder( + future: tokenStorage.read(), + builder: (context, snap) { + if (!snap.hasData) { + return const Scaffold( + body: Center(child: CircularProgressIndicator())); + } + return PrivateTableScreen( + token: snap.data!, + create: create, + joinCode: joinCode, + ); + }, + ); + }, + ), GoRoute( path: '/shop', builder: (_, __) => BlocProvider( @@ -49,6 +82,13 @@ class HakemApp extends StatelessWidget { child: const ShopScreen(), ), ), + GoRoute( + path: '/vip', + builder: (_, __) => BlocProvider( + create: (_) => ShopCubit(ShopRepository(api))..load(), + child: const VipScreen(), + ), + ), GoRoute( path: '/game/tiers', builder: (_, __) => TierListScreen(repo: GameRepository(api)), diff --git a/lib/features/auth/auth_cubit.dart b/lib/features/auth/auth_cubit.dart index 2557562..37f1847 100644 --- a/lib/features/auth/auth_cubit.dart +++ b/lib/features/auth/auth_cubit.dart @@ -9,23 +9,30 @@ 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, String? error}) => + AuthState copyWith( + {AuthStatus? status, + String? mobile, + bool? needsProfile, + String? error}) => AuthState( status: status ?? this.status, mobile: mobile ?? this.mobile, + needsProfile: needsProfile ?? this.needsProfile, error: error, ); @override - List get props => [status, mobile, error]; + List get props => [status, mobile, needsProfile, error]; } class AuthCubit extends Cubit { @@ -45,13 +52,25 @@ class AuthCubit extends Cubit { Future verifyOtp(String code) async { emit(state.copyWith(status: AuthStatus.loading)); try { - await _repo.verifyOtp(state.mobile, code); - emit(state.copyWith(status: AuthStatus.authenticated)); + final hasName = await _repo.verifyOtp(state.mobile, code); + emit(state.copyWith( + status: AuthStatus.authenticated, needsProfile: !hasName)); } catch (e) { emit(state.copyWith(status: AuthStatus.error, error: _msg(e))); } } + /// ذخیره‌ی نام و آواتار؛ سپس نیازی به صفحه‌ی پروفایل نیست. + Future saveProfile(String name, String avatar) async { + try { + await _repo.updateProfile(name, avatar); + emit(state.copyWith(needsProfile: false)); + return true; + } catch (_) { + return false; + } + } + Future logout() async { await _repo.logout(); emit(const AuthState()); diff --git a/lib/features/auth/auth_repository.dart b/lib/features/auth/auth_repository.dart index bac5bc1..97be381 100644 --- a/lib/features/auth/auth_repository.dart +++ b/lib/features/auth/auth_repository.dart @@ -13,8 +13,9 @@ class AuthRepository { await _api.dio.post('/auth/login-otp', data: {'mobile': mobile}); } - /// اعتبارسنجی کد و ذخیره‌ی توکن JWT. - Future verifyOtp(String mobile, String code) async { + /// اعتبارسنجی کد، ذخیره‌ی توکن JWT و بازگرداندنِ اینکه کاربر نام دارد یا نه. + /// اگر نام نداشته باشد، فرانت کاربر را به صفحه‌ی انتخاب نام/آواتار می‌برد. + Future verifyOtp(String mobile, String code) async { final res = await _api.dio.post( '/auth/check-otp', data: {'mobile': mobile, 'token': code}, @@ -24,6 +25,15 @@ class AuthRepository { throw Exception('no token in response'); } await _storage.write(token); + final user = res.data['user']; + final name = (user is Map) ? user['first_name'] : null; + return name is String && name.trim().isNotEmpty; + } + + /// تنظیم نام نمایشی و آواتار. + Future updateProfile(String firstName, String avatar) async { + await _api.dio.post('/profile', + data: {'first_name': firstName, 'avatar': avatar}); } Future isLoggedIn() async { diff --git a/lib/features/auth/otp_screen.dart b/lib/features/auth/otp_screen.dart index 1d59e85..b43c7e9 100644 --- a/lib/features/auth/otp_screen.dart +++ b/lib/features/auth/otp_screen.dart @@ -33,7 +33,7 @@ class _OtpScreenState extends State { child: BlocConsumer( listener: (context, state) { if (state.status == AuthStatus.authenticated) { - context.go('/lobby'); + context.go(state.needsProfile ? '/setup' : '/lobby'); } else if (state.status == AuthStatus.error) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(state.error ?? 'خطا')), diff --git a/lib/features/auth/profile_setup_screen.dart b/lib/features/auth/profile_setup_screen.dart new file mode 100644 index 0000000..9f42bf1 --- /dev/null +++ b/lib/features/auth/profile_setup_screen.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:random_avatar/random_avatar.dart'; + +import '../../core/theme/app_theme.dart'; +import '../../core/widgets/game_ui.dart'; +import 'auth_cubit.dart'; + +/// صفحه‌ی انتخاب نام و آواتار پس از اولین ورود. +/// آواتارها با پکیج random_avatar تولید می‌شوند (رایگان، بدون نیاز به asset). +class ProfileSetupScreen extends StatefulWidget { + const ProfileSetupScreen({super.key}); + + @override + State createState() => _ProfileSetupScreenState(); +} + +class _ProfileSetupScreenState extends State { + final _name = TextEditingController(); + bool _saving = false; + + // مجموعه‌ای از seedها؛ هر seed یک آواتارِ یکتا می‌سازد. + late List _seeds; + int _selected = 0; + + @override + void initState() { + super.initState(); + _seeds = List.generate(12, (i) => 'hakem-${i + 1}'); + } + + @override + void dispose() { + _name.dispose(); + super.dispose(); + } + + bool get _valid => _name.text.trim().length >= 2; + + Future _save() async { + setState(() => _saving = true); + final ok = await context + .read() + .saveProfile(_name.text.trim(), _seeds[_selected]); + if (!mounted) return; + setState(() => _saving = false); + if (ok) { + context.go('/lobby'); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('ذخیره نشد، دوباره تلاش کنید')), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: GameBackground( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const GlowText('انتخاب نام و آواتار', size: 26), + const SizedBox(height: 20), + GamePanel( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // پیش‌نمایشِ آواتارِ انتخاب‌شده + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: AppColors.gold, width: 2), + ), + child: RandomAvatar(_seeds[_selected], + height: 84, width: 84), + ), + const SizedBox(height: 14), + TextField( + controller: _name, + textAlign: TextAlign.center, + maxLength: 20, + inputFormatters: [ + LengthLimitingTextInputFormatter(20), + ], + decoration: const InputDecoration( + hintText: 'نام نمایشی (۲ تا ۲۰ حرف)', + counterText: ''), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 14), + const Text('یک آواتار انتخاب کن', + style: TextStyle(color: AppColors.gold)), + const SizedBox(height: 10), + GridView.count( + crossAxisCount: 4, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 10, + crossAxisSpacing: 10, + children: [ + for (var i = 0; i < _seeds.length; i++) + GestureDetector( + onTap: () => setState(() => _selected = i), + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.bgDark, + border: Border.all( + color: _selected == i + ? AppColors.gold + : Colors.transparent, + width: 2.5, + ), + ), + child: RandomAvatar(_seeds[i]), + ), + ), + ], + ), + const SizedBox(height: 18), + GameButton( + label: _saving ? 'در حال ذخیره…' : 'تأیید و ورود', + width: double.infinity, + colors: const [Color(0xFF3FA34D), Color(0xFF1B5E20)], + onTap: (!_valid || _saving) ? null : _save, + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/game/game_cubit.dart b/lib/features/game/game_cubit.dart index 2842d04..14bf294 100644 --- a/lib/features/game/game_cubit.dart +++ b/lib/features/game/game_cubit.dart @@ -6,12 +6,52 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../core/network/ws_client.dart'; import 'game_models.dart'; +/// یک بازیکن در اتاق انتظارِ میز خصوصی. +class LobbyPlayer { + final String name; + final bool host; + const LobbyPlayer(this.name, this.host); +} + +/// وضعیت اتاق انتظارِ میز خصوصی (دورهمی). +class TableLobby { + final String code; + final List players; + final bool isHost; + final int remaining; + final bool unlimited; + const TableLobby({ + required this.code, + required this.players, + required this.isHost, + required this.remaining, + required this.unlimited, + }); + + factory TableLobby.fromJson(Map j) => TableLobby( + code: (j['code'] ?? '') as String, + players: ((j['players'] as List?) ?? []) + .map((e) => LobbyPlayer( + (e['name'] ?? '') as String, (e['host'] ?? false) as bool)) + .toList(), + isHost: (j['host'] ?? false) as bool, + remaining: (j['remaining'] ?? 0) as int, + unlimited: (j['unlimited'] ?? false) as bool, + ); + + String get sig => '$code|$isHost|$remaining|$unlimited|' + '${players.map((p) => '${p.name}${p.host ? '*' : ''}').join(',')}'; +} + class GameUiState extends Equatable { final WsStatus connection; final GameState? state; final HandResult? handResult; // اوورلی نتیجه‌ی هَند (گذرا) final GameOver? gameOver; // اوورلی پایان بازی final String? notice; // پیام گذرا (خطا/خروج بازیکن) + final TableLobby? lobby; // اتاق انتظارِ میز خصوصی (پیش از شروع) + final int? countdown; // شمارش معکوس پیش از شروعِ بازیِ خصوصی + final bool tableClosed; // میز خصوصی منحل شد (میزبان خارج شد) const GameUiState({ this.connection = WsStatus.connecting, @@ -19,6 +59,9 @@ class GameUiState extends Equatable { this.handResult, this.gameOver, this.notice, + this.lobby, + this.countdown, + this.tableClosed = false, }); GameUiState copyWith({ @@ -27,6 +70,9 @@ class GameUiState extends Equatable { HandResult? handResult, GameOver? gameOver, String? notice, + TableLobby? lobby, + int? countdown, + bool? tableClosed, bool clearHandResult = false, bool clearNotice = false, }) => @@ -36,30 +82,59 @@ class GameUiState extends Equatable { handResult: clearHandResult ? null : (handResult ?? this.handResult), gameOver: gameOver ?? this.gameOver, notice: clearNotice ? null : (notice ?? this.notice), + lobby: lobby ?? this.lobby, + countdown: countdown ?? this.countdown, + tableClosed: tableClosed ?? this.tableClosed, ); @override - List get props => [connection, state, handResult, gameOver, notice]; + List get props => [ + connection, + state, + handResult, + gameOver, + notice, + lobby?.sig, + countdown, + tableClosed, + ]; } class GameCubit extends Cubit { final WsClient _ws; final String tier; + + /// اقدامِ ورود پس از اتصال (یک‌بار). پیش‌فرض: ورود به صفِ عمومی. + /// برای میز خصوصی: {'type':'create_table'} یا {'type':'join_table','code':...}. + final Map _joinAction; + bool _joined = false; + late final StreamSubscription _msgSub; late final StreamSubscription _statusSub; - GameCubit(this._ws, this.tier) : super(const GameUiState()) { + GameCubit(this._ws, this.tier, {Map? joinAction}) + : _joinAction = joinAction ?? {'type': 'join_queue', 'tier': tier}, + super(const GameUiState()) { _msgSub = _ws.messages.listen(_onMessage); _statusSub = _ws.status.listen(_onStatus); _ws.connect(); } + /// سازنده‌ی میز خصوصی: ساختِ میز جدید. + GameCubit.createPrivate(WsClient ws) + : this(ws, 'private', joinAction: {'type': 'create_table'}); + + /// سازنده‌ی میز خصوصی: پیوستن با کد. + GameCubit.joinPrivate(WsClient ws, String code) + : this(ws, 'private', joinAction: {'type': 'join_table', 'code': code}); + void _onStatus(WsStatus s) { emit(state.copyWith(connection: s)); - // پس از برقراری اتصال، درخواست ورود به صف؛ در صورت reconnect سرور خودش - // بازیکن را به میز برمی‌گرداند (این پیام را نادیده می‌گیرد). - if (s == WsStatus.connected) { - _ws.send({'type': 'join_queue', 'tier': tier}); + // اقدامِ ورود فقط یک‌بار در اولین اتصال؛ در reconnect سرور خودش بازیکن را + // به میز برمی‌گرداند (نباید دوباره create/join فرستاده شود). + if (s == WsStatus.connected && !_joined) { + _joined = true; + _ws.send(_joinAction); } } @@ -74,6 +149,12 @@ class GameCubit extends Cubit { 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': @@ -91,6 +172,10 @@ class GameCubit extends Cubit { 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 diff --git a/lib/features/lobby/lobby_screen.dart b/lib/features/lobby/lobby_screen.dart index 3d07b54..9207479 100644 --- a/lib/features/lobby/lobby_screen.dart +++ b/lib/features/lobby/lobby_screen.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.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/auth_cubit.dart'; -import 'wallet.dart'; import 'wallet_cubit.dart'; /// لابی اصلی: کیف‌پول، دکمه بازی، فروشگاه، سکه روزانه (ظاهرِ بازی‌گونه). @@ -31,7 +31,7 @@ class _LobbyScreenState extends State { builder: (context, state) { return Column( children: [ - _TopBar(wallet: state.wallet, onCoinTap: () => _openShop(context)), + _TopBar(walletState: state, onCoinTap: () => _openShop(context)), Expanded( child: Center( child: SingleChildScrollView( @@ -54,6 +54,19 @@ class _LobbyScreenState extends State { }, ), const SizedBox(height: 16), + GameButton( + label: 'دورهمی', + icon: Icons.group_add, + width: double.infinity, + colors: const [Color(0xFF1565C0), Color(0xFF0A2E57)], + onTap: () async { + await context.push('/private'); + if (context.mounted) { + context.read().load(); + } + }, + ), + const SizedBox(height: 16), GameButton( label: 'فروشگاه', icon: Icons.storefront, @@ -111,13 +124,13 @@ class _LobbyScreenState extends State { } class _TopBar extends StatelessWidget { - final Wallet? wallet; + final WalletState walletState; final VoidCallback onCoinTap; - const _TopBar({this.wallet, required this.onCoinTap}); + const _TopBar({required this.walletState, required this.onCoinTap}); @override Widget build(BuildContext context) { - final w = wallet; + final w = walletState.wallet; return Container( margin: const EdgeInsets.all(8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), @@ -133,15 +146,23 @@ class _TopBar extends StatelessWidget { ), child: Row( children: [ - Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: AppColors.gold, width: 2), - ), - child: const CircleAvatar( - radius: 22, - backgroundColor: AppColors.panel, - child: Icon(Icons.person, color: AppColors.gold), + GestureDetector( + onTap: () async { + await context.push('/profile'); + if (context.mounted) context.read().load(); + }, + child: Container( + width: 48, + height: 48, + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.panel, + border: Border.all(color: AppColors.gold, width: 2), + ), + child: walletState.avatar.isEmpty + ? const Icon(Icons.person, color: AppColors.gold) + : ClipOval(child: RandomAvatar(walletState.avatar)), ), ), const SizedBox(width: 10), @@ -149,6 +170,16 @@ class _TopBar extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + Row(children: [ + if (walletState.name.isNotEmpty) ...[ + Text(walletState.name, + style: const TextStyle( + color: Colors.white, fontWeight: FontWeight.bold)), + const SizedBox(width: 6), + ], + if (w?.vip == true) const _VipTag(), + ]), + const SizedBox(height: 2), Row(children: [ const Icon(Icons.star, color: AppColors.gold, size: 16), const SizedBox(width: 4), @@ -190,3 +221,24 @@ class _TopBar extends StatelessWidget { ); } } + +/// نشانِ کوچکِ VIP کنار نام در نوار بالا. +class _VipTag extends StatelessWidget { + const _VipTag(); + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFFFFD54F), Color(0xFFB8860B)]), + borderRadius: BorderRadius.circular(6), + ), + child: const Text('VIP', + style: TextStyle( + color: Color(0xFF3A0A12), + fontWeight: FontWeight.bold, + fontSize: 10)), + ); + } +} diff --git a/lib/features/lobby/wallet_cubit.dart b/lib/features/lobby/wallet_cubit.dart index 93c7ad6..67eb5d2 100644 --- a/lib/features/lobby/wallet_cubit.dart +++ b/lib/features/lobby/wallet_cubit.dart @@ -9,11 +9,18 @@ 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}); + const WalletState({ + this.status = WalletStatus.initial, + this.wallet, + this.name = '', + this.avatar = '', + }); @override - List get props => [status, wallet]; + List get props => [status, wallet, name, avatar]; } class WalletCubit extends Cubit { @@ -21,12 +28,24 @@ class WalletCubit extends Cubit { WalletCubit(this._api) : super(const WalletState()); Future load() async { - emit(const WalletState(status: WalletStatus.loading)); + emit(WalletState( + status: WalletStatus.loading, + wallet: state.wallet, + name: state.name, + avatar: state.avatar)); try { - final res = await _api.dio.get('/wallet'); + final results = await Future.wait([ + _api.dio.get('/wallet'), + _api.dio.get('/me'), + ]); + final user = (results[1].data['user'] ?? {}) as Map; + final name = (user['first_name'] as String?)?.trim(); + final avatar = (user['avatar'] as String?)?.trim(); emit(WalletState( status: WalletStatus.loaded, - wallet: Wallet.fromJson(Map.from(res.data)), + wallet: Wallet.fromJson(Map.from(results[0].data)), + name: (name == null || name.isEmpty) ? 'بازیکن' : name, + avatar: (avatar == null || avatar.isEmpty) ? 'hakem-1' : avatar, )); } catch (_) { emit(const WalletState(status: WalletStatus.error)); diff --git a/lib/features/private/private_entry_screen.dart b/lib/features/private/private_entry_screen.dart new file mode 100644 index 0000000..795a722 --- /dev/null +++ b/lib/features/private/private_entry_screen.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/network/api_client.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/widgets/game_ui.dart'; + +/// صفحه‌ی دورهمی: پیوستن با شماره میز یا ساختِ میز جدید. +class PrivateEntryScreen extends StatefulWidget { + final ApiClient api; + const PrivateEntryScreen({super.key, required this.api}); + + @override + State createState() => _PrivateEntryScreenState(); +} + +class _PrivateEntryScreenState extends State { + final _code = TextEditingController(); + int _remaining = 0; + bool _unlimited = false; + bool _loading = true; + + @override + void initState() { + super.initState(); + _loadInfo(); + } + + @override + void dispose() { + _code.dispose(); + super.dispose(); + } + + Future _loadInfo() async { + try { + final res = await widget.api.dio.get('/tables/info'); + final d = res.data as Map; + setState(() { + _remaining = (d['remaining'] ?? 0) as int; + _unlimited = (d['unlimited'] ?? false) as bool; + _loading = false; + }); + } catch (_) { + setState(() => _loading = false); + } + } + + bool get _canCreate => _unlimited || _remaining > 0; + + void _join() { + final code = _code.text.trim(); + if (code.length < 4) return; + context.push('/private/room?join=$code'); + } + + void _create() { + if (!_canCreate) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('سهمیه‌ی میزهای رایگان تمام شده؛ با VIP نامحدود بسازید'))); + return; + } + context.push('/private/room?create=1').then((_) => _loadInfo()); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: GameBackground( + child: SafeArea( + child: Column( + children: [ + Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.all(10), + child: GestureDetector( + onTap: () => context.pop(), + child: Container( + width: 46, + height: 46, + decoration: BoxDecoration( + color: AppColors.panel, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.gold, width: 1.5), + ), + child: const Icon(Icons.arrow_back, color: AppColors.gold), + ), + ), + ), + ), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + children: [ + const SizedBox(height: 8), + const Icon(Icons.person, color: AppColors.gold, size: 56), + const SizedBox(height: 12), + TextField( + controller: _code, + textAlign: TextAlign.center, + keyboardType: TextInputType.number, + style: const TextStyle(fontSize: 22, letterSpacing: 6), + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(5), + ], + decoration: const InputDecoration(hintText: 'شماره میز'), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 16), + GameButton( + label: 'پیوستن', + width: double.infinity, + colors: const [Color(0xFFC2185B), Color(0xFF6A0D38)], + onTap: _code.text.trim().length >= 4 ? _join : null, + ), + const SizedBox(height: 8), + const Text('برای ورود، شماره میز را وارد کنید.', + style: TextStyle(color: Colors.white60, fontSize: 13)), + const SizedBox(height: 24), + Divider(color: AppColors.goldDark.withValues(alpha: 0.5)), + const SizedBox(height: 16), + Text( + _loading + ? '...' + : _unlimited + ? 'میزهای نامحدود (VIP)' + : 'میزهای رایگان باقیمانده: $_remaining', + style: const TextStyle( + color: AppColors.gold, + fontSize: 14, + fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + const Icon(Icons.groups, color: AppColors.gold, size: 56), + const SizedBox(height: 12), + GameButton( + label: 'ساخت میز', + width: double.infinity, + colors: _canCreate + ? const [Color(0xFFC2185B), Color(0xFF6A0D38)] + : const [Color(0xFF555555), Color(0xFF333333)], + onTap: _loading ? null : _create, + ), + const SizedBox(height: 8), + const Text('میز جدید بساز و دوستانت را دعوت کن', + style: TextStyle(color: Colors.white60, fontSize: 13)), + const SizedBox(height: 24), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/private/private_table_screen.dart b/lib/features/private/private_table_screen.dart new file mode 100644 index 0000000..c318e15 --- /dev/null +++ b/lib/features/private/private_table_screen.dart @@ -0,0 +1,280 @@ +import 'dart:async'; + +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/network/ws_client.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/widgets/game_ui.dart'; +import '../game/game_cubit.dart'; +import '../game/game_screen.dart'; + +/// میز خصوصی: اتاق انتظار (نمایش کد، بازیکنان، شروع) و سپس صحنه‌ی بازی. +/// از همان اتصال WebSocket برای لابی و بازی استفاده می‌شود (بدون اتصال مجدد). +class PrivateTableScreen extends StatelessWidget { + final String token; + final bool create; + final String? joinCode; + const PrivateTableScreen({ + super.key, + required this.token, + required this.create, + this.joinCode, + }); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => create + ? GameCubit.createPrivate(WsClient(token)) + : GameCubit.joinPrivate(WsClient(token), joinCode ?? ''), + child: const _PrivateTableView(), + ); + } +} + +class _PrivateTableView extends StatelessWidget { + const _PrivateTableView(); + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listenWhen: (a, b) => + (a.notice != b.notice && b.notice != null) || + (!a.tableClosed && b.tableClosed), + listener: (context, state) { + if (state.tableClosed) { + if (context.canPop()) context.pop(); + return; + } + if (state.notice != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(state.notice!), duration: const Duration(seconds: 2)), + ); + context.read().clearNotice(); + } + }, + builder: (context, state) { + // بازی شروع شده ⇒ همان صحنه‌ی بازی روی همین اتصال. + if (state.state != null) { + return const GameScreen(prize: 0); + } + return _LobbyView(state: state); + }, + ); + } +} + +class _LobbyView extends StatelessWidget { + final GameUiState state; + const _LobbyView({required this.state}); + + @override + Widget build(BuildContext context) { + final lobby = state.lobby; + final connecting = state.connection != WsStatus.connected || lobby == null; + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + context.read().leaveTable(); + if (context.canPop()) context.pop(); + }, + child: Scaffold( + body: GameBackground( + child: SafeArea( + child: Stack( + children: [ + Column( + children: [ + Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.all(10), + child: GestureDetector( + onTap: () { + context.read().leaveTable(); + if (context.canPop()) 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: connecting + ? const Center( + child: CircularProgressIndicator( + color: AppColors.gold)) + : _content(context, lobby), + ), + ], + ), + if (state.countdown != null) + _CountdownOverlay(seconds: state.countdown!), + ], + ), + ), + ), + ), + ); + } + + Widget _content(BuildContext context, TableLobby lobby) { + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + children: [ + const GlowText('میز دورهمی', size: 26), + const SizedBox(height: 16), + // کد میز برای اشتراک‌گذاری + GamePanel( + child: Column( + children: [ + const Text('شماره میز', + style: TextStyle(color: Colors.white70)), + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SelectableText( + lobby.code, + style: const TextStyle( + color: AppColors.gold, + fontSize: 40, + fontWeight: FontWeight.bold, + letterSpacing: 8), + ), + IconButton( + onPressed: () { + Clipboard.setData(ClipboardData(text: lobby.code)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('کد کپی شد')), + ); + }, + icon: const Icon(Icons.copy, color: AppColors.gold), + ), + ], + ), + const Text('این کد را برای دوستانت بفرست', + style: TextStyle(color: Colors.white54, fontSize: 12)), + ], + ), + ), + const SizedBox(height: 16), + // فهرست بازیکنان (۴ جایگاه) + GamePanel( + child: Column( + children: [ + for (var i = 0; i < 4; i++) _seatRow(i, lobby), + ], + ), + ), + const SizedBox(height: 20), + if (lobby.isHost) + GameButton( + label: 'شروع بازی', + icon: Icons.play_arrow, + width: double.infinity, + colors: const [Color(0xFF3FA34D), Color(0xFF1B5E20)], + onTap: () => context.read().startTable(), + ) + else + const Text('در انتظار شروع توسط میزبان…', + style: TextStyle(color: AppColors.gold, fontSize: 15)), + const SizedBox(height: 8), + if (lobby.isHost) + const Text('جای‌های خالی با ربات پر می‌شوند', + style: TextStyle(color: Colors.white54, fontSize: 12)), + const SizedBox(height: 24), + ], + ), + ); + } + + Widget _seatRow(int i, TableLobby lobby) { + final filled = i < lobby.players.length; + final p = filled ? lobby.players[i] : null; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Icon(filled ? Icons.person : Icons.person_outline, + color: filled ? AppColors.gold : Colors.white24, size: 24), + const SizedBox(width: 10), + Text( + filled ? p!.name : 'در انتظار بازیکن…', + style: TextStyle( + color: filled ? Colors.white : Colors.white38, + fontSize: 15, + fontWeight: filled ? FontWeight.bold : FontWeight.normal), + ), + const Spacer(), + if (p?.host == true) + const Icon(Icons.star, color: AppColors.gold, size: 18), + ], + ), + ); + } +} + +/// اوورلی شمارش معکوس ۳، ۲، ۱ پیش از شروع بازی. +class _CountdownOverlay extends StatefulWidget { + final int seconds; + const _CountdownOverlay({required this.seconds}); + + @override + State<_CountdownOverlay> createState() => _CountdownOverlayState(); +} + +class _CountdownOverlayState extends State<_CountdownOverlay> { + late int _n; + Timer? _timer; + + @override + void initState() { + super.initState(); + _n = widget.seconds; + _timer = Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted) return; + setState(() => _n--); + if (_n <= 0) _timer?.cancel(); + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + color: Colors.black.withValues(alpha: 0.7), + alignment: Alignment.center, + child: TweenAnimationBuilder( + key: ValueKey(_n), + tween: Tween(begin: 0.4, end: 1.2), + duration: const Duration(milliseconds: 700), + curve: Curves.easeOut, + builder: (context, scale, child) => + Transform.scale(scale: scale, child: child), + child: GlowText(_n > 0 ? '$_n' : 'شروع!', size: 96), + ), + ); + } +} diff --git a/lib/features/profile/profile_screen.dart b/lib/features/profile/profile_screen.dart new file mode 100644 index 0000000..85c3bd1 --- /dev/null +++ b/lib/features/profile/profile_screen.dart @@ -0,0 +1,535 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show LengthLimitingTextInputFormatter; +import 'package:go_router/go_router.dart'; +import 'package:random_avatar/random_avatar.dart'; + +import '../../core/network/api_client.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/widgets/game_ui.dart'; + +/// صفحه‌ی پروفایل: نام، آواتار، سطح، جام‌ها و آمارِ بازی. +/// نمایشِ آمار ویژه‌ی کاربرانِ VIP است (سرور هم این محدودیت را اعمال می‌کند). +class ProfileScreen extends StatefulWidget { + final ApiClient api; + const ProfileScreen({super.key, required this.api}); + + @override + State createState() => _ProfileScreenState(); +} + +class _ProfileData { + final String name; + final String avatar; + final String mobile; + final int level; + final int trophies; + final int xpInto; + final int xpNext; + final bool vip; + final Map? stats; // null یعنی قفل (غیر VIP) + + _ProfileData({ + required this.name, + required this.avatar, + required this.mobile, + required this.level, + required this.trophies, + required this.xpInto, + required this.xpNext, + required this.vip, + required this.stats, + }); +} + +class _ProfileScreenState extends State { + late Future<_ProfileData> _future; + + @override + void initState() { + super.initState(); + _future = _load(); + } + + Future<_ProfileData> _load() async { + final dio = widget.api.dio; + final res = await Future.wait([ + dio.get('/me'), + dio.get('/wallet'), + dio.get('/stats'), + ]); + final user = (res[0].data['user'] ?? {}) as Map; + final w = (res[1].data ?? {}) as Map; + final s = (res[2].data ?? {}) as Map; + final name = (user['first_name'] as String?)?.trim(); + final avatar = (user['avatar'] as String?)?.trim(); + return _ProfileData( + name: (name == null || name.isEmpty) ? 'بازیکن' : name, + avatar: (avatar == null || avatar.isEmpty) ? 'hakem-1' : avatar, + mobile: (user['mobile'] as String?) ?? '', + level: (w['level'] ?? 1) as int, + trophies: (w['trophies'] ?? 0) as int, + xpInto: (w['xp_into_level'] ?? 0) as int, + xpNext: (w['xp_for_next'] ?? 1) as int, + vip: (s['vip'] ?? false) as bool, + stats: (s['stats'] as Map?)?.cast(), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: GameBackground( + child: SafeArea( + child: FutureBuilder<_ProfileData>( + future: _future, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const Center( + child: CircularProgressIndicator(color: AppColors.gold), + ); + } + if (snap.hasError || !snap.hasData) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'خطا در دریافت پروفایل', + style: TextStyle(color: Colors.white70), + ), + const SizedBox(height: 12), + GameButton( + label: 'تلاش دوباره', + onTap: () => setState(() { _future = _load(); }), + ), + ], + ), + ); + } + return _content(context, snap.data!); + }, + ), + ), + ), + ); + } + + // ویرایش نام و آواتار؛ پس از ذخیره، پروفایل دوباره بارگذاری می‌شود. + Future _editProfile(_ProfileData d) async { + final result = await showModalBottomSheet>( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => _EditProfileSheet(name: d.name, avatar: d.avatar), + ); + if (result == null || !mounted) return; + try { + await widget.api.dio.post( + '/profile', + data: {'first_name': result['name'], 'avatar': result['avatar']}, + ); + if (!mounted) return; + setState(() { _future = _load(); }); + } catch (e) { + print(e); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('ذخیره نشد، دوباره تلاش کنید')), + ); + } + } + + Widget _content(BuildContext context, _ProfileData d) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Row( + children: [ + IconButton( + onPressed: () => context.pop(), + icon: const Icon(Icons.arrow_back, color: AppColors.gold), + ), + const Spacer(), + const GlowText('پروفایل', size: 24), + const Spacer(), + const SizedBox(width: 48), + ], + ), + const SizedBox(height: 8), + GamePanel( + child: Column( + children: [ + Stack( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: AppColors.gold, width: 2.5), + ), + child: RandomAvatar(d.avatar, height: 92, width: 92), + ), + Positioned( + bottom: 0, + right: 0, + child: GestureDetector( + onTap: () => _editProfile(d), + child: Container( + padding: const EdgeInsets.all(6), + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [Color(0xFFFFD54F), Color(0xFFB8860B)], + ), + ), + child: const Icon( + Icons.edit, + color: Color(0xFF3A0A12), + size: 18, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible(child: GlowText(d.name, size: 22)), + if (d.vip) ...[const SizedBox(width: 8), const _VipBadge()], + ], + ), + if (d.mobile.isNotEmpty) + Text( + d.mobile, + style: const TextStyle(color: Colors.white38, fontSize: 12), + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: _MiniStat( + icon: Icons.star, + label: 'سطح', + value: '${d.level}', + ), + ), + Expanded( + child: _MiniStat( + icon: Icons.emoji_events, + label: 'جام', + value: '${d.trophies}', + ), + ), + ], + ), + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular(5), + child: LinearProgressIndicator( + value: d.xpNext == 0 ? 0 : d.xpInto / d.xpNext, + minHeight: 8, + backgroundColor: Colors.white10, + valueColor: const AlwaysStoppedAnimation(AppColors.gold), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + '${d.xpInto} / ${d.xpNext} XP', + style: const TextStyle(color: Colors.white38, fontSize: 11), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + const Align( + alignment: Alignment.centerRight, + child: GlowText('آمار بازی', size: 18), + ), + const SizedBox(height: 8), + _statsSection(context, d), + ], + ), + ); + } + + Widget _statsSection(BuildContext context, _ProfileData d) { + final rows = [ + _StatRow('بازی کل', d.stats?['games'], Icons.casino), + _StatRow('برد کل', d.stats?['wins'], Icons.thumb_up), + _StatRow('باخت کل', d.stats?['losses'], Icons.thumb_down), + _StatRow('کُت کردن', d.stats?['kot_made'], Icons.flash_on), + _StatRow('کُت شدن', d.stats?['kot_received'], Icons.flash_off), + _StatRow('بریدن', d.stats?['cuts'], Icons.bolt), + _StatRow('دست حاکم', d.stats?['hakem_count'], Icons.workspace_premium), + ]; + + final panel = GamePanel(child: Column(children: rows)); + if (d.vip) return panel; + + // غیر VIP: آمار قفل است؛ روی آن لایه‌ی قفل و دعوت به اشتراک نشان بده. + return Stack( + children: [ + // محتوای محو زیرِ قفل (مقادیر نامشخص) + Opacity(opacity: 0.35, child: IgnorePointer(child: panel)), + Positioned.fill( + child: Container( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.goldDark), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.lock, color: AppColors.gold, size: 36), + const SizedBox(height: 8), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Text( + 'مشاهده‌ی آمار ویژه‌ی کاربران VIP است', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white, fontSize: 14), + ), + ), + const SizedBox(height: 12), + GameButton( + label: 'تهیه اشتراک VIP', + icon: Icons.workspace_premium, + colors: const [Color(0xFFFFC107), Color(0xFFB8860B)], + onTap: () async { + await context.push('/vip'); + if (context.mounted) setState(() { _future = _load(); }); + }, + ), + ], + ), + ), + ), + ], + ); + } +} + +class _StatRow extends StatelessWidget { + final String label; + final Object? value; + final IconData icon; + const _StatRow(this.label, this.value, this.icon); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 7), + child: Row( + children: [ + Icon(icon, color: AppColors.gold, size: 20), + const SizedBox(width: 10), + Text( + label, + style: const TextStyle(color: Colors.white, fontSize: 15), + ), + const Spacer(), + Text( + '${value ?? '—'}', + style: const TextStyle( + color: AppColors.gold, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + } +} + +class _MiniStat extends StatelessWidget { + final IconData icon; + final String label; + final String value; + const _MiniStat({ + required this.icon, + required this.label, + required this.value, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Icon(icon, color: AppColors.gold, size: 22), + const SizedBox(height: 2), + Text( + value, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + Text( + label, + style: const TextStyle(color: Colors.white54, fontSize: 12), + ), + ], + ); + } +} + +/// شیتِ ویرایش نام و آواتار (با کلیک «تأیید» مقدار جدید برگردانده می‌شود). +class _EditProfileSheet extends StatefulWidget { + final String name; + final String avatar; + const _EditProfileSheet({required this.name, required this.avatar}); + + @override + State<_EditProfileSheet> createState() => _EditProfileSheetState(); +} + +class _EditProfileSheetState extends State<_EditProfileSheet> { + late final TextEditingController _name; + late final List _seeds; + late String _selected; + + @override + void initState() { + super.initState(); + _name = TextEditingController(text: widget.name); + _seeds = List.generate(12, (i) => 'hakem-${i + 1}'); + // آواتارِ فعلی را در شبکه نگه دار حتی اگر جزو seedهای پیش‌فرض نباشد. + if (!_seeds.contains(widget.avatar)) _seeds.insert(0, widget.avatar); + _selected = widget.avatar; + } + + @override + void dispose() { + _name.dispose(); + super.dispose(); + } + + bool get _valid => _name.text.trim().length >= 2; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Container( + decoration: const BoxDecoration( + color: AppColors.bgDark, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + border: Border(top: BorderSide(color: AppColors.gold, width: 2)), + ), + padding: const EdgeInsets.all(18), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const GlowText('ویرایش پروفایل', size: 20), + const SizedBox(height: 14), + Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: AppColors.gold, width: 2), + ), + child: RandomAvatar(_selected, height: 72, width: 72), + ), + const SizedBox(height: 12), + TextField( + controller: _name, + textAlign: TextAlign.center, + maxLength: 20, + inputFormatters: [LengthLimitingTextInputFormatter(20)], + decoration: const InputDecoration( + hintText: 'نام نمایشی (۲ تا ۲۰ حرف)', + counterText: '', + ), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 10), + const Align( + alignment: Alignment.centerRight, + child: Text( + 'انتخاب آواتار', + style: TextStyle(color: AppColors.gold), + ), + ), + const SizedBox(height: 8), + GridView.count( + crossAxisCount: 4, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 10, + crossAxisSpacing: 10, + children: [ + for (final s in _seeds) + GestureDetector( + onTap: () => setState(() => _selected = s), + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.panel, + border: Border.all( + color: + _selected == s + ? AppColors.gold + : Colors.transparent, + width: 2.5, + ), + ), + child: RandomAvatar(s), + ), + ), + ], + ), + const SizedBox(height: 16), + GameButton( + label: 'تأیید', + width: double.infinity, + colors: const [Color(0xFF3FA34D), Color(0xFF1B5E20)], + onTap: + _valid + ? () => Navigator.pop(context, { + 'name': _name.text.trim(), + 'avatar': _selected, + }) + : null, + ), + ], + ), + ), + ), + ); + } +} + +class _VipBadge extends StatelessWidget { + const _VipBadge(); + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFFFFD54F), Color(0xFFB8860B)], + ), + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'VIP', + style: TextStyle( + color: Color(0xFF3A0A12), + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ); + } +} diff --git a/lib/features/shop/shop_models.dart b/lib/features/shop/shop_models.dart index 89b6528..4ded4a2 100644 --- a/lib/features/shop/shop_models.dart +++ b/lib/features/shop/shop_models.dart @@ -56,12 +56,26 @@ class Booster { priceToman = (j['price_toman'] ?? 0) as int; } +class VIPPackage { + final String id; + final String title; + final int months; + final int priceToman; + + VIPPackage.fromJson(Map j) + : id = j['id'] as String, + title = j['title'] as String, + months = (j['months'] ?? 1) as int, + priceToman = (j['price_toman'] ?? 0) as int; +} + /// داده‌ی کامل فروشگاه: کاتالوگ + کارت‌های متعلق به کاربر + کارت انتخابی. class ShopData { final List coinPackages; final List ticketPackages; final List cardSkins; final List boosters; + final List vipPackages; final List ownedCards; final String selectedCard; @@ -70,6 +84,7 @@ class ShopData { required this.ticketPackages, required this.cardSkins, required this.boosters, + required this.vipPackages, required this.ownedCards, required this.selectedCard, }); @@ -85,6 +100,7 @@ class ShopData { 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, ); diff --git a/lib/features/shop/shop_screen.dart b/lib/features/shop/shop_screen.dart index 7897969..ba42d3f 100644 --- a/lib/features/shop/shop_screen.dart +++ b/lib/features/shop/shop_screen.dart @@ -15,7 +15,7 @@ class ShopScreen extends StatelessWidget { @override Widget build(BuildContext context) { return DefaultTabController( - length: 4, + length: 5, child: Scaffold( backgroundColor: Colors.transparent, body: GameBackground( @@ -68,6 +68,7 @@ class ShopScreen extends StatelessWidget { _TicketsTab(packages: d.ticketPackages), _CardsTab(data: d), _BoostersTab(boosters: d.boosters), + _VipTab(packages: d.vipPackages), ], ); }, @@ -140,6 +141,7 @@ class _ShopTabs extends StatelessWidget { Tab(text: 'بلیط'), Tab(text: 'کارت'), Tab(text: 'تجهیزات'), + Tab(text: 'VIP'), ], ); } @@ -243,6 +245,96 @@ class _BoostersTab extends StatelessWidget { } } +class _VipTab extends StatelessWidget { + final List packages; + const _VipTab({required this.packages}); + @override + Widget build(BuildContext context) { + final isVip = context.select((WalletCubit c) => c.state.wallet?.vip ?? false); + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF5A3A00), Color(0xFF2A1A00)]), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.gold, width: 1.3), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.workspace_premium, + color: AppColors.gold, size: 22), + const SizedBox(width: 6), + Text(isVip ? 'شما کاربر VIP هستید' : 'مزایای اشتراک VIP', + style: const TextStyle( + color: AppColors.gold, + fontWeight: FontWeight.bold, + fontSize: 15)), + ], + ), + const SizedBox(height: 8), + const _Benefit('میزهای خصوصی نامحدود'), + const _Benefit('مشاهده‌ی کامل آمار بازی در پروفایل'), + const _Benefit('۱۰٪ سکه‌ی هدیه در هر خرید'), + ], + ), + ), + ), + Expanded( + child: GridView.count( + crossAxisCount: 2, + padding: const EdgeInsets.all(12), + childAspectRatio: 0.74, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + children: [ + for (final p in packages) + _ItemCard( + title: p.title, + glowColor: const Color(0xFF8A6D00), + icon: Icons.workspace_premium, + ribbon: p.months >= 6 ? 'بهترین' : null, + subtitle: '${p.months} ماه اشتراک', + action: _PriceButton( + label: '${p.priceToman} تومان', + onTap: () => _do(context, + () => context.read().purchase('vip', p.id)), + ), + ), + ], + ), + ), + ], + ); + } +} + +class _Benefit extends StatelessWidget { + final String text; + const _Benefit(this.text); + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + const Icon(Icons.check_circle, color: Color(0xFF6FCF7A), size: 16), + const SizedBox(width: 6), + Expanded( + child: Text(text, + style: const TextStyle(color: Colors.white, fontSize: 13))), + ], + ), + ); + } +} + class _CardsTab extends StatelessWidget { final ShopData data; const _CardsTab({required this.data}); diff --git a/lib/features/shop/vip_screen.dart b/lib/features/shop/vip_screen.dart new file mode 100644 index 0000000..b8f7758 --- /dev/null +++ b/lib/features/shop/vip_screen.dart @@ -0,0 +1,201 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/theme/app_theme.dart'; +import '../../core/widgets/game_ui.dart'; +import '../lobby/wallet_cubit.dart'; +import 'shop_cubit.dart'; + +/// صفحه‌ی اشتراک VIP: نمایش بسته‌ها و خرید. +class VipScreen extends StatelessWidget { + const VipScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: GameBackground( + child: SafeArea( + child: BlocBuilder( + builder: (context, state) { + if (state.status == ShopStatus.loading || + state.status == ShopStatus.initial) { + return const Center( + child: CircularProgressIndicator(color: AppColors.gold)); + } + if (state.status == ShopStatus.error || state.data == null) { + return Center( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + const Text('خطا در بارگذاری', + style: TextStyle(color: Colors.white70)), + TextButton( + onPressed: () => context.read().load(), + child: const Text('تلاش مجدد')), + ]), + ); + } + final packages = state.data!.vipPackages; + final isVip = + context.select((WalletCubit c) => c.state.wallet?.vip ?? false); + return Column( + children: [ + Row( + children: [ + IconButton( + onPressed: () => context.pop(), + icon: const Icon(Icons.arrow_back, color: AppColors.gold), + ), + const Spacer(), + const GlowText('اشتراک VIP', size: 24), + const Spacer(), + const SizedBox(width: 48), + ], + ), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF5A3A00), Color(0xFF2A1A00)]), + borderRadius: BorderRadius.circular(14), + border: + Border.all(color: AppColors.gold, width: 1.3), + ), + child: Column( + children: [ + const Icon(Icons.workspace_premium, + color: AppColors.gold, size: 40), + const SizedBox(height: 6), + Text( + isVip + ? 'شما کاربر VIP هستید' + : 'با VIP بازی حرفه‌ای‌تری داشته باش', + style: const TextStyle( + color: AppColors.gold, + fontWeight: FontWeight.bold, + fontSize: 16)), + const SizedBox(height: 10), + const _Benefit('میزهای خصوصی نامحدود'), + const _Benefit('مشاهده‌ی کامل آمار بازی'), + const _Benefit('۱۰٪ سکه‌ی هدیه در هر خرید'), + ], + ), + ), + const SizedBox(height: 18), + for (final p in packages) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _VipPackageTile( + title: p.title, + months: p.months, + price: p.priceToman, + busy: state.busy, + onBuy: () => _buy(context, p.id), + ), + ), + if (packages.isEmpty) + const Padding( + padding: EdgeInsets.only(top: 30), + child: Text('فعلاً بسته‌ای موجود نیست', + style: TextStyle(color: Colors.white54)), + ), + ], + ), + ), + ), + ], + ); + }, + ), + ), + ), + ); + } + + Future _buy(BuildContext context, String id) async { + final msg = await context.read().purchase('vip', id); + if (!context.mounted || msg.isEmpty) return; + await context.read().load(); + if (!context.mounted) return; + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(msg))); + } +} + +class _VipPackageTile extends StatelessWidget { + final String title; + final int months; + final int price; + final bool busy; + final VoidCallback onBuy; + const _VipPackageTile({ + required this.title, + required this.months, + required this.price, + required this.busy, + required this.onBuy, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF6E1322), Color(0xFF3A0A12)], + ), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.gold, width: 1.4), + ), + child: Row( + children: [ + const Icon(Icons.workspace_premium, color: AppColors.gold, size: 34), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: const TextStyle( + color: AppColors.gold, + fontWeight: FontWeight.bold, + fontSize: 16)), + Text('$months ماه اشتراک', + style: const TextStyle(color: Colors.white70, fontSize: 12)), + ], + ), + const Spacer(), + GameButton( + label: '$price تومان', + onTap: busy ? null : onBuy, + ), + ], + ), + ); + } +} + +class _Benefit extends StatelessWidget { + final String text; + const _Benefit(this.text); + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + const Icon(Icons.check_circle, color: Color(0xFF6FCF7A), size: 16), + const SizedBox(width: 6), + Expanded( + child: Text(text, + style: const TextStyle(color: Colors.white, fontSize: 13))), + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index f414e99..ea4ea85 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.myket.ir" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -262,6 +270,14 @@ packages: url: "https://pub.myket.ir" source: hosted version: "4.1.0" + flutter_svg: + dependency: transitive + description: + name: flutter_svg + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" + url: "https://pub.myket.ir" + source: hosted + version: "2.3.0" flutter_test: dependency: "direct dev" description: flutter @@ -392,6 +408,14 @@ packages: url: "https://pub.myket.ir" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.myket.ir" + source: hosted + version: "1.1.0" path_provider: dependency: transitive description: @@ -440,6 +464,14 @@ packages: url: "https://pub.myket.ir" source: hosted version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.myket.ir" + source: hosted + version: "7.0.2" platform: dependency: transitive description: @@ -464,6 +496,14 @@ packages: url: "https://pub.myket.ir" source: hosted version: "6.1.5+1" + random_avatar: + dependency: "direct main" + description: + name: random_avatar + sha256: "1468b060ac4324fa4f6aeeced732079638d9b6a64838b705ecbe9f208bd1609b" + url: "https://pub.myket.ir" + source: hosted + version: "0.0.8" sky_engine: dependency: transitive description: flutter @@ -541,6 +581,30 @@ packages: url: "https://pub.myket.ir" source: hosted version: "4.5.3" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "2306c03da2ba81724afeb589c351ebbc0aa7d86005925be8f8735856dbe5e42d" + url: "https://pub.myket.ir" + source: hosted + version: "1.2.2" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.myket.ir" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "7ee12e6dffe0fc8e755179d6d91b3b34f5924223fc104d85572ef9180d73d172" + url: "https://pub.myket.ir" + source: hosted + version: "1.2.5" vector_math: dependency: transitive description: @@ -597,6 +661,14 @@ packages: url: "https://pub.myket.ir" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.myket.ir" + source: hosted + version: "6.6.1" sdks: - dart: ">=3.8.0 <4.0.0" + dart: ">=3.10.0 <4.0.0" flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml index 657d38b..49b9440 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,6 +42,7 @@ dependencies: equatable: ^2.0.8 flame: ^1.30.1 flame_audio: ^2.11.14 + random_avatar: ^0.0.8 # path_provider_android نسخه‌ی 2.3.x به jni (کد native + NDK) وابسته شد و build را # سنگین/کند می‌کند. تا نسخه‌ی پیش‌از‑jni پین می‌کنیم تا NDK لازم نباشد.