feat: profile and subs

This commit is contained in:
2026-06-17 11:32:18 +03:30
parent 943ba13872
commit e9f294fa19
19 changed files with 1762 additions and 34 deletions
Binary file not shown.
+40
View File
@@ -10,15 +10,20 @@ import 'features/auth/auth_cubit.dart';
import 'features/auth/auth_repository.dart'; import 'features/auth/auth_repository.dart';
import 'features/auth/mobile_screen.dart'; import 'features/auth/mobile_screen.dart';
import 'features/auth/otp_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_cubit.dart';
import 'features/game/game_repository.dart'; import 'features/game/game_repository.dart';
import 'features/game/game_screen.dart'; import 'features/game/game_screen.dart';
import 'features/game/tier_list_screen.dart'; import 'features/game/tier_list_screen.dart';
import 'features/lobby/lobby_screen.dart'; import 'features/lobby/lobby_screen.dart';
import 'features/lobby/wallet_cubit.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_cubit.dart';
import 'features/shop/shop_repository.dart'; import 'features/shop/shop_repository.dart';
import 'features/shop/shop_screen.dart'; import 'features/shop/shop_screen.dart';
import 'features/shop/vip_screen.dart';
class HakemApp extends StatelessWidget { class HakemApp extends StatelessWidget {
final ApiClient api; final ApiClient api;
@@ -41,7 +46,35 @@ class HakemApp extends StatelessWidget {
routes: [ routes: [
GoRoute(path: '/login', builder: (_, __) => const MobileScreen()), GoRoute(path: '/login', builder: (_, __) => const MobileScreen()),
GoRoute(path: '/otp', builder: (_, __) => const OtpScreen()), GoRoute(path: '/otp', builder: (_, __) => const OtpScreen()),
GoRoute(path: '/setup', builder: (_, __) => const ProfileSetupScreen()),
GoRoute(path: '/lobby', builder: (_, __) => const LobbyScreen()), 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<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(
@@ -49,6 +82,13 @@ class HakemApp extends StatelessWidget {
child: const ShopScreen(), child: const ShopScreen(),
), ),
), ),
GoRoute(
path: '/vip',
builder: (_, __) => BlocProvider(
create: (_) => ShopCubit(ShopRepository(api))..load(),
child: const VipScreen(),
),
),
GoRoute( GoRoute(
path: '/game/tiers', path: '/game/tiers',
builder: (_, __) => TierListScreen(repo: GameRepository(api)), builder: (_, __) => TierListScreen(repo: GameRepository(api)),
+23 -4
View File
@@ -9,23 +9,30 @@ enum AuthStatus { initial, loading, otpSent, authenticated, error }
class AuthState extends Equatable { class AuthState extends Equatable {
final AuthStatus status; final AuthStatus status;
final String mobile; final String mobile;
final bool needsProfile; // پس از ورود، آیا کاربر باید نام/آواتار انتخاب کند
final String? error; final String? error;
const AuthState({ const AuthState({
this.status = AuthStatus.initial, this.status = AuthStatus.initial,
this.mobile = '', this.mobile = '',
this.needsProfile = false,
this.error, this.error,
}); });
AuthState copyWith({AuthStatus? status, String? mobile, String? error}) => AuthState copyWith(
{AuthStatus? status,
String? mobile,
bool? needsProfile,
String? error}) =>
AuthState( AuthState(
status: status ?? this.status, status: status ?? this.status,
mobile: mobile ?? this.mobile, mobile: mobile ?? this.mobile,
needsProfile: needsProfile ?? this.needsProfile,
error: error, error: error,
); );
@override @override
List<Object?> get props => [status, mobile, error]; List<Object?> get props => [status, mobile, needsProfile, error];
} }
class AuthCubit extends Cubit<AuthState> { class AuthCubit extends Cubit<AuthState> {
@@ -45,13 +52,25 @@ class AuthCubit extends Cubit<AuthState> {
Future<void> verifyOtp(String code) async { Future<void> verifyOtp(String code) async {
emit(state.copyWith(status: AuthStatus.loading)); emit(state.copyWith(status: AuthStatus.loading));
try { try {
await _repo.verifyOtp(state.mobile, code); final hasName = await _repo.verifyOtp(state.mobile, code);
emit(state.copyWith(status: AuthStatus.authenticated)); emit(state.copyWith(
status: AuthStatus.authenticated, needsProfile: !hasName));
} catch (e) { } catch (e) {
emit(state.copyWith(status: AuthStatus.error, error: _msg(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 { Future<void> logout() async {
await _repo.logout(); await _repo.logout();
emit(const AuthState()); emit(const AuthState());
+12 -2
View File
@@ -13,8 +13,9 @@ class AuthRepository {
await _api.dio.post('/auth/login-otp', data: {'mobile': mobile}); await _api.dio.post('/auth/login-otp', data: {'mobile': mobile});
} }
/// اعتبارسنجی کد و ذخیره‌ی توکن JWT. /// اعتبارسنجی کد، ذخیره‌ی توکن JWT و بازگرداندنِ اینکه کاربر نام دارد یا نه.
Future<void> verifyOtp(String mobile, String code) async { /// اگر نام نداشته باشد، فرانت کاربر را به صفحه‌ی انتخاب نام/آواتار می‌برد.
Future<bool> verifyOtp(String mobile, String code) async {
final res = await _api.dio.post( final res = await _api.dio.post(
'/auth/check-otp', '/auth/check-otp',
data: {'mobile': mobile, 'token': code}, data: {'mobile': mobile, 'token': code},
@@ -24,6 +25,15 @@ class AuthRepository {
throw Exception('no token in response'); throw Exception('no token in response');
} }
await _storage.write(token); 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 { Future<bool> isLoggedIn() async {
+1 -1
View File
@@ -33,7 +33,7 @@ class _OtpScreenState extends State<OtpScreen> {
child: BlocConsumer<AuthCubit, AuthState>( child: BlocConsumer<AuthCubit, AuthState>(
listener: (context, state) { listener: (context, state) {
if (state.status == AuthStatus.authenticated) { if (state.status == AuthStatus.authenticated) {
context.go('/lobby'); context.go(state.needsProfile ? '/setup' : '/lobby');
} else if (state.status == AuthStatus.error) { } else if (state.status == AuthStatus.error) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.error ?? 'خطا')), SnackBar(content: Text(state.error ?? 'خطا')),
+144
View File
@@ -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<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,
),
],
),
),
],
),
),
),
),
);
}
}
+91 -6
View File
@@ -6,12 +6,52 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../../core/network/ws_client.dart'; import '../../core/network/ws_client.dart';
import 'game_models.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 { class GameUiState extends Equatable {
final WsStatus connection; final WsStatus connection;
final GameState? state; final GameState? state;
final HandResult? handResult; // اوورلی نتیجه‌ی هَند (گذرا) final HandResult? handResult; // اوورلی نتیجه‌ی هَند (گذرا)
final GameOver? gameOver; // اوورلی پایان بازی final GameOver? gameOver; // اوورلی پایان بازی
final String? notice; // پیام گذرا (خطا/خروج بازیکن) final String? notice; // پیام گذرا (خطا/خروج بازیکن)
final TableLobby? lobby; // اتاق انتظارِ میز خصوصی (پیش از شروع)
final int? countdown; // شمارش معکوس پیش از شروعِ بازیِ خصوصی
final bool tableClosed; // میز خصوصی منحل شد (میزبان خارج شد)
const GameUiState({ const GameUiState({
this.connection = WsStatus.connecting, this.connection = WsStatus.connecting,
@@ -19,6 +59,9 @@ class GameUiState extends Equatable {
this.handResult, this.handResult,
this.gameOver, this.gameOver,
this.notice, this.notice,
this.lobby,
this.countdown,
this.tableClosed = false,
}); });
GameUiState copyWith({ GameUiState copyWith({
@@ -27,6 +70,9 @@ class GameUiState extends Equatable {
HandResult? handResult, HandResult? handResult,
GameOver? gameOver, GameOver? gameOver,
String? notice, String? notice,
TableLobby? lobby,
int? countdown,
bool? tableClosed,
bool clearHandResult = false, bool clearHandResult = false,
bool clearNotice = false, bool clearNotice = false,
}) => }) =>
@@ -36,30 +82,59 @@ class GameUiState extends Equatable {
handResult: clearHandResult ? null : (handResult ?? this.handResult), handResult: clearHandResult ? null : (handResult ?? this.handResult),
gameOver: gameOver ?? this.gameOver, gameOver: gameOver ?? this.gameOver,
notice: clearNotice ? null : (notice ?? this.notice), notice: clearNotice ? null : (notice ?? this.notice),
lobby: lobby ?? this.lobby,
countdown: countdown ?? this.countdown,
tableClosed: tableClosed ?? this.tableClosed,
); );
@override @override
List<Object?> get props => [connection, state, handResult, gameOver, notice]; List<Object?> get props => [
connection,
state,
handResult,
gameOver,
notice,
lobby?.sig,
countdown,
tableClosed,
];
} }
class GameCubit extends Cubit<GameUiState> { class GameCubit extends Cubit<GameUiState> {
final WsClient _ws; final WsClient _ws;
final String tier; 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 _msgSub;
late final StreamSubscription _statusSub; late final StreamSubscription _statusSub;
GameCubit(this._ws, this.tier) : super(const GameUiState()) { GameCubit(this._ws, this.tier, {Map<String, dynamic>? joinAction})
: _joinAction = joinAction ?? {'type': 'join_queue', 'tier': tier},
super(const GameUiState()) {
_msgSub = _ws.messages.listen(_onMessage); _msgSub = _ws.messages.listen(_onMessage);
_statusSub = _ws.status.listen(_onStatus); _statusSub = _ws.status.listen(_onStatus);
_ws.connect(); _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) { void _onStatus(WsStatus s) {
emit(state.copyWith(connection: s)); emit(state.copyWith(connection: s));
// پس از برقراری اتصال، درخواست ورود به صف؛ در صورت reconnect سرور خودش // اقدامِ ورود فقط یک‌بار در اولین اتصال؛ در reconnect سرور خودش بازیکن را
// بازیکن را به میز برمی‌گرداند (این پیام را نادیده می‌گیرد). // به میز برمی‌گرداند (نباید دوباره create/join فرستاده شود).
if (s == WsStatus.connected) { if (s == WsStatus.connected && !_joined) {
_ws.send({'type': 'join_queue', 'tier': tier}); _joined = true;
_ws.send(_joinAction);
} }
} }
@@ -74,6 +149,12 @@ class GameCubit extends Cubit<GameUiState> {
emit(state.copyWith(handResult: HandResult.fromJson(msg))); emit(state.copyWith(handResult: HandResult.fromJson(msg)));
case 'game_over': case 'game_over':
emit(state.copyWith(gameOver: GameOver.fromJson(msg))); 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': case 'player_disconnected':
emit(state.copyWith(notice: 'یک بازیکن قطع شد')); emit(state.copyWith(notice: 'یک بازیکن قطع شد'));
case 'player_reconnected': case 'player_reconnected':
@@ -91,6 +172,10 @@ class GameCubit extends Cubit<GameUiState> {
void leave() => _ws.send({'type': 'leave'}); 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)); void clearNotice() => emit(state.copyWith(clearNotice: true));
@override @override
+62 -10
View File
@@ -1,11 +1,11 @@
import 'package:flutter/material.dart'; 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 '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/auth_cubit.dart';
import 'wallet.dart';
import 'wallet_cubit.dart'; import 'wallet_cubit.dart';
/// لابی اصلی: کیف‌پول، دکمه بازی، فروشگاه، سکه روزانه (ظاهرِ بازی‌گونه). /// لابی اصلی: کیف‌پول، دکمه بازی، فروشگاه، سکه روزانه (ظاهرِ بازی‌گونه).
@@ -31,7 +31,7 @@ class _LobbyScreenState extends State<LobbyScreen> {
builder: (context, state) { builder: (context, state) {
return Column( return Column(
children: [ children: [
_TopBar(wallet: state.wallet, onCoinTap: () => _openShop(context)), _TopBar(walletState: state, onCoinTap: () => _openShop(context)),
Expanded( Expanded(
child: Center( child: Center(
child: SingleChildScrollView( child: SingleChildScrollView(
@@ -54,6 +54,19 @@ class _LobbyScreenState extends State<LobbyScreen> {
}, },
), ),
const SizedBox(height: 16), 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<WalletCubit>().load();
}
},
),
const SizedBox(height: 16),
GameButton( GameButton(
label: 'فروشگاه', label: 'فروشگاه',
icon: Icons.storefront, icon: Icons.storefront,
@@ -111,13 +124,13 @@ class _LobbyScreenState extends State<LobbyScreen> {
} }
class _TopBar extends StatelessWidget { class _TopBar extends StatelessWidget {
final Wallet? wallet; final WalletState walletState;
final VoidCallback onCoinTap; final VoidCallback onCoinTap;
const _TopBar({this.wallet, required this.onCoinTap}); const _TopBar({required this.walletState, required this.onCoinTap});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final w = wallet; final w = walletState.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),
@@ -133,15 +146,23 @@ class _TopBar extends StatelessWidget {
), ),
child: Row( child: Row(
children: [ children: [
Container( GestureDetector(
onTap: () async {
await context.push('/profile');
if (context.mounted) context.read<WalletCubit>().load();
},
child: Container(
width: 48,
height: 48,
padding: const EdgeInsets.all(2),
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: AppColors.panel,
border: Border.all(color: AppColors.gold, width: 2), border: Border.all(color: AppColors.gold, width: 2),
), ),
child: const CircleAvatar( child: walletState.avatar.isEmpty
radius: 22, ? const Icon(Icons.person, color: AppColors.gold)
backgroundColor: AppColors.panel, : ClipOval(child: RandomAvatar(walletState.avatar)),
child: Icon(Icons.person, color: AppColors.gold),
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
@@ -149,6 +170,16 @@ class _TopBar extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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: [ Row(children: [
const Icon(Icons.star, color: AppColors.gold, size: 16), const Icon(Icons.star, color: AppColors.gold, size: 16),
const SizedBox(width: 4), 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)),
);
}
}
+24 -5
View File
@@ -9,11 +9,18 @@ enum WalletStatus { initial, loading, loaded, error }
class WalletState extends Equatable { class WalletState extends Equatable {
final WalletStatus status; final WalletStatus status;
final Wallet? wallet; 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 @override
List<Object?> get props => [status, wallet]; List<Object?> get props => [status, wallet, name, avatar];
} }
class WalletCubit extends Cubit<WalletState> { class WalletCubit extends Cubit<WalletState> {
@@ -21,12 +28,24 @@ class WalletCubit extends Cubit<WalletState> {
WalletCubit(this._api) : super(const WalletState()); WalletCubit(this._api) : super(const WalletState());
Future<void> load() async { Future<void> load() async {
emit(const WalletState(status: WalletStatus.loading)); emit(WalletState(
status: WalletStatus.loading,
wallet: state.wallet,
name: state.name,
avatar: state.avatar));
try { 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( emit(WalletState(
status: WalletStatus.loaded, status: WalletStatus.loaded,
wallet: Wallet.fromJson(Map<String, dynamic>.from(res.data)), 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 (_) { } catch (_) {
emit(const WalletState(status: WalletStatus.error)); emit(const WalletState(status: WalletStatus.error));
@@ -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<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),
],
),
),
),
],
),
),
),
);
}
}
@@ -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<GameCubit, GameUiState>(
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<GameCubit>().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<GameCubit>().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<GameCubit>().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<GameCubit>().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<double>(
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),
),
);
}
}
+535
View File
@@ -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<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,
),
),
);
}
}
+16
View File
@@ -56,12 +56,26 @@ class Booster {
priceToman = (j['price_toman'] ?? 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 { class ShopData {
final List<CoinPackage> coinPackages; final List<CoinPackage> coinPackages;
final List<TicketPackage> ticketPackages; final List<TicketPackage> ticketPackages;
final List<CardSkin> cardSkins; final List<CardSkin> cardSkins;
final List<Booster> boosters; final List<Booster> boosters;
final List<VIPPackage> vipPackages;
final List<String> ownedCards; final List<String> ownedCards;
final String selectedCard; final String selectedCard;
@@ -70,6 +84,7 @@ class ShopData {
required this.ticketPackages, required this.ticketPackages,
required this.cardSkins, required this.cardSkins,
required this.boosters, required this.boosters,
required this.vipPackages,
required this.ownedCards, required this.ownedCards,
required this.selectedCard, required this.selectedCard,
}); });
@@ -85,6 +100,7 @@ class ShopData {
ticketPackages: parse('ticket_packages', TicketPackage.fromJson), ticketPackages: parse('ticket_packages', TicketPackage.fromJson),
cardSkins: parse('card_skins', CardSkin.fromJson), cardSkins: parse('card_skins', CardSkin.fromJson),
boosters: parse('boosters', Booster.fromJson), boosters: parse('boosters', Booster.fromJson),
vipPackages: parse('vip_packages', VIPPackage.fromJson),
ownedCards: ((j['owned_cards'] as List?) ?? []).map((e) => e as String).toList(), ownedCards: ((j['owned_cards'] as List?) ?? []).map((e) => e as String).toList(),
selectedCard: (j['selected_card'] ?? 'simple') as String, selectedCard: (j['selected_card'] ?? 'simple') as String,
); );
+93 -1
View File
@@ -15,7 +15,7 @@ class ShopScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return DefaultTabController( return DefaultTabController(
length: 4, length: 5,
child: Scaffold( child: Scaffold(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
body: GameBackground( body: GameBackground(
@@ -68,6 +68,7 @@ class ShopScreen extends StatelessWidget {
_TicketsTab(packages: d.ticketPackages), _TicketsTab(packages: d.ticketPackages),
_CardsTab(data: d), _CardsTab(data: d),
_BoostersTab(boosters: d.boosters), _BoostersTab(boosters: d.boosters),
_VipTab(packages: d.vipPackages),
], ],
); );
}, },
@@ -140,6 +141,7 @@ class _ShopTabs extends StatelessWidget {
Tab(text: 'بلیط'), Tab(text: 'بلیط'),
Tab(text: 'کارت'), Tab(text: 'کارت'),
Tab(text: 'تجهیزات'), Tab(text: 'تجهیزات'),
Tab(text: 'VIP'),
], ],
); );
} }
@@ -243,6 +245,96 @@ class _BoostersTab extends StatelessWidget {
} }
} }
class _VipTab extends StatelessWidget {
final List<VIPPackage> 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<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});
+201
View File
@@ -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<ShopCubit, ShopState>(
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<ShopCubit>().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<void> _buy(BuildContext context, String id) async {
final msg = await context.read<ShopCubit>().purchase('vip', id);
if (!context.mounted || msg.isEmpty) return;
await context.read<WalletCubit>().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))),
],
),
);
}
}
+73 -1
View File
@@ -1,6 +1,14 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.myket.ir"
source: hosted
version: "2.7.0"
async: async:
dependency: transitive dependency: transitive
description: description:
@@ -262,6 +270,14 @@ packages:
url: "https://pub.myket.ir" url: "https://pub.myket.ir"
source: hosted source: hosted
version: "4.1.0" 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: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -392,6 +408,14 @@ packages:
url: "https://pub.myket.ir" url: "https://pub.myket.ir"
source: hosted source: hosted
version: "1.9.1" 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: path_provider:
dependency: transitive dependency: transitive
description: description:
@@ -440,6 +464,14 @@ packages:
url: "https://pub.myket.ir" url: "https://pub.myket.ir"
source: hosted source: hosted
version: "2.3.0" version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.myket.ir"
source: hosted
version: "7.0.2"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@@ -464,6 +496,14 @@ packages:
url: "https://pub.myket.ir" url: "https://pub.myket.ir"
source: hosted source: hosted
version: "6.1.5+1" 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: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -541,6 +581,30 @@ packages:
url: "https://pub.myket.ir" url: "https://pub.myket.ir"
source: hosted source: hosted
version: "4.5.3" 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: vector_math:
dependency: transitive dependency: transitive
description: description:
@@ -597,6 +661,14 @@ packages:
url: "https://pub.myket.ir" url: "https://pub.myket.ir"
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.myket.ir"
source: hosted
version: "6.6.1"
sdks: sdks:
dart: ">=3.8.0 <4.0.0" dart: ">=3.10.0 <4.0.0"
flutter: ">=3.35.0" flutter: ">=3.35.0"
+1
View File
@@ -42,6 +42,7 @@ dependencies:
equatable: ^2.0.8 equatable: ^2.0.8
flame: ^1.30.1 flame: ^1.30.1
flame_audio: ^2.11.14 flame_audio: ^2.11.14
random_avatar: ^0.0.8
# path_provider_android نسخه‌ی 2.3.x به jni (کد native + NDK) وابسته شد و build را # path_provider_android نسخه‌ی 2.3.x به jni (کد native + NDK) وابسته شد و build را
# سنگین/کند می‌کند. تا نسخه‌ی پیش‌از‑jni پین می‌کنیم تا NDK لازم نباشد. # سنگین/کند می‌کند. تا نسخه‌ی پیش‌از‑jni پین می‌کنیم تا NDK لازم نباشد.