feat: add carpet

This commit is contained in:
2026-07-03 21:56:34 +03:30
parent 5160deb973
commit 7acd72c286
21 changed files with 536 additions and 156 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
"type": "dart", "type": "dart",
"program": "lib/main.dart", "program": "lib/main.dart",
"args": [ "args": [
"--dart-define=BASE_URL=http://192.168.8.210:8080", "--dart-define=BASE_URL=http://192.168.1.105:8080",
"--target-platform=android-arm64" "--target-platform=android-arm64"
] ]
}, },
+1 -1
View File
@@ -8,7 +8,7 @@ class AppConfig {
/// با تغییر شبکه/IP مک، مقدار dart-define یا همین پیش‌فرض را عوض کنید. /// با تغییر شبکه/IP مک، مقدار dart-define یا همین پیش‌فرض را عوض کنید.
static const String baseUrl = String.fromEnvironment( static const String baseUrl = String.fromEnvironment(
'BASE_URL', 'BASE_URL',
defaultValue: 'http://192.168.8.210:8080', defaultValue: 'http://192.168.1.105:8080',
); );
static String get apiUrl => '$baseUrl/api'; static String get apiUrl => '$baseUrl/api';
+2
View File
@@ -31,6 +31,7 @@ import '../../feature/ranked/domain/repository/ranked_repository.dart';
import '../../feature/ranked/domain/use_cases/get_leaderboard_usecase.dart'; import '../../feature/ranked/domain/use_cases/get_leaderboard_usecase.dart';
import '../../feature/ranked/presentation/bloc/leaderboard_bloc.dart'; import '../../feature/ranked/presentation/bloc/leaderboard_bloc.dart';
import '../../feature/shop/data/data_source/remote/shop_api_provider.dart'; import '../../feature/shop/data/data_source/remote/shop_api_provider.dart';
import '../../feature/shop/data/data_source/remote/carpet_api_provider.dart';
import '../../feature/shop/data/repository/shop_repository_impl.dart'; import '../../feature/shop/data/repository/shop_repository_impl.dart';
import '../../feature/shop/domain/repository/shop_repository.dart'; import '../../feature/shop/domain/repository/shop_repository.dart';
import '../../feature/shop/domain/use_cases/ad_reward_usecase.dart'; import '../../feature/shop/domain/use_cases/ad_reward_usecase.dart';
@@ -61,6 +62,7 @@ Future<void> setupLocator() async {
locator.registerSingleton<AuthApiProvider>(AuthApiProvider()); locator.registerSingleton<AuthApiProvider>(AuthApiProvider());
locator.registerSingleton<WalletApiProvider>(WalletApiProvider()); locator.registerSingleton<WalletApiProvider>(WalletApiProvider());
locator.registerSingleton<ShopApiProvider>(ShopApiProvider()); locator.registerSingleton<ShopApiProvider>(ShopApiProvider());
locator.registerSingleton<CarpetApiProvider>(CarpetApiProvider());
locator.registerSingleton<ProfileApiProvider>(ProfileApiProvider()); locator.registerSingleton<ProfileApiProvider>(ProfileApiProvider());
locator.registerSingleton<RankedApiProvider>(RankedApiProvider()); locator.registerSingleton<RankedApiProvider>(RankedApiProvider());
locator.registerSingleton<ChatApiProvider>(ChatApiProvider()); locator.registerSingleton<ChatApiProvider>(ChatApiProvider());
+16
View File
@@ -57,6 +57,22 @@ class CardImages {
} }
} }
/// تصویرِ فرش (برای سطحِ میز) از backend: `/carpets/<id>.jpg`. کش‌شده.
static Future<Sprite?> carpet(String id) {
return _cache.putIfAbsent('carpet/$id', () async {
try {
final res = await _dio
.get<List<int>>('${AppConfig.baseUrl}/carpets/$id.jpg');
final data = res.data;
if (data == null || data.isEmpty) return null;
return Sprite(await _decode(Uint8List.fromList(data)));
} catch (e) {
debugPrint('CardImages: carpet/$id failed: $e');
return null;
}
});
}
static Future<ui.Image> _decode(Uint8List bytes) async { static Future<ui.Image> _decode(Uint8List bytes) async {
final codec = await ui.instantiateImageCodec(bytes); final codec = await ui.instantiateImageCodec(bytes);
final frame = await codec.getNextFrame(); final frame = await codec.getNextFrame();
+38 -2
View File
@@ -53,11 +53,17 @@ class AppTheme {
final base = ThemeData.dark(useMaterial3: true); final base = ThemeData.dark(useMaterial3: true);
return base.copyWith( return base.copyWith(
scaffoldBackgroundColor: AppColors.bg, scaffoldBackgroundColor: AppColors.bg,
primaryColor: AppColors.accent, primaryColor: AppColors.lapis,
canvasColor: AppColors.bg,
cardColor: AppColors.panel,
dividerColor: AppColors.goldFaint,
colorScheme: base.colorScheme.copyWith( colorScheme: base.colorScheme.copyWith(
primary: AppColors.gold, primary: AppColors.gold,
secondary: AppColors.accent, onPrimary: AppColors.lapisInk,
secondary: AppColors.lapis,
tertiary: AppColors.accent,
surface: AppColors.panel, surface: AppColors.panel,
onSurface: AppColors.text,
), ),
textTheme: base.textTheme.apply( textTheme: base.textTheme.apply(
fontFamily: fontFamily, fontFamily: fontFamily,
@@ -69,6 +75,36 @@ class AppTheme {
foregroundColor: AppColors.gold, foregroundColor: AppColors.gold,
centerTitle: true, centerTitle: true,
), ),
// دیالوگ/منو/اسنک‌بار با همان قابِ لاجورد + طلا.
dialogTheme: DialogThemeData(
backgroundColor: AppColors.panel,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
side: const BorderSide(color: AppColors.gold, width: 1.5),
),
titleTextStyle: const TextStyle(
fontFamily: fontFamily,
color: AppColors.gold,
fontSize: 19,
fontWeight: FontWeight.bold),
contentTextStyle:
const TextStyle(fontFamily: fontFamily, color: AppColors.text),
),
snackBarTheme: const SnackBarThemeData(
backgroundColor: AppColors.lapisMid,
contentTextStyle: TextStyle(fontFamily: fontFamily, color: AppColors.text),
behavior: SnackBarBehavior.floating,
),
tabBarTheme: const TabBarThemeData(
labelColor: AppColors.gold,
unselectedLabelColor: Colors.white60,
indicatorColor: AppColors.gold,
),
bottomSheetTheme: const BottomSheetThemeData(
backgroundColor: AppColors.panel,
surfaceTintColor: Colors.transparent,
),
inputDecorationTheme: InputDecorationTheme( inputDecorationTheme: InputDecorationTheme(
filled: true, filled: true,
fillColor: AppColors.bgDark.withValues(alpha: 0.6), fillColor: AppColors.bgDark.withValues(alpha: 0.6),
+33
View File
@@ -1,8 +1,41 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../service/app_sounds.dart'; import '../service/app_sounds.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
/// دکمه‌ی بازگشتِ سراسری: قابِ طلایی هماهنگ با تمِ بازی. اگر [onTap] داده نشود،
/// به‌صورت پیش‌فرض یک صفحه به عقب می‌رود (context.pop()).
class AppBackButton extends StatelessWidget {
final VoidCallback? onTap;
final double size;
const AppBackButton({super.key, this.onTap, this.size = 46});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
AppSounds.button();
if (onTap != null) {
onTap!();
} else {
context.pop();
}
},
child: Container(
width: size,
height: size,
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),
),
);
}
}
/// مجموعه‌ی ویجت‌های گرافیکیِ مشترک برای ظاهرِ بازیِ (Unity-style): /// مجموعه‌ی ویجت‌های گرافیکیِ مشترک برای ظاهرِ بازیِ (Unity-style):
/// پس‌زمینه‌ی گرادیانی، پنل براق، دکمه‌ی جواهرگون، و عنوانِ درخشان. /// پس‌زمینه‌ی گرادیانی، پنل براق، دکمه‌ی جواهرگون، و عنوانِ درخشان.
@@ -50,7 +50,9 @@ class _ChatPanelState extends State<ChatPanel> {
_future = _api.getChatPacks(); _future = _api.getChatPacks();
} }
void _reload() => setState(() => _future = _api.getChatPacks()); void _reload() => setState(() {
_future = _api.getChatPacks();
});
Future<void> _buy(ChatPack p) async { Future<void> _buy(ChatPack p) async {
setState(() => _busyPack = p.id); setState(() => _busyPack = p.id);
@@ -31,6 +31,7 @@ class GameScreen extends StatefulWidget {
class _GameScreenState extends State<GameScreen> { class _GameScreenState extends State<GameScreen> {
late final HokmGame _game; late final HokmGame _game;
String _carpet = 'classic'; // فرشِ انتخابی (برای پس‌زمینه‌ی پشتِ صحنه)
Timer? _introTimer; Timer? _introTimer;
bool _introHidden = false; bool _introHidden = false;
int _countdown = 0; // شمارشِ معکوسِ شروعِ بازی پس از یافتنِ حریفان (۳…۲…۱) int _countdown = 0; // شمارشِ معکوسِ شروعِ بازی پس از یافتنِ حریفان (۳…۲…۱)
@@ -40,9 +41,11 @@ class _GameScreenState extends State<GameScreen> {
super.initState(); super.initState();
final ws = context.read<WalletBloc>().state.walletStatus; final ws = context.read<WalletBloc>().state.walletStatus;
final skin = ws is WalletLoaded ? ws.wallet.selectedCard : 'simple'; final skin = ws is WalletLoaded ? ws.wallet.selectedCard : 'simple';
_carpet = ws is WalletLoaded ? ws.wallet.selectedCarpet : 'classic';
_game = HokmGame( _game = HokmGame(
context.read<GameBloc>(), context.read<GameBloc>(),
skin: skin.isEmpty ? 'simple' : skin, skin: skin.isEmpty ? 'simple' : skin,
carpet: _carpet.isEmpty ? 'classic' : _carpet,
); );
} }
@@ -114,6 +117,7 @@ class _GameScreenState extends State<GameScreen> {
} }
return Stack( return Stack(
children: [ children: [
// فرش به‌عنوانِ سطحِ میز داخلِ صحنه‌ی Flame (Felt) کشیده می‌شود.
GameWidget(game: _game), GameWidget(game: _game),
if (!_showSearch(state) && state.state != null) if (!_showSearch(state) && state.state != null)
TableHud( TableHud(
@@ -65,25 +65,11 @@ class _PrivateEntryScreenState extends State<PrivateEntryScreen> {
final canCreate = unlimited || remaining > 0; final canCreate = unlimited || remaining > 0;
return Column( return Column(
children: [ children: [
Align( const Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: Padding( child: Padding(
padding: const EdgeInsets.all(10), padding: EdgeInsets.all(10),
child: GestureDetector( child: AppBackButton(),
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( Expanded(
@@ -20,18 +20,22 @@ class PrivateTableScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocConsumer<GameBloc, GameUiState>( return BlocConsumer<GameBloc, GameUiState>(
listenWhen: (a, b) => listenWhen:
(a.notice != b.notice && b.notice != null) || (a, b) =>
(!a.tableClosed && b.tableClosed), (a.notice != b.notice && b.notice != null) ||
(!a.tableClosed && b.tableClosed),
listener: (context, state) { listener: (context, state) {
if (state.tableClosed) { if (state.tableClosed) {
if (context.canPop()) context.pop(); if (context.canPop()) context.pop();
return; return;
} }
if (state.notice != null) { if (state.notice != null) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.notice!), content: Text(state.notice!),
duration: const Duration(seconds: 2))); duration: const Duration(seconds: 2),
),
);
context.read<GameBloc>().clearNotice(); context.read<GameBloc>().clearNotice();
} }
}, },
@@ -81,11 +85,14 @@ class _LobbyViewState extends State<_LobbyView> {
children: [ children: [
_topBar(context, lobby), _topBar(context, lobby),
Expanded( Expanded(
child: connecting child:
? const Center( connecting
child: CircularProgressIndicator( ? const Center(
color: AppColors.gold)) child: CircularProgressIndicator(
: _content(context, lobby), color: AppColors.gold,
),
)
: _content(context, lobby),
), ),
], ],
), ),
@@ -102,18 +109,18 @@ class _LobbyViewState extends State<_LobbyView> {
// نوار بالا: بازگشت (چپ) و اشتراک‌گذاریِ کد (راست). // نوار بالا: بازگشت (چپ) و اشتراک‌گذاریِ کد (راست).
Widget _topBar(BuildContext context, TableLobby? lobby) { Widget _topBar(BuildContext context, TableLobby? lobby) {
Widget btn(IconData icon, VoidCallback onTap) => GestureDetector( Widget btn(IconData icon, VoidCallback onTap) => GestureDetector(
onTap: onTap, onTap: onTap,
child: Container( child: Container(
width: 46, width: 46,
height: 46, height: 46,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.panel, color: AppColors.panel,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.gold, width: 1.5), border: Border.all(color: AppColors.gold, width: 1.5),
), ),
child: Icon(icon, color: AppColors.gold), child: Icon(icon, color: AppColors.gold),
), ),
); );
return Padding( return Padding(
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(10),
child: Directionality( child: Directionality(
@@ -121,13 +128,16 @@ class _LobbyViewState extends State<_LobbyView> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
btn(Icons.arrow_back, _leave),
if (lobby != null) if (lobby != null)
btn(Icons.share, () { btn(Icons.share, () {
Clipboard.setData(ClipboardData(text: lobby.code)); Clipboard.setData(ClipboardData(text: lobby.code));
ScaffoldMessenger.of(context).showSnackBar(const SnackBar( ScaffoldMessenger.of(context).showSnackBar(
content: Text('کد میز کپی شد؛ برای دوستانت بفرست'))); const SnackBar(
content: Text('کد میز کپی شد؛ برای دوستانت بفرست'),
),
);
}), }),
btn(Icons.arrow_forward, _leave),
], ],
), ),
), ),
@@ -160,7 +170,9 @@ class _LobbyViewState extends State<_LobbyView> {
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 28, vertical: 36), horizontal: 28,
vertical: 36,
),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: const RadialGradient( gradient: const RadialGradient(
@@ -168,29 +180,42 @@ class _LobbyViewState extends State<_LobbyView> {
radius: 0.9, radius: 0.9,
), ),
borderRadius: BorderRadius.circular(160), borderRadius: BorderRadius.circular(160),
border: Border.all(color: const Color(0xFF8A5A12), width: 6), border: Border.all(
), color: const Color(0xFF8A5A12),
child: const Center( width: 6,
child: GlowText('سلطان حکم', size: 18), ),
), ),
child: const Center(child: GlowText('سلطان حکم', size: 18)),
), ),
), ),
Align(alignment: Alignment.topCenter, child: _seat(top, false)), Align(alignment: Alignment.topCenter, child: _seat(top, false)),
Align(alignment: Alignment.centerLeft, child: _seat(left, false)),
Align( Align(
alignment: Alignment.centerRight, child: _seat(right, false)), alignment: Alignment.centerLeft,
Align(alignment: Alignment.bottomCenter, child: _seat(me, true)), child: _seat(left, false),
),
Align(
alignment: Alignment.centerRight,
child: _seat(right, false),
),
Align(
alignment: Alignment.bottomCenter,
child: _seat(me, true),
),
// چرخشِ جایگاه‌ها (فقط میزبان) — تغییرِ تیم‌بندی. // چرخشِ جایگاه‌ها (فقط میزبان) — تغییرِ تیم‌بندی.
if (lobby.isHost) ...[ if (lobby.isHost) ...[
Align( Align(
alignment: const Alignment(-1, -0.35), alignment: const Alignment(-1, -0.35),
child: _rotateBtn(Icons.rotate_left, child: _rotateBtn(
() => context.read<GameBloc>().rotateTable(-1)), Icons.rotate_left,
() => context.read<GameBloc>().rotateTable(-1),
),
), ),
Align( Align(
alignment: const Alignment(1, -0.35), alignment: const Alignment(1, -0.35),
child: _rotateBtn(Icons.rotate_right, child: _rotateBtn(
() => context.read<GameBloc>().rotateTable(1)), Icons.rotate_right,
() => context.read<GameBloc>().rotateTable(1),
),
), ),
], ],
], ],
@@ -215,12 +240,16 @@ class _LobbyViewState extends State<_LobbyView> {
onTap: () => context.read<GameBloc>().startTable(_hands), onTap: () => context.read<GameBloc>().startTable(_hands),
) )
else else
const Text('در انتظار شروع توسط میزبان…', const Text(
style: TextStyle(color: AppColors.gold, fontSize: 15)), 'در انتظار شروع توسط میزبان…',
style: TextStyle(color: AppColors.gold, fontSize: 15),
),
const SizedBox(height: 6), const SizedBox(height: 6),
if (lobby.isHost) if (lobby.isHost)
const Text('جای‌های خالی با ربات پر می‌شوند', const Text(
style: TextStyle(color: Colors.white54, fontSize: 12)), 'جای‌های خالی با ربات پر می‌شوند',
style: TextStyle(color: Colors.white54, fontSize: 12),
),
const SizedBox(height: 20), const SizedBox(height: 20),
], ],
), ),
@@ -244,15 +273,20 @@ class _LobbyViewState extends State<_LobbyView> {
color: AppColors.bgDark, color: AppColors.bgDark,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: isYou ? AppColors.gold : AppColors.goldDark, color: isYou ? AppColors.gold : AppColors.goldDark,
width: 2), width: 2,
),
), ),
child: empty child:
? const Icon(Icons.person_add_alt_1, color: Colors.white24) empty
: ClipRRect( ? const Icon(
borderRadius: BorderRadius.circular(9), Icons.person_add_alt_1,
child: RandomAvatar(p.name), color: Colors.white24,
), )
: ClipRRect(
borderRadius: BorderRadius.circular(9),
child: RandomAvatar(p.name),
),
), ),
if (!empty) if (!empty)
const Positioned( const Positioned(
@@ -274,8 +308,10 @@ class _LobbyViewState extends State<_LobbyView> {
), ),
), ),
if (p?.host == true) if (p?.host == true)
const Text('میزبان', const Text(
style: TextStyle(color: AppColors.gold, fontSize: 10)), 'میزبان',
style: TextStyle(color: AppColors.gold, fontSize: 10),
),
], ],
); );
} }
@@ -314,14 +350,17 @@ class _LobbyViewState extends State<_LobbyView> {
alignment: Alignment.center, alignment: Alignment.center,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
gradient: selected gradient:
? const LinearGradient( selected
colors: [AppColors.gold, AppColors.goldDark]) ? const LinearGradient(
: null, colors: [AppColors.gold, AppColors.goldDark],
)
: null,
color: selected ? null : AppColors.panel, color: selected ? null : AppColors.panel,
border: Border.all( border: Border.all(
color: selected ? AppColors.gold : AppColors.goldDark, color: selected ? AppColors.gold : AppColors.goldDark,
width: 2), width: 2,
),
), ),
child: Text( child: Text(
fa[h]!, fa[h]!,
@@ -377,8 +416,9 @@ class _CountdownOverlayState extends State<_CountdownOverlay> {
tween: Tween(begin: 0.4, end: 1.2), tween: Tween(begin: 0.4, end: 1.2),
duration: const Duration(milliseconds: 700), duration: const Duration(milliseconds: 700),
curve: Curves.easeOut, curve: Curves.easeOut,
builder: (context, scale, child) => builder:
Transform.scale(scale: scale, child: child), (context, scale, child) =>
Transform.scale(scale: scale, child: child),
child: GlowText(_n > 0 ? '$_n' : 'شروع!', size: 96), child: GlowText(_n > 0 ? '$_n' : 'شروع!', size: 96),
), ),
); );
@@ -20,7 +20,7 @@ class TierListScreen extends StatelessWidget {
Padding( Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: Row(children: [ child: Row(children: [
_BackButton(onTap: () => context.pop()), const AppBackButton(),
const Spacer(), const Spacer(),
const GlowText('انتخاب میز', size: 26), const GlowText('انتخاب میز', size: 26),
const Spacer(), const Spacer(),
@@ -64,25 +64,6 @@ class TierListScreen extends StatelessWidget {
} }
} }
class _BackButton extends StatelessWidget {
final VoidCallback onTap;
const _BackButton({required this.onTap});
@override
Widget build(BuildContext context) => GestureDetector(
onTap: onTap,
child: Container(
width: 48,
height: 48,
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),
),
);
}
class _TierCard extends StatelessWidget { class _TierCard extends StatelessWidget {
final TableTier tier; final TableTier tier;
final int index; final int index;
@@ -69,14 +69,18 @@ class HokmGame extends FlameGame {
double _hbAccum = 0; // انباشتِ زمان برای تکرارِ منظمِ ضربان تا پایانِ نوبت double _hbAccum = 0; // انباشتِ زمان برای تکرارِ منظمِ ضربان تا پایانِ نوبت
bool _wasMyTurn = false; bool _wasMyTurn = false;
HokmGame(this.cubit, {this.skin = 'simple'}); /// فرشِ انتخابی؛ 'classic' یعنی سطحِ میزِ ترسیمی. فرشِ تصویری به‌عنوانِ سطحِ
/// میز (بریده در شکلِ میز) روی زمینِ لاجوردی کشیده می‌شود.
final String carpet;
HokmGame(this.cubit, {this.skin = 'simple', this.carpet = 'classic'});
@override @override
Color backgroundColor() => AppColors.lapisNight; Color backgroundColor() => AppColors.lapisNight;
@override @override
Future<void> onLoad() async { Future<void> onLoad() async {
add(Felt()); add(Felt(carpet: carpet));
_sub = cubit.stream.listen((ui) { _sub = cubit.stream.listen((ui) {
if (ui.state == null) return; if (ui.state == null) return;
final s = ui.state!; final s = ui.state!;
@@ -3,22 +3,39 @@ import 'dart:math' as math;
import 'package:flame/components.dart'; import 'package:flame/components.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../../../core/network/card_images.dart';
import '../../../../../core/theme/app_theme.dart'; import '../../../../../core/theme/app_theme.dart';
// رنگ‌های مشترکِ میز (منبعِ واحد در AppColors). // رنگ‌های مشترکِ میز (منبعِ واحد در AppColors).
const _gold = AppColors.gold; const _gold = AppColors.gold;
const _goldDark = AppColors.goldDark; const _goldDark = AppColors.goldDark;
/// میز: زمینِ سبزِ نمدی در کلِ صفحه + یک «میزِ» مرکزیِ سرمه‌ای با قابِ طلاییِ /// میز: زمینِ لاجوردیِ نمدی در کلِ صفحه + یک «میزِ» مرکزی با قابِ طلاییِ دولایه.
/// دولایه (مطابقِ تصویرِ مرجع). کارت‌های زمین داخلِ ناحیه‌ی سرمه‌ای انداخته می‌شوند /// اگر فرشی انتخاب شده باشد، سطحِ میز تصویرِ فرش است (بریده‌شده در شکلِ میز)؛
/// و پشت‌کارتِ حریفان روی نمدِ سبزِ دورِ آن می‌نشیند. /// وگرنه سطحِ سرمه‌ایِ ترسیمی. کارت‌ها داخلِ همین ناحیه انداخته می‌شوند.
class Felt extends PositionComponent with HasGameReference { class Felt extends PositionComponent with HasGameReference {
/// شناسه‌ی فرشِ انتخابی؛ 'classic' یعنی سطحِ ترسیمی (بدون تصویر).
final String carpet;
Felt({this.carpet = 'classic'});
Sprite? _carpetSprite;
bool get _hasCarpet => carpet != 'classic';
@override
Future<void> onLoad() async {
if (_hasCarpet) {
_carpetSprite = await CardImages.carpet(carpet);
}
}
@override @override
void render(Canvas canvas) { void render(Canvas canvas) {
final w = game.size.x, h = game.size.y; final w = game.size.x, h = game.size.y;
final center = Offset(w / 2, h / 2); final center = Offset(w / 2, h / 2);
// ۱) زمینِ لاجوردیِ نمدی (کلِ صفحه) با وینیتِ شعاعی برای عمق. // ۱) زمینِ لاجوردیِ نمدی (کلِ صفحه) با وینیتِ شعاعی برای عمق. همیشه کشیده
// می‌شود تا زیرِ میز/فرش یکدست باشد و لبه‌های فرش با پس‌زمینه جور شود.
canvas.drawRect( canvas.drawRect(
Rect.fromLTWH(0, 0, w, h), Rect.fromLTWH(0, 0, w, h),
Paint() Paint()
@@ -30,7 +47,7 @@ class Felt extends PositionComponent with HasGameReference {
).createShader(Rect.fromLTWH(0, 0, w, h)), ).createShader(Rect.fromLTWH(0, 0, w, h)),
); );
// ۲) میزِ مرکزیِ سرمه‌ای با قابِ طلایی. // ۲) میزِ مرکزی با قابِ طلایی.
final rect = Rect.fromCenter( final rect = Rect.fromCenter(
center: center, width: w * 0.72, height: h * 0.46); center: center, width: w * 0.72, height: h * 0.46);
final radius = Radius.circular(h * 0.05); final radius = Radius.circular(h * 0.05);
@@ -55,17 +72,32 @@ class Felt extends PositionComponent with HasGameReference {
).createShader(rect), ).createShader(rect),
); );
// سطحِ سرمه‌ایِ میز با گرادیانِ شعاعی (مرکز روشن‌تر). // سطحِ میز: تصویرِ فرش (بریده در شکلِ میز) یا سطحِ سرمه‌ایِ ترسیمی.
canvas.drawRRect( final sprite = _carpetSprite;
table, if (_hasCarpet && sprite != null) {
Paint() canvas.save();
..shader = RadialGradient( canvas.clipRRect(table);
center: Alignment.center, // فرش را به‌صورتِ cover داخلِ مستطیلِ میز می‌کشیم.
radius: 0.9, sprite.render(
colors: const [AppColors.lapisLo, AppColors.lapisInk], canvas,
stops: const [0.4, 1.0], position: Vector2(rect.left, rect.top),
).createShader(rect), size: Vector2(rect.width, rect.height),
); );
// تیره‌ترِ ملایم برای خوانایی کارت‌ها روی فرش.
canvas.drawRRect(table, Paint()..color = const Color(0x1A000000));
canvas.restore();
} else {
canvas.drawRRect(
table,
Paint()
..shader = RadialGradient(
center: Alignment.center,
radius: 0.9,
colors: const [AppColors.lapisLo, AppColors.lapisInk],
stops: const [0.4, 1.0],
).createShader(rect),
);
}
// خطِ طلاییِ نازکِ داخلی (قابِ دولایه). // خطِ طلاییِ نازکِ داخلی (قابِ دولایه).
canvas.drawRRect( canvas.drawRRect(
@@ -92,10 +92,7 @@ class ProfileScreen extends StatelessWidget {
children: [ children: [
Row( Row(
children: [ children: [
IconButton( const AppBackButton(),
onPressed: () => context.pop(),
icon: const Icon(Icons.arrow_back, color: AppColors.gold),
),
const Spacer(), const Spacer(),
const GlowText('پروفایل', size: 24), const GlowText('پروفایل', size: 24),
const Spacer(), const Spacer(),
@@ -1,6 +1,5 @@
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:random_avatar/random_avatar.dart'; import 'package:random_avatar/random_avatar.dart';
import '../../../../core/theme/app_theme.dart'; import '../../../../core/theme/app_theme.dart';
@@ -21,10 +20,7 @@ class LeaderboardScreen extends StatelessWidget {
child: Column( child: Column(
children: [ children: [
Row(children: [ Row(children: [
IconButton( const AppBackButton(),
onPressed: () => context.pop(),
icon: const Icon(Icons.arrow_back, color: AppColors.gold),
),
const Spacer(), const Spacer(),
const GlowText('رتبه‌بندی فصل', size: 22), const GlowText('رتبه‌بندی فصل', size: 22),
const Spacer(), const Spacer(),
@@ -0,0 +1,45 @@
import '../../../../../core/locator/locator.dart';
import '../../../../../core/network/api_provider_imp.dart';
import '../../../domain/entities/carpet.dart';
/// دادهٔ فرش‌ها از backend (دریافت، خرید، انتخاب).
class CarpetApiProvider {
ApiProviderImp get _api => locator<ApiProviderImp>();
Future<CarpetCatalog> getCarpets() async {
final res = await _api.get('/carpets');
if (res.statusCode != 200) {
throw Exception('carpets: ${res.statusCode}');
}
final list = (res.data['carpets'] as List?) ?? const [];
return CarpetCatalog(
carpets: list
.map((e) => Carpet.fromJson(Map<String, dynamic>.from(e as Map)))
.toList(),
selected: (res.data['selected'] ?? 'classic') as String,
);
}
/// خرید؛ null یعنی موفق، وگرنه پیامِ خطای فارسی.
Future<String?> buyCarpet(String id) =>
_post('/shop/buy-carpet', id);
/// انتخاب؛ null یعنی موفق، وگرنه پیامِ خطای فارسی.
Future<String?> selectCarpet(String id) =>
_post('/shop/select-carpet', id);
Future<String?> _post(String path, String id) async {
final res = await _api.post(path, body: {'carpet_id': id});
if (res.statusCode == 200) return null;
switch (res.statusCode) {
case 402:
return 'سکهٔ کافی ندارید';
case 403:
return 'این فرش را ندارید';
case 409:
return 'این فرش را دارید';
default:
return 'عملیات ناموفق بود';
}
}
}
@@ -0,0 +1,38 @@
import '../../../../core/config.dart';
/// یک فرشِ ایرانی که به‌جای میزِ بازی استفاده می‌شود. تصویر از backend می‌آید.
class Carpet {
final String id;
final String title;
final int priceCoins;
final bool vip; // برای VIP رایگان
final bool owned;
const Carpet({
required this.id,
required this.title,
required this.priceCoins,
required this.vip,
required this.owned,
});
/// 'classic' یعنی میزِ ترسیمیِ پیش‌فرض (بدون تصویر).
bool get isClassic => id == 'classic';
String get imageUrl => '${AppConfig.baseUrl}/carpets/$id.jpg';
factory Carpet.fromJson(Map<String, dynamic> j) => Carpet(
id: (j['id'] ?? '') as String,
title: (j['title'] ?? '') as String,
priceCoins: (j['price_coins'] ?? 0) as int,
vip: (j['vip'] ?? false) as bool,
owned: (j['owned'] ?? false) as bool,
);
}
/// نتیجه‌ی دریافتِ فرش‌ها: فهرست + شناسه‌ی فرشِ انتخابی.
class CarpetCatalog {
final List<Carpet> carpets;
final String selected;
const CarpetCatalog({required this.carpets, required this.selected});
}
@@ -1,14 +1,16 @@
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 '../../../../core/config.dart'; import '../../../../core/config.dart';
import '../../../../core/locator/locator.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 '../../../wallet/presentation/bloc/wallet_bloc.dart'; import '../../../wallet/presentation/bloc/wallet_bloc.dart';
import '../../../wallet/presentation/bloc/wallet_event.dart'; import '../../../wallet/presentation/bloc/wallet_event.dart';
import '../../../wallet/presentation/bloc/wallet_state.dart'; import '../../../wallet/presentation/bloc/wallet_state.dart';
import '../../../wallet/presentation/bloc/wallet_status.dart'; import '../../../wallet/presentation/bloc/wallet_status.dart';
import '../../data/data_source/remote/carpet_api_provider.dart';
import '../../domain/entities/carpet.dart';
import '../../domain/entities/shop_entities.dart'; import '../../domain/entities/shop_entities.dart';
import '../bloc/shop_bloc.dart'; import '../bloc/shop_bloc.dart';
import '../bloc/shop_event.dart'; import '../bloc/shop_event.dart';
@@ -22,7 +24,7 @@ class ShopScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return DefaultTabController( return DefaultTabController(
length: 5, length: 6,
child: Scaffold( child: Scaffold(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
body: GameBackground( body: GameBackground(
@@ -85,6 +87,7 @@ class ShopScreen extends StatelessWidget {
_CoinsTab(packages: d.coinPackages), _CoinsTab(packages: d.coinPackages),
_TicketsTab(packages: d.ticketPackages), _TicketsTab(packages: d.ticketPackages),
_CardsTab(data: d), _CardsTab(data: d),
const _CarpetsTab(),
_BoostersTab(boosters: d.boosters), _BoostersTab(boosters: d.boosters),
_VipTab(packages: d.vipPackages), _VipTab(packages: d.vipPackages),
], ],
@@ -114,19 +117,7 @@ class ShopScreen extends StatelessWidget {
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: Row( child: Row(
children: [ children: [
GestureDetector( const AppBackButton(),
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),
),
),
const Spacer(), const Spacer(),
BlocBuilder<WalletBloc, WalletBlocState>( BlocBuilder<WalletBloc, WalletBlocState>(
builder: (context, s) { builder: (context, s) {
@@ -164,7 +155,7 @@ class _ShopTabs extends StatelessWidget {
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [Color(0xFFC62828), Color(0xFF7B0E14)], colors: [AppColors.panel, AppColors.lapis],
), ),
borderRadius: BorderRadius.vertical(bottom: Radius.circular(12)), borderRadius: BorderRadius.vertical(bottom: Radius.circular(12)),
), ),
@@ -179,6 +170,7 @@ class _ShopTabs extends StatelessWidget {
Tab(text: 'سکه'), Tab(text: 'سکه'),
Tab(text: 'بلیط'), Tab(text: 'بلیط'),
Tab(text: 'کارت'), Tab(text: 'کارت'),
Tab(text: 'فرش'),
Tab(text: 'تجهیزات'), Tab(text: 'تجهیزات'),
Tab(text: 'VIP'), Tab(text: 'VIP'),
], ],
@@ -349,6 +341,179 @@ class _CardsTab extends StatelessWidget {
} }
} }
/// تبِ فرش‌ها: پیش‌نمایشِ تصویرِ هر فرش از backend با خرید (سکه/VIP) و انتخاب.
/// داده‌ها مستقل از ShopBloc از /api/carpets گرفته می‌شوند.
class _CarpetsTab extends StatefulWidget {
const _CarpetsTab();
@override
State<_CarpetsTab> createState() => _CarpetsTabState();
}
class _CarpetsTabState extends State<_CarpetsTab> {
final _api = locator<CarpetApiProvider>();
late Future<CarpetCatalog> _future;
String? _busy;
@override
void initState() {
super.initState();
_future = _api.getCarpets();
}
void _reload() => setState(() {
_future = _api.getCarpets();
});
Future<void> _act(Carpet c, {required bool buy}) async {
setState(() => _busy = c.id);
final err = buy ? await _api.buyCarpet(c.id) : await _api.selectCarpet(c.id);
if (!mounted) return;
setState(() => _busy = null);
if (err == null) {
context.read<WalletBloc>().add(LoadWalletEvent());
_reload();
} else {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(err)));
}
}
@override
Widget build(BuildContext context) {
return FutureBuilder<CarpetCatalog>(
future: _future,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(
child: CircularProgressIndicator(color: AppColors.gold));
}
if (!snap.hasData) {
return Center(
child: TextButton(
onPressed: _reload, child: const Text('تلاش مجدد')),
);
}
final cat = snap.data!;
return _grid(
note: 'فرشِ زیرِ میز را عوض کن؛ بعضی فرش‌ها ویژه‌ی VIP هستند.',
children: [
for (final c in cat.carpets)
_CarpetCard(
carpet: c,
selected: cat.selected == c.id,
busy: _busy == c.id,
onSelect: () => _act(c, buy: false),
onBuy: () => _act(c, buy: true),
),
],
);
},
);
}
}
class _CarpetCard extends StatelessWidget {
final Carpet carpet;
final bool selected;
final bool busy;
final VoidCallback onSelect;
final VoidCallback onBuy;
const _CarpetCard({
required this.carpet,
required this.selected,
required this.busy,
required this.onSelect,
required this.onBuy,
});
@override
Widget build(BuildContext context) {
final c = carpet;
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF6E1322), Color(0xFF3A0A12)],
),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: selected ? AppColors.gold : AppColors.goldDark,
width: selected ? 2 : 1.4),
),
child: Column(
children: [
Text(c.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.gold, fontWeight: FontWeight.bold)),
const SizedBox(height: 6),
Expanded(child: _preview(c)),
const SizedBox(height: 6),
SizedBox(width: double.infinity, child: _action(c)),
],
),
);
}
Widget _preview(Carpet c) {
final frame = ClipRRect(
borderRadius: BorderRadius.circular(8),
child: c.isClassic
? Container(
color: const Color(0xFF0E3614),
child: const Center(
child: Icon(Icons.casino, color: AppColors.gold, size: 36)),
)
: Image.network(
c.imageUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
color: const Color(0xFF2A0710),
child: const Icon(Icons.image_not_supported,
color: AppColors.goldDark)),
),
);
return Stack(fit: StackFit.expand, children: [
frame,
if (c.vip && !c.owned)
const Positioned(
top: 4,
left: 4,
child: Chip(
label: Text('VIP',
style: TextStyle(
color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold)),
backgroundColor: AppColors.goldDark,
padding: EdgeInsets.zero,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
]);
}
Widget _action(Carpet c) {
if (busy) {
return const _PriceButton(label: '...', disabled: true);
}
if (selected) {
return const _PriceButton(label: 'انتخاب شده', disabled: true);
}
if (c.owned) {
return _PriceButton(label: 'انتخاب', green: true, onTap: onSelect);
}
// قفل: VIP رایگان برای VIP (که owned می‌شود)، وگرنه خرید با سکه.
return _PriceButton(
label: '${c.priceCoins} سکه',
green: true,
coin: true,
onTap: onBuy,
);
}
}
Widget _grid({required String note, required List<Widget> children}) { Widget _grid({required String note, required List<Widget> children}) {
return Column( return Column(
children: [ children: [
@@ -506,13 +671,16 @@ class _CardSkinArt extends StatelessWidget {
child: Image.network( child: Image.network(
_url(file), _url(file),
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container( errorBuilder:
color: const Color(0xFF0E3C73), (_, __, ___) => Container(
child: const Icon(Icons.style, color: AppColors.gold), color: const Color(0xFF0E3C73),
), child: const Icon(Icons.style, color: AppColors.gold),
loadingBuilder: (ctx, child, progress) => progress == null ),
? child loadingBuilder:
: Container(color: const Color(0xFF0A2547)), (ctx, child, progress) =>
progress == null
? child
: Container(color: const Color(0xFF0A2547)),
), ),
), ),
), ),
@@ -1,6 +1,5 @@
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 '../../../../core/theme/app_theme.dart'; import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/game_ui.dart'; import '../../../../core/widgets/game_ui.dart';
@@ -60,10 +59,7 @@ class VipScreen extends StatelessWidget {
return Column( return Column(
children: [ children: [
Row(children: [ Row(children: [
IconButton( const AppBackButton(),
onPressed: () => context.pop(),
icon: const Icon(Icons.arrow_back, color: AppColors.gold),
),
const Spacer(), const Spacer(),
const GlowText('اشتراک VIP', size: 24), const GlowText('اشتراک VIP', size: 24),
const Spacer(), const Spacer(),
@@ -12,6 +12,7 @@ class WalletModel extends WalletEntity {
required super.xpForNext, required super.xpForNext,
required super.vip, required super.vip,
required super.selectedCard, required super.selectedCard,
required super.selectedCarpet,
required super.name, required super.name,
required super.avatar, required super.avatar,
}); });
@@ -32,6 +33,7 @@ class WalletModel extends WalletEntity {
xpForNext: (wallet['xp_for_next'] ?? 1) as int, xpForNext: (wallet['xp_for_next'] ?? 1) as int,
vip: (wallet['vip'] ?? false) as bool, vip: (wallet['vip'] ?? false) as bool,
selectedCard: (wallet['selected_card'] ?? 'simple') as String, selectedCard: (wallet['selected_card'] ?? 'simple') as String,
selectedCarpet: (wallet['selected_carpet'] ?? 'classic') as String,
name: (name == null || name.isEmpty) ? 'بازیکن' : name, name: (name == null || name.isEmpty) ? 'بازیکن' : name,
avatar: (avatar == null || avatar.isEmpty) ? 'hakem-1' : avatar, avatar: (avatar == null || avatar.isEmpty) ? 'hakem-1' : avatar,
); );
@@ -9,6 +9,7 @@ class WalletEntity {
final int xpForNext; final int xpForNext;
final bool vip; final bool vip;
final String selectedCard; final String selectedCard;
final String selectedCarpet;
final String name; final String name;
final String avatar; final String avatar;
@@ -22,6 +23,7 @@ class WalletEntity {
required this.xpForNext, required this.xpForNext,
required this.vip, required this.vip,
required this.selectedCard, required this.selectedCard,
required this.selectedCarpet,
required this.name, required this.name,
required this.avatar, required this.avatar,
}); });