feat: profile and subs
This commit is contained in:
@@ -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<Object?> get props => [status, mobile, error];
|
||||
List<Object?> get props => [status, mobile, needsProfile, error];
|
||||
}
|
||||
|
||||
class AuthCubit extends Cubit<AuthState> {
|
||||
@@ -45,13 +52,25 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
Future<void> 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<bool> saveProfile(String name, String avatar) async {
|
||||
try {
|
||||
await _repo.updateProfile(name, avatar);
|
||||
emit(state.copyWith(needsProfile: false));
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await _repo.logout();
|
||||
emit(const AuthState());
|
||||
|
||||
@@ -13,8 +13,9 @@ class AuthRepository {
|
||||
await _api.dio.post('/auth/login-otp', data: {'mobile': mobile});
|
||||
}
|
||||
|
||||
/// اعتبارسنجی کد و ذخیرهی توکن JWT.
|
||||
Future<void> verifyOtp(String mobile, String code) async {
|
||||
/// اعتبارسنجی کد، ذخیرهی توکن JWT و بازگرداندنِ اینکه کاربر نام دارد یا نه.
|
||||
/// اگر نام نداشته باشد، فرانت کاربر را به صفحهی انتخاب نام/آواتار میبرد.
|
||||
Future<bool> 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<void> updateProfile(String firstName, String avatar) async {
|
||||
await _api.dio.post('/profile',
|
||||
data: {'first_name': firstName, 'avatar': avatar});
|
||||
}
|
||||
|
||||
Future<bool> isLoggedIn() async {
|
||||
|
||||
@@ -33,7 +33,7 @@ class _OtpScreenState extends State<OtpScreen> {
|
||||
child: BlocConsumer<AuthCubit, AuthState>(
|
||||
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 ?? 'خطا')),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<LobbyPlayer> players;
|
||||
final bool isHost;
|
||||
final int remaining;
|
||||
final bool unlimited;
|
||||
const TableLobby({
|
||||
required this.code,
|
||||
required this.players,
|
||||
required this.isHost,
|
||||
required this.remaining,
|
||||
required this.unlimited,
|
||||
});
|
||||
|
||||
factory TableLobby.fromJson(Map<String, dynamic> j) => TableLobby(
|
||||
code: (j['code'] ?? '') as String,
|
||||
players: ((j['players'] as List?) ?? [])
|
||||
.map((e) => LobbyPlayer(
|
||||
(e['name'] ?? '') as String, (e['host'] ?? false) as bool))
|
||||
.toList(),
|
||||
isHost: (j['host'] ?? false) as bool,
|
||||
remaining: (j['remaining'] ?? 0) as int,
|
||||
unlimited: (j['unlimited'] ?? false) as bool,
|
||||
);
|
||||
|
||||
String get sig => '$code|$isHost|$remaining|$unlimited|'
|
||||
'${players.map((p) => '${p.name}${p.host ? '*' : ''}').join(',')}';
|
||||
}
|
||||
|
||||
class GameUiState extends Equatable {
|
||||
final WsStatus connection;
|
||||
final GameState? state;
|
||||
final HandResult? handResult; // اوورلی نتیجهی هَند (گذرا)
|
||||
final GameOver? gameOver; // اوورلی پایان بازی
|
||||
final String? notice; // پیام گذرا (خطا/خروج بازیکن)
|
||||
final TableLobby? lobby; // اتاق انتظارِ میز خصوصی (پیش از شروع)
|
||||
final int? countdown; // شمارش معکوس پیش از شروعِ بازیِ خصوصی
|
||||
final bool tableClosed; // میز خصوصی منحل شد (میزبان خارج شد)
|
||||
|
||||
const GameUiState({
|
||||
this.connection = WsStatus.connecting,
|
||||
@@ -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<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> {
|
||||
final WsClient _ws;
|
||||
final String tier;
|
||||
|
||||
/// اقدامِ ورود پس از اتصال (یکبار). پیشفرض: ورود به صفِ عمومی.
|
||||
/// برای میز خصوصی: {'type':'create_table'} یا {'type':'join_table','code':...}.
|
||||
final Map<String, dynamic> _joinAction;
|
||||
bool _joined = false;
|
||||
|
||||
late final StreamSubscription _msgSub;
|
||||
late final StreamSubscription _statusSub;
|
||||
|
||||
GameCubit(this._ws, this.tier) : 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);
|
||||
_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<GameUiState> {
|
||||
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<GameUiState> {
|
||||
|
||||
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
|
||||
|
||||
@@ -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<LobbyScreen> {
|
||||
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<LobbyScreen> {
|
||||
},
|
||||
),
|
||||
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(
|
||||
label: 'فروشگاه',
|
||||
icon: Icons.storefront,
|
||||
@@ -111,13 +124,13 @@ class _LobbyScreenState extends State<LobbyScreen> {
|
||||
}
|
||||
|
||||
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<WalletCubit>().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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Object?> get props => [status, wallet];
|
||||
List<Object?> get props => [status, wallet, name, avatar];
|
||||
}
|
||||
|
||||
class WalletCubit extends Cubit<WalletState> {
|
||||
@@ -21,12 +28,24 @@ class WalletCubit extends Cubit<WalletState> {
|
||||
WalletCubit(this._api) : super(const WalletState());
|
||||
|
||||
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 {
|
||||
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<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 (_) {
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<String, dynamic> j)
|
||||
: id = j['id'] as String,
|
||||
title = j['title'] as String,
|
||||
months = (j['months'] ?? 1) as int,
|
||||
priceToman = (j['price_toman'] ?? 0) as int;
|
||||
}
|
||||
|
||||
/// دادهی کامل فروشگاه: کاتالوگ + کارتهای متعلق به کاربر + کارت انتخابی.
|
||||
class ShopData {
|
||||
final List<CoinPackage> coinPackages;
|
||||
final List<TicketPackage> ticketPackages;
|
||||
final List<CardSkin> cardSkins;
|
||||
final List<Booster> boosters;
|
||||
final List<VIPPackage> vipPackages;
|
||||
final List<String> ownedCards;
|
||||
final String selectedCard;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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<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 {
|
||||
final ShopData data;
|
||||
const _CardsTab({required this.data});
|
||||
|
||||
@@ -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))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user