feat: profile and subs
This commit is contained in:
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)),
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 ?? 'خطا')),
|
||||||
|
|||||||
@@ -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 '../../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
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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});
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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 لازم نباشد.
|
||||||
|
|||||||
Reference in New Issue
Block a user